Compare commits

..
Author SHA1 Message Date
l0ng-ai e81b7c1f36 fix(github): expose review settings in the native settings window 2026-11-08 15:59:58 +08:00
96 changed files with 1228 additions and 2251 deletions

No files matched your search

Generated
+37 -37
View File
@@ -4106,7 +4106,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"dunce",
"encoding_rs",
@@ -4129,7 +4129,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4143,7 +4143,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"ide-core",
"serde",
@@ -4154,7 +4154,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"blake3",
"dirs 6.0.0",
@@ -4170,7 +4170,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4179,7 +4179,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"dunce",
"encoding_rs",
@@ -4196,7 +4196,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-run",
@@ -4210,7 +4210,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"futures",
"ide-core",
@@ -4231,7 +4231,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4258,7 +4258,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"ide-core",
"serde",
@@ -4271,7 +4271,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4291,7 +4291,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4303,7 +4303,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4316,7 +4316,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4378,7 +4378,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"ide-core",
"parking_lot",
@@ -4396,7 +4396,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"blake3",
"criterion",
@@ -4418,7 +4418,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"encoding_rs",
"futures",
@@ -4435,7 +4435,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"blake3",
"criterion",
@@ -4491,7 +4491,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"dunce",
"futures",
@@ -4517,7 +4517,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"futures",
"ide-core",
@@ -4540,7 +4540,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"blake3",
"ide-core",
@@ -4561,7 +4561,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-run",
@@ -4574,7 +4574,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"globset",
"ide-core",
@@ -4592,7 +4592,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"dunce",
"encoding_rs",
@@ -4616,7 +4616,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"dunce",
"ide-core",
@@ -4635,7 +4635,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"criterion",
"globset",
@@ -4655,7 +4655,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"ide-core",
"parking_lot",
@@ -4668,7 +4668,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4689,7 +4689,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-language",
@@ -4704,7 +4704,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"ide-core",
"libc",
@@ -4717,7 +4717,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"criterion",
"ide-core",
@@ -4730,7 +4730,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"flate2",
"hex",
@@ -4751,7 +4751,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"blake3",
"dunce",
@@ -4772,7 +4772,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"chardetng",
"dunce",
@@ -4796,7 +4796,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"ide-core",
"open",
@@ -4812,7 +4812,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"blake3",
"dunce",
@@ -6358,7 +6358,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.39"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.39"
version = "0.1.38"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+6 -64
View File
@@ -35,32 +35,20 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
});
}
/// 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"))
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"))
}
/// 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| is_bypass_mode(&v.value))?;
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"
})?;
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> {
@@ -279,52 +267,6 @@ 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": [
+1 -6
View File
@@ -1581,7 +1581,6 @@ impl Task {
.collect()
};
if due.is_empty() {
self.approve_pending();
return;
}
let task = self.clone();
@@ -1611,7 +1610,6 @@ 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();
});
@@ -1728,9 +1726,6 @@ 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) {
@@ -1862,7 +1857,7 @@ impl Task {
fn approve_pending(&self) {
let s = self.state.lock();
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
return;
}
let mut pending = self.permissions.lock();
+2 -161
View File
@@ -35,8 +35,6 @@ 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>>,
@@ -130,13 +128,6 @@ 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" => {
@@ -209,29 +200,6 @@ 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()
@@ -1470,12 +1438,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(&t.state.lock().task.config));
assert!(config::bypass_approvals("cursor", &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(&mgr.shared.known_config("cursor")));
assert!(config::bypass_approvals("cursor", &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;
@@ -1566,133 +1534,6 @@ 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();
-21
View File
@@ -692,27 +692,6 @@ 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();
-4
View File
@@ -395,10 +395,6 @@ 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();
}
-19
View File
@@ -151,25 +151,6 @@ 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 = [
-29
View File
@@ -396,35 +396,6 @@ 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",
+3 -4
View File
@@ -112,10 +112,9 @@ for (const theme of ["dark", "light"] as const) {
});
expect(geometry.scroll).toBe(geometry.client);
expect(geometry.contained).toBe(true);
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.promptRadius).toBeLessThanOrEqual(4);
expect(geometry.replyBackground).not.toBe("rgba(0, 0, 0, 0)");
expect(geometry.replyBorder).toBe("1px");
expect(geometry.userOnRight).toBe(true);
expect(geometry.approvalAligned).toBe(true);
expect(geometry.authorBeforeWork).toBe(true);
-59
View File
@@ -1,59 +0,0 @@
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);
});
}
-34
View File
@@ -1,34 +0,0 @@
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);
}
});
}
-28
View File
@@ -1,28 +0,0 @@
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);
}
}
});
-84
View File
@@ -1,84 +0,0 @@
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);
}
});
-44
View File
@@ -1,44 +0,0 @@
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);
}
});
}
-40
View File
@@ -1,45 +1,5 @@
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 });
-36
View File
@@ -1,36 +0,0 @@
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();
});
-73
View File
@@ -1,73 +0,0 @@
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);
});
}
-98
View File
@@ -1,98 +0,0 @@
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);
}
});
-11
View File
@@ -26,17 +26,6 @@ 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
View File
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.39",
"version": "0.1.38",
"license": "Apache-2.0",
"type": "module",
"scripts": {
@@ -1,75 +0,0 @@
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>;
}
@@ -1,50 +0,0 @@
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>;
}
@@ -1,22 +0,0 @@
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>;
}
@@ -1,26 +0,0 @@
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.3.0;
package openidea:plugin@1.2.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
@@ -29,25 +29,18 @@ 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`, `files-changed` (batched disk changes
/// as `other`, with created/modified/removed path lists in JSON), `editor-focused` (the focused editor
/// Kinds: `file-opened`, `file-saved`, `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,
/// 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.
/// call that began it may run for the background budget (60 s) instead of 5 s.
interface tasks {
resource task {
/// `done` of `total` units (`none`: indeterminate), with a short status.
@@ -1,4 +1,4 @@
package openidea:plugin@1.3.0;
package openidea:plugin@1.2.0;
interface lifecycle {
use types.{context, error};
@@ -1,80 +0,0 @@
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>;
}
@@ -1,55 +0,0 @@
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.3.0;
package openidea:plugin@1.2.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
@@ -33,23 +33,9 @@ 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);
@@ -65,36 +51,24 @@ 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 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.
/// 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.
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 64 MiB,
/// counting UTF-8 bytes of keys and values; rejected writes preserve the data.
/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB.
interface storage {
use types.{error};
@@ -1,29 +0,0 @@
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.3.0;
package openidea:plugin@1.2.0;
/// Cross-file associations a plugin publishes for the files it declares under
/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable
@@ -1,39 +0,0 @@
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.3.0;
package openidea:plugin@1.2.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
@@ -75,10 +75,8 @@ interface secrets {
is-set: func(name: string) -> bool;
}
/// 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`.
/// One-off runs of programs granted with `process:<program>` (the file the
/// user approved, checked before every run).
interface process {
use types.{error};
use workspace.{project};
@@ -91,56 +89,8 @@ 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,23 +3,20 @@
/// 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 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];
/// `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];
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;
@@ -59,43 +56,6 @@ 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 {
@@ -116,55 +76,3 @@ 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.3.0;
package openidea:plugin@1.2.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
@@ -75,9 +75,6 @@ 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>;
@@ -1,31 +0,0 @@
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.3.0;
package openidea:plugin@1.2.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.3.0;
package openidea:plugin@1.2.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
@@ -123,7 +123,6 @@ 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.
@@ -137,18 +136,9 @@ 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,43 +1,7 @@
package openidea:plugin@1.3.0;
package openidea:plugin@1.2.0;
interface workspace {
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,
}
use types.{error};
/// A project open in the IDE.
resource project {
@@ -45,40 +9,12 @@ 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. A missing path is `not-found`.
/// Requires `project.read`.
/// contents win over the disk. 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`.
+2 -2
View File
@@ -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", "NSTitlebarAccessoryViewController", "NSToolbar", "NSView", "NSViewController", "NSWindow", "NSWindowTabGroup", "NSWorkspace"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString"] }
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"] }
# 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"] }
+1
View File
@@ -87,6 +87,7 @@ 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,
+11 -14
View File
@@ -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, Window};
use tauri::{AppHandle, State, WebviewWindow};
/// 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,10 +193,17 @@ pub fn window_open_projects(
.collect())
}
/// Whether this window is in native full screen (on macOS, also a tab of a fullscreen group).
/// Whether this window fills the screen: in native full screen, or (macOS) shown in its window
/// group's full-screen Space.
#[tauri::command]
#[specta::specta]
pub fn window_is_fullscreen(window: WebviewWindow) -> bool {
#[cfg(target_os = "macos")]
{
use tauri::Manager;
crate::window_groups::in_fullscreen(window.app_handle(), &window)
}
#[cfg(not(target_os = "macos"))]
window.is_fullscreen().unwrap_or(false)
}
@@ -211,18 +218,8 @@ pub struct CloseContext {
#[tauri::command]
#[specta::specta]
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 }
pub fn window_close_context(app: AppHandle, windows: State<'_, WindowsState>) -> CloseContext {
CloseContext { quitting: windows.quitting(), windows: windows::project_window_labels(&app).len() as u32 }
}
/// Quits: closes every window, each of which runs its own unsaved-changes check.
+14
View File
@@ -55,6 +55,20 @@ 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<()> {
+2 -11
View File
@@ -190,10 +190,6 @@ 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() {
@@ -205,13 +201,8 @@ pub fn run() {
Ok(())
})
.on_window_event(|window, event| match event {
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());
}
}
#[cfg(target_os = "linux")]
tauri::WindowEvent::CloseRequested { .. } => linux_window_state::save_before_close(window.app_handle()),
tauri::WindowEvent::Focused(true) => {
application_focus::changed(window.app_handle(), true);
if let Some(w) = window.try_state::<WindowsState>() {
+354 -63
View File
@@ -1,13 +1,15 @@
//! macOS project tabs: native window tab groups behind the page's own tab bar.
//! macOS window tab preferences, applied to application-drawn project tabs.
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, Sel},
runtime::{AnyClass, AnyObject, Imp, ProtocolObject, Sel},
};
use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference};
use objc2_app_kit::{NSWindow, NSWindowAnimationBehavior, NSWindowUserTabbingPreference};
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSRect};
use std::cell::Cell;
use tauri::{AppHandle, Manager, WebviewWindow};
fn tabbing_preference(app: &AppHandle) -> IdeResult<TabbingPreference> {
@@ -45,13 +47,33 @@ 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()
});
let fullscreen = active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false));
// 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 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() {
@@ -64,74 +86,182 @@ fn on_main<R: Send + 'static>(window: &WebviewWindow, f: impl FnOnce() -> R + Se
rx.recv().map_err(|_| tauri::Error::FailedToReceiveMessage)
}
/// 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);
}
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(group) = shown.tabGroup() {
group.setSelectedWindow(Some(shown));
}
shown.makeKeyAndOrderFront(None);
for member in &members {
if let Some(window) = native(member) {
hide_tab_bar(window);
}
}
})
}
/// 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<()> {
/// 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 Some(native) = native(&target) else { return };
if native.tabbedWindows().is_some_and(|tabs| tabs.count() > 1) {
native.moveTabToNewWindow(None);
}
hide_tab_bar(native);
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()) };
})
}
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))
/// 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());
}
}
if let Some(frame) = frame {
native.setFrame_display(frame, true);
}
target.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
})
}
/// 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);
/// 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(());
}
});
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();
on_main(window, move || {
ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.remove(&label));
})?;
toggled
}
/// 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()) };
}
}
/// 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)();
}
}
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;
@@ -171,4 +301,165 @@ 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)
}
+5 -14
View File
@@ -12,9 +12,8 @@ use objc2::{
runtime::ProtocolObject,
};
use objc2_app_kit::{
NSToolbar, NSWindow, NSWindowDidBecomeKeyNotification, NSWindowDidEnterFullScreenNotification,
NSWindowDidExitFullScreenNotification, NSWindowStyleMask, NSWindowToolbarStyle,
NSWindowWillEnterFullScreenNotification,
NSToolbar, NSWindow, NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification,
NSWindowStyleMask, NSWindowToolbarStyle, NSWindowWillEnterFullScreenNotification,
};
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol};
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
@@ -57,7 +56,7 @@ impl Drop for Observer {
struct Tracked {
state: Rc<Fullscreen>,
_observers: [Observer; 4],
_observers: [Observer; 3],
}
thread_local! {
@@ -86,15 +85,9 @@ 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 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);
});
// The web title bar shows the window buttons itself in full screen.
app.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
});
let observer = unsafe {
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
@@ -111,8 +104,6 @@ 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 }
+506 -56
View File
@@ -8,13 +8,22 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tauri::{AppHandle, Manager, WebviewWindow};
#[cfg(not(target_os = "macos"))]
mod frames;
type Group = ide_workspace::window_groups::WindowGroup;
/// 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);
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -53,57 +62,410 @@ fn changed(app: &AppHandle) {
app.state::<AppState>().sink.emit(IdeEvent::WindowGroupsChanged);
}
/// A presented group window moved or resized. Native tabs (macOS) share one frame already.
pub fn frame_changed(app: &AppHandle, label: &str) {
#[cfg(not(target_os = "macos"))]
frames::frame_changed(app, label);
#[cfg(target_os = "macos")]
let _ = (app, label);
/// 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,
}
/// Fullscreen for `window`. On macOS a group is one native tab group, which AppKit takes into a
/// fullscreen Space as a whole.
/// 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.
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
}
}
/// 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.
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)?;
#[cfg(target_os = "macos")]
let result = native_select(app, &previous, &to);
#[cfg(not(target_os = "macos"))]
let result = frames::present(app, &previous, target, &to);
// 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(())
})();
if result.is_err() {
let _ = state.groups.lock().select(caller, &previous.selected);
#[cfg(target_os = "macos")]
if let Some(window) = app.get_webview_window(&previous.selected) {
let _ = native_select(app, &previous, &window);
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(not(target_os = "macos"))]
frames::restore_selection(app, &previous);
} else if !state.quitting() {
#[cfg(not(target_os = "macos"))]
frames::selected(app, target);
// 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);
}
});
}
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>();
@@ -130,43 +492,88 @@ 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>();
let Some(next) = state.groups.lock().detach(caller) else {
#[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())?;
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) = 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(next) = next {
if let Some(window) = app.get_webview_window(&next) {
window.show().map_err(|e| IdeError::internal(e.to_string()))?;
}
#[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));
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);
}
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();
@@ -214,7 +621,6 @@ 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;
@@ -367,3 +773,47 @@ 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);
}
}
-285
View File
@@ -1,285 +0,0 @@
//! 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);
}
}
+2 -25
View File
@@ -140,9 +140,6 @@ 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).
@@ -152,21 +149,6 @@ 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);
}
@@ -205,7 +187,6 @@ 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) {
@@ -305,9 +286,6 @@ 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());
@@ -687,8 +665,6 @@ 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)?;
@@ -715,7 +691,6 @@ 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() {
@@ -724,6 +699,8 @@ 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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenIDEA",
"version": "0.1.39",
"version": "0.1.38",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
+9 -1
View File
@@ -42,6 +42,11 @@ 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")),
/**
@@ -920,7 +925,10 @@ 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 is in native full screen (on macOS, also a tab of a fullscreen group). */
/**
* Whether this window fills the screen: in native full screen, or (macOS) shown in its window
* group's full-screen Space.
*/
windowIsFullscreen: () => __TAURI_INVOKE<boolean>("window_is_fullscreen"),
/**
* Starts dragging `window` while the primary mouse button is still down; returns whether a drag began.
@@ -1,6 +1,5 @@
.split {
display: flex;
min-width: 640px;
flex: none;
min-height: 320px;
border: 1px solid var(--border-strong);
+22 -23
View File
@@ -132,19 +132,21 @@
/* Scrolled up: one click back to the latest message, floating over the stream's end. */
.jumpToLatest {
position: absolute;
right: var(--space-3);
bottom: var(--space-2);
left: 50%;
bottom: var(--space-3);
transform: translateX(-50%);
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--h-control-small);
height: var(--h-control-small);
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-toolbar-button);
background: var(--bg-app);
color: var(--fg-muted);
border: 1px solid var(--border-popup);
border-radius: 50%;
background: var(--bg-popup);
color: var(--fg);
box-shadow: var(--shadow-popup);
cursor: pointer;
}
.jumpToLatest:hover {
@@ -228,14 +230,18 @@
}
.conversationRow > .process,
.conversationRow > .permissionLine {
max-width: 100%;
max-width: min(var(--session-card-width), calc(var(--session-content-width) * 0.8), 760px);
}
/* Replies read as plain text on the panel, like other tool-window content. */
.agentCard {
box-sizing: border-box;
max-width: 100%;
width: fit-content;
max-width: min(var(--session-card-width), calc(var(--session-content-width) * 0.8), 760px);
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 {
@@ -252,14 +258,6 @@
-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,
@@ -329,10 +327,11 @@
.user {
width: 100%;
box-sizing: border-box;
padding: 6px 10px;
border-radius: var(--radius-tab);
background: var(--bg-hover);
color: var(--fg);
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);
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
+1 -1
View File
@@ -189,7 +189,6 @@ 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;
@@ -206,6 +205,7 @@ 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?.();
+2 -6
View File
@@ -350,15 +350,11 @@
/* Dialogs. */
.form {
display: grid;
grid-template-columns: fit-content(40%) minmax(0, 1fr);
grid-template-columns: max-content 1fr;
align-items: center;
gap: 8px 12px;
min-width: 0;
min-width: 440px;
padding: 14px;
overflow-wrap: anywhere;
}
.form > * {
min-width: 0;
}
.formLabel {
color: var(--fg-secondary);
@@ -43,7 +43,6 @@
}
.body {
display: flex;
min-width: 640px;
flex: 1;
min-height: 0;
border-radius: var(--radius-small);
+2 -21
View File
@@ -193,32 +193,13 @@ 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"]);
});
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"]);
expect(contextConfig("src/a.spec.js", configs)!.args).toEqual(["run", "test", "--", "src/a.spec.js"]);
});
it("narrows rspec and rake test runs to the file", () => {
+2 -2
View File
@@ -11,7 +11,6 @@ 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;
@@ -217,7 +216,8 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC
switch (base.kind) {
case "npm":
case "bun": {
const args = appendScriptArgs(base.command, base.args, [file]);
// `npm run test -- file` works for npm, pnpm, yarn and bun.
const args = base.args.includes("--") ? [...base.args, file] : [...base.args, "--", file];
return derived(base, relPath, { args });
}
case "deno":
-7
View File
@@ -1,7 +0,0 @@
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];
}
+1
View File
@@ -170,6 +170,7 @@ 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$",
+3 -19
View File
@@ -86,23 +86,15 @@ describe("rerunFailedConfig", () => {
expect(c.args).toEqual(["test", "--", "--nocapture", "old", "a", "--exact"]);
});
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"]), [
it("jest/vitest via npm: files + -t pattern", () => {
const c = rerunFailedConfig(cfg("npm", "pnpm", ["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",
@@ -110,14 +102,6 @@ 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]"],
+4 -4
View File
@@ -5,7 +5,6 @@
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";
@@ -72,10 +71,11 @@ 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 flags = ["-t", "--testNamePattern"];
const args = after === null ? dropFlag(before, flags) : [...before, "--", ...dropFlag(after, flags)];
const rest = dropFlag(after ?? [], ["-t", "--testNamePattern"]);
const filter = names.length ? ["-t", `^(?:${names.map(escapeRegExp).join("|")})$`] : [];
return appendScriptArgs(config.command, args, [...files, ...filter]);
// 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];
}
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-|tint-strength$)/;
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|mac-window-|tint-strength$)/;
describe("palette themes", () => {
const ISLANDS = ["light", "dark", "dark-hc"];
@@ -75,10 +75,9 @@
.pageHeader {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
min-height: 40px;
height: 40px;
padding: 0 12px 0 20px;
}
.history {
@@ -121,7 +120,6 @@
}
.scope {
display: inline-flex;
flex: none;
padding: 2px;
border-radius: var(--radius-small);
box-shadow: inset 0 0 0 1px var(--border-strong);
@@ -132,7 +130,6 @@
border-radius: 3px;
color: var(--fg-muted);
font-size: var(--fs-small);
white-space: nowrap;
}
.scopeButton:hover {
color: var(--fg-emphasis);
@@ -12,8 +12,6 @@
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}>
<div className={`${styles.pathRow} ${styles.existingPathRow}`}>
<Select
id={ids.existing}
className={styles.pathInput}
+7 -14
View File
@@ -9,25 +9,21 @@
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 180px;
min-width: 0;
flex: 1;
max-width: 560px;
}
.sdkSelect {
flex: 1;
min-width: 0;
min-width: 260px;
}
.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);
@@ -156,7 +152,7 @@
/* Add Python Interpreter dialog */
.dialogBody {
display: grid;
grid-template-columns: minmax(150px, max-content) minmax(0, 1fr);
grid-template-columns: 150px minmax(0, 1fr);
min-height: 300px;
}
.kinds {
@@ -178,7 +174,6 @@
color: var(--fg);
font: inherit;
text-align: left;
white-space: nowrap;
}
.kindOn {
background: var(--bg-list-selected);
@@ -192,14 +187,12 @@
}
.radios {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
gap: 16px;
}
.radio {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.pathRow {
display: flex;
@@ -208,7 +201,7 @@
flex: 1;
min-width: 0;
}
.form > .pathRow {
.existingPathRow {
flex: none;
}
.pathInput {
@@ -219,7 +212,7 @@
/* Project Structure */
.structure {
display: grid;
grid-template-columns: minmax(240px, 1fr) 220px;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 10px;
min-height: 420px;
}
@@ -425,7 +418,7 @@
}
.splitLayout {
display: grid;
grid-template-columns: 268px minmax(300px, 1fr);
grid-template-columns: 268px minmax(0, 1fr);
gap: 12px;
min-height: 360px;
}
@@ -27,7 +27,6 @@
/* Inline option groups, with custom font controls wrapping together. */
.fontRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 28px;
@@ -94,14 +93,11 @@
.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;
}
@@ -115,7 +111,6 @@
border-collapse: collapse;
}
.table th {
white-space: nowrap;
text-align: left;
font-weight: normal;
color: var(--fg-muted);
@@ -124,7 +119,6 @@
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(220px, 1fr);
grid-template-columns: 200px minmax(0, 1fr);
gap: 14px;
align-items: start;
}
@@ -25,13 +25,11 @@
/* "[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;
@@ -168,21 +166,6 @@
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);
}
@@ -191,7 +174,6 @@
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(180px, 1fr) minmax(220px, 260px);
grid-template-columns: minmax(0, 1fr) minmax(220px, 260px);
gap: 14px;
min-height: 320px;
}
@@ -14,10 +14,6 @@
.profileSelect {
min-width: 220px;
}
.profileRow > span {
flex: none;
white-space: nowrap;
}
.toolbar {
display: flex;
align-items: center;
@@ -31,7 +27,6 @@
}
.split {
flex: 1;
min-width: 520px;
min-height: 0;
display: flex;
gap: 12px;
+2 -7
View File
@@ -491,13 +491,8 @@ function RunTargetsEditor() {
className={clsx(styles.toolRow, t.id === selected && styles.toolRowSelected)}
onMouseDown={() => setSelected(t.id)}
>
<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)}
>
<span>{t.name || t.id}</span>
<span className={styles.toolGroup}>
{t.kind === "ssh" ? (t.sshConfig ? sshName(t.sshConfig) : "") : containerSummary(t)}
</span>
</div>
+2 -6
View File
@@ -339,12 +339,8 @@ function SshConfigsEditor() {
className={clsx(styles.toolRow, c.id === selected && styles.toolRowSelected)}
onMouseDown={() => setSelected(c.id)}
>
<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>
<span>{c.name.trim() || sshLabel(c) || c.id}</span>
<span className={styles.toolGroup}>{sshLabel(c)}</span>
</div>
))}
</div>
+2 -6
View File
@@ -480,12 +480,8 @@ function ExternalToolsEditor() {
checked={t.enabled}
onChange={(on) => setTools(tools.map((x) => (x.id === t.id ? { ...x, enabled: on } : x)))}
/>
<span className={styles.toolName} title={t.name}>
{t.name}
</span>
<span className={styles.toolGroup} title={t.group}>
{t.group}
</span>
<span>{t.name}</span>
<span className={styles.toolGroup}>{t.group}</span>
</div>
))}
</div>
@@ -112,9 +112,7 @@ export function LiveTemplatesPage() {
<span className={clsx(styles.itemLabel, isModifiedBuiltin(t.id) && styles.modified)}>
{t.abbrev}
</span>
<span className={styles.itemDetail} title={t.description}>
{t.description}
</span>
<span className={styles.itemDetail}>{t.description}</span>
</div>
))}
</div>
@@ -63,9 +63,7 @@ export function PostfixPage() {
aria-label={tx`Enable .${t.key}`}
/>
<span className={styles.mono}>.{t.key}</span>
<span className={styles.itemDetail} title={t.description}>
{t.description}
</span>
<span className={styles.itemDetail}>{t.description}</span>
</div>
))}
</div>
@@ -14,7 +14,6 @@
}
.split {
flex: 1;
min-width: 520px;
min-height: 320px;
display: flex;
border: 1px solid var(--border-strong);
@@ -81,8 +80,6 @@
color: var(--fg-emphasis);
}
.itemDetail {
min-width: 0;
white-space: nowrap;
color: var(--fg-subtle);
overflow: hidden;
text-overflow: ellipsis;
@@ -13,7 +13,6 @@
}
.body {
display: flex;
min-width: 600px;
flex: 1;
min-height: 0;
border-radius: var(--radius-small);
@@ -170,7 +169,6 @@
}
.header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
}
+4 -22
View File
@@ -24,7 +24,6 @@ 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 {
@@ -116,14 +115,6 @@ 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()) {
@@ -149,21 +140,12 @@ export async function openRecent(project: Pick<RecentProject, "path" | "name">):
return true;
} catch (e) {
if (isCancelled(e)) return false;
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;
}
const message = e instanceof Error ? e.message : String(e);
const missing = e instanceof IpcError ? e.kind === "notFound" : /not found|no such/i.test(message);
notifications().notify({
level: "error",
title: `Cannot open ${project.name}`,
body: message,
title: missing ? `Project folder not found: ${project.name}` : `Cannot open ${project.name}`,
body: missing ? project.path : message,
key: `welcome.open:${project.path}`,
actions: [{ label: "Remove from Recent Projects", run: () => void removeRecent(project.path) }],
});
+1
View File
@@ -197,6 +197,7 @@
"window.caption.minimize": "Minimize",
"window.caption.maximize": "Maximize",
"window.caption.restore": "Restore Down",
"window.caption.exitFullScreen": "Exit Full Screen",
"window.caption.close": "Close",
"menu.help.keymapReference": "Keymap Reference",
"menu.help.showLog": "{os, select, macos {Show Log in Finder} windows {Show Log in Explorer} other {Show Log in Files}}",
+1
View File
@@ -197,6 +197,7 @@
"window.caption.minimize": "最小化",
"window.caption.maximize": "最大化",
"window.caption.restore": "向下还原",
"window.caption.exitFullScreen": "退出全屏",
"window.caption.close": "关闭",
"menu.help.keymapReference": "快捷键参考",
"menu.help.showLog": "{os, select, macos {在访达中显示日志} windows {在资源管理器中显示日志} other {在文件管理器中显示日志}}",
+12
View File
@@ -436,6 +436,18 @@ 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
-1
View File
@@ -2,7 +2,6 @@
.button {
--button-radius: var(--radius-control, 4px);
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
gap: 6px;
+2 -4
View File
@@ -96,11 +96,9 @@ function PopupInner({
shift({ padding: 6 }),
sizeMw({
padding: 6,
apply({ rects, elements, availableHeight, availableWidth }) {
apply({ rects, elements, availableHeight }) {
elements.floating.style.maxHeight = `${Math.max(120, availableHeight)}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`;
if (matchWidth) elements.floating.style.minWidth = `${rects.reference.width}px`;
},
}),
],
+1 -2
View File
@@ -91,8 +91,7 @@
overflow: hidden;
}
.optionLabel {
flex: 1 1 auto;
min-width: 0;
flex: 1 0 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+1 -3
View File
@@ -268,9 +268,7 @@ 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)} title={given(o.label)}>
{given(o.label)}
</span>
<span className={clsx(styles.optionLabel, o.detail && styles.withDetail)}>{given(o.label)}</span>
{o.detail && (
<span className={styles.detail} title={o.detail}>
{given(o.detail)}
+3
View File
@@ -252,6 +252,9 @@ const ICONS = {
"window-maximize": "openidea/general/windowMaximize",
"window-restore": "openidea/general/windowRestore",
"window-close": "openidea/general/windowClose",
"mac-window-close": "openidea/general/macWindowClose",
"mac-window-minimize": "openidea/general/macWindowMinimize",
"mac-window-exit-full-screen": "openidea/general/macWindowExitFullScreen",
hide: "general/hide",
pin: "general/pin",
eye: "general/show",
@@ -0,0 +1,4 @@
<!-- OpenIDEA: macOS window button glyph (close), drawn in the full-screen title bar. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 4.5L11.5 11.5M11.5 4.5L4.5 11.5" stroke="#6C707E" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

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

After

Width:  |  Height:  |  Size: 319 B

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

After

Width:  |  Height:  |  Size: 277 B

+96
View File
@@ -0,0 +1,96 @@
/**
* 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>
);
}
+49 -4
View File
@@ -16,10 +16,6 @@
/* 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);
@@ -199,3 +195,52 @@
background: var(--caption-close-pressed);
color: var(--fg-caption-close-hover);
}
/* macOS window buttons drawn in the traffic-light slot while the window fills the screen. */
.macButtons {
position: fixed;
top: 0;
left: 0;
height: var(--h-titlebar);
display: flex;
align-items: center;
gap: 8px;
padding-left: 12px;
zoom: calc(1 / var(--titlebar-zoom, 1));
z-index: 1;
}
.macButton {
width: 12px;
height: 12px;
padding: 0;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--mac-window-glyph);
box-shadow: inset 0 0 0 0.5px var(--mac-window-edge);
}
.macButton > * {
opacity: 0;
}
.macButtons:hover .macButton:not(:disabled) > * {
opacity: 1;
}
.macClose {
background: var(--mac-window-close);
}
.macMinimize {
background: var(--mac-window-minimize);
}
.macZoom {
background: var(--mac-window-zoom);
}
.macButton:active:not(:disabled) {
filter: brightness(0.8);
}
/* Disabled in full screen, and every button of an inactive window, are grey as AppKit draws them. */
.macButton:disabled,
.macButtonsInactive:not(:hover) .macButton {
background: var(--mac-window-inactive);
}
+4 -8
View File
@@ -21,7 +21,7 @@ import {
widgetTitle,
} from "./mainToolbar";
import { WindowControls } from "./WindowControls";
import { useFullscreen } from "./useFullscreen";
import { MacWindowButtons } from "./MacWindowButtons";
import { usePlatform } from "@/platform/platformInfo";
import { projectInitials } from "./projectColor";
import { keymapItems, themeItems, themePreview, type ThemePreview, VIEW_MODE_ITEMS } from "./quickSwitch";
@@ -291,23 +291,18 @@ export function TitleBar({
title?: string;
}) {
const overlay = usePlatform((s) => s.overlayTitleBar);
const fullscreen = useFullscreen();
const frameless = usePlatform((s) => s.framelessTitleBar);
const { map, shown, menu } = useToolbar();
const [menuExpanded, setMenuExpanded] = useState(false);
const [bar, setBar] = useState<HTMLElement | null>(null);
useNativeTitlebar(bar);
const className = clsx(
styles.bar,
overlay && styles.overlay,
overlay && fullscreen && styles.overlayFullscreen,
frameless && styles.frameless,
);
const className = clsx(styles.bar, overlay && styles.overlay, frameless && styles.frameless);
// Distraction Free Mode: the window frame only. Where the title bar carries the
// main menu (Windows, Linux) it stays, or nothing would lead back out of the mode.
if (bare)
return (
<header ref={setBar} className={className} {...windowDragRegion} data-titlebar="bare">
{overlay && <MacWindowButtons />}
{frameless && (
<>
<MainMenu />
@@ -330,6 +325,7 @@ export function TitleBar({
showContextMenu(e, menu);
}}
>
{overlay && <MacWindowButtons />}
{frameless && !minimal && <MainMenu onExpandedChange={setMenuExpanded} />}
{title && (
<span className={styles.windowTitle} {...windowDragRegion}>
-28
View File
@@ -1,28 +0,0 @@
/**
* Whether this window fills the screen: native full screen, or a project tab shown in its
* group's full-screen Space. Native full screen moves the window buttons out of the title bar,
* so the room kept for them is released.
*/
import { useEffect, useState } from "react";
import { commands, isTauri, onIdeEvent } from "@/platform/ipc";
export function useFullscreen(): boolean {
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
// The plain-browser shell and tests have no Tauri window to ask.
if (!isTauri()) return;
let disposed = false;
const sync = () =>
void commands
.windowIsFullscreen()
.then((on) => !disposed && setFullscreen(on))
.catch(() => {});
sync();
const off = onIdeEvent("windowFullscreenChanged", sync);
return () => {
disposed = true;
off();
};
}, []);
return fullscreen;
}