Compare commits

...
Author SHA1 Message Date
l0ng-ai 25424465c0 chore: v0.1.39 2026-11-08 18:33:43 +08:00
l0ng-aiandClaude cbe4355b8d fix(welcome): ask to remove a recent project whose folder is gone (#2131)
Opening a recent project whose folder no longer exists reported the failure as a
notification, which is not shown when the system group's balloons are off, and the
welcome screen has no list to find it in, so the click did nothing. It now opens a
dialog offering to remove the entry. The open failure's kind is also read from the
raw error the trust check rejects with, so a missing folder is no longer reported as
a generic failure.

Co-authored-by: Claude <[email protected]>
2026-11-08 18:26:38 +08:00
l0ng-aiandClaude 47d547e206 fix(window): closing a project tab group returns to the welcome screen (#2130)
The system close button closes a native tab group as a whole, so every member asks
to close at once. Each member's close guard saw the other members as remaining
windows, let itself close, and the app quit instead of showing the welcome screen.

Members closing together now count as one window closing: the first of them is the
last window left and returns to the welcome screen, and the others close.

Co-authored-by: Claude <[email protected]>
2026-11-08 18:23:59 +08:00
Shohruh Ismoilov f42d10d86c fix(run): canonicalize complete Windows Tomcat roots (#2096) 2026-11-08 18:08:14 +08:00
l0ng-ai 86f390f2a9 fix(e2e): match the plain agent reply style in the approval test (#2120)
#2118 made agent replies plain text on the panel and gave the prompt the 6px tab radius;
assert that style instead of the earlier card.
2026-11-08 17:47:11 +08:00
l0ng-aiandClaude 78784837d1 fix(icons): restore the light rounded-square backing on the app icon (#2125)
Reverts #2112. The app icon, its platform sizes and the source SVGs go back to the
version with the light rounded-square backing.

Co-authored-by: Claude <[email protected]>
2026-11-08 17:46:46 +08:00
l0ng-aiandClaude 4d968a2aca fix(window): stop the app panicking when windows close and open together (#2124)
Closing a window posts its full-screen notification while the runtime still
holds its window table; the title bar observer emitted from there and the emit
re-entered the table ("RefCell already mutably borrowed"). The emit now goes
out after the notification returns.

Opening several project windows at once could also hand the same project-N
label to each request, so label choice and window creation now take turns.

Co-authored-by: Claude <[email protected]>
2026-11-08 17:46:10 +08:00
arookieofc f6e942dd00 feat(languages): start Laravel LSP next to Intelephense in Laravel projects (#2117)
Laravel ships a first-party language server (laravel/lsp, the one behind
the official VS Code/Zed/Sublime extensions) that answers hover,
definition and completion inside config('…'), route('…') and view('…')
calls — places Intelephense returns null.

Register it in languages/php.toml after Intelephense and the phpactor
fallback (Intelephense keeps hover, rename and symbols for PHP code),
gated by require-root-marker on artisan so it only starts in Laravel
projects — the same pattern angular-language-server uses for
angular.json.

Add a ToolSpec so Settings › Language Servers can install it: GitHub
releases carry plain per-platform binaries (server-v*-<arch>-<os>[.exe]),
which Method::Release + ArchiveKind::Raw already handle; the versioned
asset name lands in bin/ via the usual link/wrapper step. laravel-lsp
runs the project's PHP, so needs stays empty (PHP detection is the
project interpreter's job).

Closes #2100
2026-11-08 17:36:30 +08:00
l0ng-ai 75cb45ed94 fix(window): make macOS project tabs native window tabs (#2123)
Project tabs in a window group are now one native AppKit tab group behind the
page's own tab bar, with AppKit's tab bar hidden. The group shares one frame and
enters full screen as a whole, so every tab shows the system window buttons in
the bar revealed with the menu bar, and tabs no longer lose their title bar
layout after leaving full screen.

This removes the full-screen auxiliary windows, the host handover on close, the
frame copying between hidden members and the window buttons drawn by the page.
The title bar releases the room kept for the window buttons in full screen.
Linux and Windows keep showing one member at a time.
2026-11-08 17:20:59 +08:00
arookieofc f07627ada6 fix(icons): drop the light rounded-square backing from the app icon (#2112)
* fix(icons): drop the light rounded-square backing from the app icon

The source SVGs drew an #EAE7E0 rounded rect behind the mark, which shows
as a pale border/frame around the icon on dark taskbars and in the app
window chrome. Remove the backing rect from both openidea.svg and
openidea-windows.svg, enlarge the mark to fill the canvas (scale 1.55 and
1.8 respectively), and regenerate the raster set with the tauri icon
toolchain.

Closes #2111

* fix(run): skip the drive prefix when canonicalizing verbatim roots

canonical_path walks a path component by component so it can compare a
not-yet-created base with existing sources. On Windows, a verbatim root
(\?\D:\...) puts a bare \?\D: Prefix component first; canonicalizing
that prefix alone fails with ERROR_INVALID_FUNCTION (GetFinalPathNameByHandle
has no directory form for a bare drive), aborting prepare() before any
work — the verbatim-roots test fails on windows-latest.

Skip Component::Prefix: the drive prefix has nothing to canonicalize, and
the RootDir right after it resolves the drive itself.

This fixes the same upstream-main failure tracked in #2057/#2096, scoped
to this branch so the icon change doesn't inherit a red job.
2026-11-08 17:04:03 +08:00
l0ng-ai 48c5ba51b6 fix(agents): plain agent replies, neutral prompts and a toolbar-style jump button (#2118) 2026-11-08 16:26:00 +08:00
l0ng-ai 558ee395dc fix(templates): keep postfix descriptions inside their rows (#2116) 2026-11-08 16:17:47 +08:00
l0ng-ai 63304ec9fe fix(settings): preserve both panes in compact windows (#2115) 2026-11-08 16:16:06 +08:00
l0ng-ai 65f3660bd9 fix(settings): keep compact inline controls readable (#2114) 2026-11-08 16:16:02 +08:00
l0ng-ai 105e730555 fix(github): expose review settings in the native settings window (#2113) 2026-11-08 16:15:23 +08:00
l0ng-ai 3a0a95ac90 fix(settings): wrap header controls without splitting labels (#2107) 2026-11-08 16:15:20 +08:00
l0ng-ai 1439adc00c fix(ui): constrain long dropdowns to the viewport (#2106) 2026-11-08 16:15:16 +08:00
l0ng-ai 0b08cde4ba fix(settings): keep interpreter controls from overlapping (#2105) 2026-11-08 16:15:12 +08:00
l0ng-ai cde358888c fix(settings): contain long names in configuration lists (#2104) 2026-11-08 16:15:09 +08:00
l0ng-ai 48393b2c8c fix(database): keep credential dialog fields inside the form (#2103) 2026-11-08 16:15:05 +08:00
l0ng-ai 587819d9ca fix(settings): keep Python interpreter dialog rows aligned (#2099) 2026-11-08 16:15:01 +08:00
l0ng-ai 86ed4d6c59 fix(run): preserve pnpm test selection arguments (#2092) 2026-11-08 16:14:54 +08:00
xtr-hub 11911a12c6 fix(agents): honor full-access settings for tool approvals (#1702) (#2109)
The client only auto-approved permission requests for Cursor's openidea.cursor.approval=bypass setting. Agents whose current mode is full-access / bypassPermissions / yolo, or whose permissions option is on, still showed permission cards while the composer displayed Full Access.\n\nTreat those selected settings as approval bypasses, matching the composer's bypassDefaults. Re-evaluate pending requests when the setting arrives late or changes while a request waits.\n\nCloses #1702
2026-11-08 16:09:59 +08:00
102 changed files with 2320 additions and 1234 deletions

No files matched your search

Generated
+37 -37
View File
@@ -4106,7 +4106,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4129,7 +4129,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4143,7 +4143,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"serde",
@@ -4154,7 +4154,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"dirs 6.0.0",
@@ -4170,7 +4170,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4179,7 +4179,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4196,7 +4196,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-run",
@@ -4210,7 +4210,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
@@ -4231,7 +4231,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4258,7 +4258,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"serde",
@@ -4271,7 +4271,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4291,7 +4291,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4303,7 +4303,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4316,7 +4316,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4378,7 +4378,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"parking_lot",
@@ -4396,7 +4396,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4418,7 +4418,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"encoding_rs",
"futures",
@@ -4435,7 +4435,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4491,7 +4491,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"futures",
@@ -4517,7 +4517,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
@@ -4540,7 +4540,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"ide-core",
@@ -4561,7 +4561,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-run",
@@ -4574,7 +4574,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"globset",
"ide-core",
@@ -4592,7 +4592,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4616,7 +4616,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"ide-core",
@@ -4635,7 +4635,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"criterion",
"globset",
@@ -4655,7 +4655,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"parking_lot",
@@ -4668,7 +4668,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4689,7 +4689,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-language",
@@ -4704,7 +4704,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"libc",
@@ -4717,7 +4717,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"criterion",
"ide-core",
@@ -4730,7 +4730,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"flate2",
"hex",
@@ -4751,7 +4751,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -4772,7 +4772,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"chardetng",
"dunce",
@@ -4796,7 +4796,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"open",
@@ -4812,7 +4812,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -6358,7 +6358,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.38"
version = "0.1.39"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+64 -6
View File
@@ -35,20 +35,32 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
});
}
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
/// Whether the current settings approve tool requests without asking: a bypass mode,
/// an allow-all permissions option, or a bypass approval policy.
pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
let selected = |category: &str| {
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
};
selected("mode").is_some_and(is_bypass_mode)
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
}
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
let value = option.values.iter().find(|v| {
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
})?;
let value = option.values.iter().find(|v| is_bypass_mode(&v.value))?;
Some((option, value))
}
fn is_bypass_mode(mode: &str) -> bool {
// Mode ids can be URL-like (`…/session-modes#yolo`), as the composer's mode icon also assumes.
let lower = mode.to_lowercase();
let m: String =
lower.rsplit(['#', '/']).next().unwrap_or_default().chars().filter(char::is_ascii_lowercase).collect();
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
}
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
/// plus the older `modes` / `models` fields for what those don't cover.
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
@@ -267,6 +279,52 @@ mod tests {
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
}
#[test]
fn selected_bypass_settings_approve_without_asking() {
let full_access = options_of(&json!({
"modes": {"currentModeId": "full-access", "availableModes": [
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
]}
}));
assert!(bypass_approvals(&full_access));
assert_eq!(bypass_mode(&full_access).map(|(_, v)| v.value.as_str()), Some("full-access"));
let asking = options_of(&json!({
"modes": {"currentModeId": "ask", "availableModes": [
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
]}
}));
assert!(!bypass_approvals(&asking));
assert_eq!(
bypass_mode(&asking).map(|(_, v)| v.value.as_str()),
Some("full-access"),
"the offered bypass mode stays available"
);
let permissions = |current: &str| {
options_of(&json!({"configOptions": [{
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select",
"currentValue": current, "options": [{"value": "off"}, {"value": "on"}]
}]}))
};
assert!(bypass_approvals(&permissions("on")));
assert!(bypass_approvals(&permissions("TRUE")));
assert!(!bypass_approvals(&permissions("off")));
let approval = options_of(&json!({"configOptions": [{
"id": "approval", "name": "Approvals", "category": "approval", "type": "select",
"currentValue": "bypass", "options": [{"value": "ask"}, {"value": "bypass"}]
}]}));
assert!(bypass_approvals(&approval));
// The composer's mode icon splits URL-like ids on `#` / `/`; the backend must agree.
let url_mode = options_of(&json!({
"modes": {"currentModeId": "https://agentclientprotocol.com/protocol/session-modes#yolo",
"availableModes": [{"id": "https://agentclientprotocol.com/protocol/session-modes#yolo"}]}
}));
assert!(bypass_approvals(&url_mode));
}
#[test]
fn commands_and_usage() {
let cmds = commands_of(&json!({"availableCommands": [
+6 -1
View File
@@ -1581,6 +1581,7 @@ impl Task {
.collect()
};
if due.is_empty() {
self.approve_pending();
return;
}
let task = self.clone();
@@ -1610,6 +1611,7 @@ impl Task {
Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id),
}
}
task.approve_pending();
task.persist();
task.emit_task();
});
@@ -1726,6 +1728,9 @@ impl Task {
if kind == "config_option_update" && !self.replaying.load(Ordering::Acquire) {
self.apply_late_choices();
}
if !self.replaying.load(Ordering::Acquire) && kind == "current_mode_update" {
self.approve_pending();
}
return;
}
if self.replaying.load(Ordering::Acquire) {
@@ -1857,7 +1862,7 @@ impl Task {
fn approve_pending(&self) {
let s = self.state.lock();
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
return;
}
let mut pending = self.permissions.lock();
+161 -2
View File
@@ -35,6 +35,8 @@ struct FakeAgent {
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
/// Hold the late mode reply until the prompt's permission request is already waiting.
hold_late_mode: bool,
empty_config: AtomicBool,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
@@ -128,6 +130,13 @@ impl Handler for FakeAgent {
{
return Err((codes::INTERNAL_ERROR, "Authentication required".into()));
}
if self.hold_late_mode
&& method == "session/set_config_option"
&& params.get("value").and_then(Value::as_str) == Some("full-access")
{
// Let the prompt's permission request reach the task before the mode changes.
std::thread::sleep(Duration::from_millis(100));
}
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
match method {
"initialize" => {
@@ -200,6 +209,29 @@ impl Handler for FakeAgent {
self.say(&session, "ok");
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "late full access" {
// The mode option appears only now; the user's choice is applied late.
self.update(
&session,
json!({"sessionUpdate": "config_option_update", "configOptions": [
{"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]}
]}),
);
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session, "toolCall": {"toolCallId": "late", "title": "Run cargo test"},
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
None,
))
.unwrap();
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("late chose {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "ask" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
@@ -1438,12 +1470,12 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
mgr.prompt(&task.id, text("late option"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let t = mgr.task(&task.id).unwrap();
assert!(config::bypass_approvals("cursor", &t.state.lock().task.config));
assert!(config::bypass_approvals(&t.state.lock().task.config));
t.kill();
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
assert!(config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
assert!(config::bypass_approvals(&mgr.shared.known_config("cursor")));
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
@@ -1534,6 +1566,133 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_full_access_mode_approves_tool_requests() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = vec![json!({
"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]
})];
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
assert!(
mgr.entries(&task.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
// Choosing Full Access while the request is already waiting also approves it.
let waiting = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &waiting.id, AgentStatus::NeedsInput).await;
mgr.set_config(&waiting.id, "mode", "full-access".into()).await.unwrap();
wait_for(&mgr, &waiting.id, AgentStatus::Ready).await;
assert!(
mgr.entries(&waiting.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_late_full_access_option_approves_a_pending_request() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { hold_late_mode: true, ..Default::default() });
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("late full access"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let task = mgr.task(&task.id).unwrap();
let (status, busy) = {
let s = task.state.lock();
(s.task.status, s.busy)
};
if status == AgentStatus::Ready && !busy {
break;
}
if std::time::Instant::now() > deadline {
let _ = mgr.cancel(&task.id, None);
mgr.shutdown();
panic!("late Full Access did not approve the pending request");
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
assert!(
mgr.entries(&task.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn an_allow_all_permissions_option_approves_tool_requests() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = vec![json!({
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select", "currentValue": "off",
"options": [{"value": "off", "name": "Ask"}, {"value": "on", "name": "Full Access"}]
})];
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "allow_all".into(), value: "on".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"on\"")));
assert!(
mgr.entries(&task.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn archived_tasks_can_be_restored_and_continue_their_session() {
let tmp = tempfile::tempdir().unwrap();
+21
View File
@@ -692,6 +692,27 @@ mod tests {
}
}
#[test]
fn laravel_lsp_serves_php_files_only_below_artisan() {
let reg = reg();
let cfg = reg.get(&LanguageId::new("php")).unwrap();
let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect();
// After the fallback: hover, rename and symbols stay with Intelephense.
assert_eq!(names, ["intelephense", "phpactor", "laravel-lsp"]);
let lsp = cfg.language_servers.iter().find(|s| s.name == "laravel-lsp").unwrap();
assert_eq!(lsp.command.as_str(), "laravel-lsp");
assert!(lsp.require_root_marker && !lsp.fallback);
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for dir in ["shop", "lib"] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
std::fs::write(root.join("shop/artisan"), "").unwrap();
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &lsp.root_markers);
assert_eq!(served("shop/app/Models/Order.php"), Some(root.join("shop")));
assert_eq!(served("lib/src/Parser.php"), None);
}
#[test]
fn ruff_serves_python_files_only_below_its_config() {
let reg = reg();
+4
View File
@@ -395,6 +395,10 @@ fn canonical_path(path: &Path) -> IdeResult<PathBuf> {
let mut resolved = PathBuf::new();
for component in std::path::absolute(path)?.components() {
match component {
std::path::Component::Prefix(_) => {
resolved.push(component);
continue;
}
std::path::Component::ParentDir => {
resolved.pop();
}
+19
View File
@@ -151,6 +151,25 @@ mod tests {
assert_eq!(select_asset(&names, rules, WIN), Some(names[4]));
}
#[test]
fn laravel_lsp_assets() {
let names = [
"server-v0.0.32-arm64-darwin",
"server-v0.0.32-x64-darwin",
"server-v0.0.32-arm64-linux",
"server-v0.0.32-x64-linux",
"server-v0.0.32-x64-win32.exe",
];
let rules = crate::registry::find("laravel-lsp").unwrap().asset_rules();
assert_eq!(select_asset(&names, rules, MAC_ARM), Some(names[0]));
assert_eq!(select_asset(&names, rules, MAC_X64), Some(names[1]));
assert_eq!(select_asset(&names, rules, LINUX_ARM), Some(names[2]));
assert_eq!(select_asset(&names, rules, LINUX_X64), Some(names[3]));
assert_eq!(select_asset(&names, rules, WIN), Some(names[4]));
// The composer-installed helper is not a release asset.
assert_eq!(select_asset(&["laravel-lsp"], rules, LINUX_X64), None);
}
#[test]
fn kmp_lsp_assets_include_the_native_jar_indexer() {
let names = [
+29
View File
@@ -396,6 +396,35 @@ pub static TOOLS: &[ToolSpec] = &[
homepage: "https://intelephense.com",
needs: &[],
},
ToolSpec {
name: "laravel-lsp",
display: "Laravel LSP",
kind: LanguageServer,
commands: &["laravel-lsp"],
// Plain binaries per platform (`server-v*-<arch>-<os>[.exe]`).
method: Method::Release {
source: ReleaseSource::GitHub { repo: "laravel/lsp" },
assets: &[
rule(Mac, Arm64, "server-v*-arm64-darwin"),
rule(Mac, X64, "server-v*-x64-darwin"),
rule(Linux, Arm64, "server-v*-arm64-linux"),
rule(Linux, X64, "server-v*-x64-linux"),
rule(Windows, X64, "server-v*-x64-win32.exe"),
],
locate: &[
"laravel-lsp",
"server-v*-arm64-darwin",
"server-v*-x64-darwin",
"server-v*-arm64-linux",
"server-v*-x64-linux",
"server-v*-x64-win32",
],
},
fallback: None,
version: ver("laravel-lsp"),
homepage: "https://github.com/laravel/lsp",
needs: &[],
},
ToolSpec {
name: "gopls",
display: "gopls",
+4 -3
View File
@@ -112,9 +112,10 @@ for (const theme of ["dark", "light"] as const) {
});
expect(geometry.scroll).toBe(geometry.client);
expect(geometry.contained).toBe(true);
expect(geometry.promptRadius).toBeLessThanOrEqual(4);
expect(geometry.replyBackground).not.toBe("rgba(0, 0, 0, 0)");
expect(geometry.replyBorder).toBe("1px");
expect(geometry.promptRadius).toBeLessThanOrEqual(6);
// Replies read as plain text on the panel.
expect(geometry.replyBackground).toBe("rgba(0, 0, 0, 0)");
expect(geometry.replyBorder).toBe("0px");
expect(geometry.userOnRight).toBe(true);
expect(geometry.approvalAligned).toBe(true);
expect(geometry.authorBeforeWork).toBe(true);
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from "./fixtures";
for (const [id, selector] of [
["general", "[class*='radios'] label"],
["notifications", "table tbody td:first-child"],
["keymap", "[class*='presetLabel']"],
["editor.inspections", "[class*='profileRow'] > span:first-child"],
["vcs.issueNavigation", "[class*='pageBody'] button:not([aria-label])"],
]) {
test(`${id} keeps compact labels inside their controls`, async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await page.evaluate(async (id) => {
const localePath = "/src/platform/i18n.ts";
await (await import(/* @vite-ignore */ localePath)).setLocale("zh");
const stagingPath = "/src/features/settings/model/staging.ts";
(await import(/* @vite-ignore */ stagingPath)).selectPage(id);
}, id);
const labels = dialog.locator(selector);
await expect(labels.first()).toBeVisible();
for (const width of [1000, 640, 520]) {
await page.setViewportSize({ width, height: 760 });
const fits = await labels.evaluateAll((labels) =>
labels.every((label) => {
const box = label.getBoundingClientRect();
const range = document.createRange();
range.selectNodeContents(label);
const lines = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
return (
lines.every((r) => r.left >= box.left - 1 && r.right <= box.right + 1 && r.height <= 24) &&
Math.max(...lines.map((r) => r.bottom)) - Math.min(...lines.map((r) => r.top)) <= 24
);
}),
);
expect(fits).toBe(true);
}
});
}
for (const [id, selector] of [
["general", "[class*='fontRow'] > label"],
["build.console", "[class*='inlineRow'] > label"],
]) {
test(`${id} keeps a readable width for inline text`, async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.evaluate(async (id) => {
const localePath = "/src/platform/i18n.ts";
await (await import(/* @vite-ignore */ localePath)).setLocale("zh");
const path = "/src/features/settings/model/staging.ts";
(await import(/* @vite-ignore */ path)).selectPage(id);
}, id);
const text = page.locator(selector).first();
await expect(text).toBeVisible();
await page.setViewportSize({ width: 520, height: 760 });
expect((await text.boundingBox())!.width).toBeGreaterThan(100);
});
}
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from "./fixtures";
for (const kind of ["password", "sshSecret", "params"] as const) {
test(`database ${kind} dialog keeps its fields inside the window`, async ({ workbench: page }) => {
await page.evaluate(async (kind) => {
const path = "/src/features/database/store.ts";
const { openDialog } = await import(/* @vite-ignore */ path);
const source = {
id: "layout-test",
name: "Layout test",
driver: "postgres",
user: "account_".repeat(30),
ssh: { enabled: true, host: "bastion.example.invalid", auth: "password" },
};
if (kind === "password") openDialog({ kind, source, resolve: () => {} });
else if (kind === "sshSecret")
openDialog({ kind, source, prompt: "SSH password required for " + "host_".repeat(30), resolve: () => {} });
else openDialog({ kind, names: ["parameter_".repeat(25)], resolve: () => {} });
}, kind);
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
for (const width of [1000, 640, 500]) {
await page.setViewportSize({ width, height: 760 });
const fits = await dialog.evaluate((dialog) => {
const box = dialog.getBoundingClientRect();
return [...dialog.querySelectorAll("input, [class*='form_']")].every((el) => {
const r = el.getBoundingClientRect();
return r.left >= box.left && r.right <= box.right && el.scrollWidth <= el.clientWidth + 1;
});
});
expect(fits).toBe(true);
}
});
}
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from "./fixtures";
test("postfix template descriptions remain inside their rows", async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.evaluate(async () => {
const path = "/src/features/settings/model/staging.ts";
(await import(/* @vite-ignore */ path)).selectPage("editor.general.postfixCompletion");
});
const rows = page.getByRole("list", { name: "Postfix templates" }).getByRole("listitem");
await expect(rows.first()).toBeVisible();
for (const language of ["JavaScript / TypeScript", "Rust", "Python", "Go", "Java", "Kotlin"]) {
await page.getByRole("tab", { name: language, exact: true }).click();
for (const width of [1000, 640, 520]) {
await page.setViewportSize({ width, height: 760 });
const fits = await rows.evaluateAll((rows) =>
rows.every((row) => {
const bounds = row.getBoundingClientRect();
const detail = row.lastElementChild!;
const range = document.createRange();
range.selectNodeContents(detail);
return [...range.getClientRects()].every((r) => r.top >= bounds.top - 1 && r.bottom <= bounds.bottom + 1);
}),
);
expect(fits).toBe(true);
}
}
});
+84
View File
@@ -0,0 +1,84 @@
import { expect, test } from "./fixtures";
for (const [id, key, name] of [
["languages.node", "languages.node.interpreter", "Node interpreter"],
["languages.java", "languages.java.jdk", "Project JDK"],
["languages.php", "languages.php.interpreter", "PHP interpreter"],
]) {
test(`${name} keeps its value and browse controls inside their field`, async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.evaluate(
async ({ id, key }) => {
const path = "/src/features/settings/model/staging.ts";
const { selectPage, stage } = await import(/* @vite-ignore */ path);
stage(id, "project", key, "/project/" + "long-path/".repeat(15) + "runtime");
selectPage(id);
},
{ id, key },
);
const combo = page
.getByRole("dialog", { name: "Settings", exact: true })
.getByRole("button", { name, exact: true });
await expect(combo).toBeVisible();
for (const width of [1000, 800, 520]) {
await page.setViewportSize({ width, height: 760 });
const geometry = await combo.evaluate((el) => {
const field = el.parentElement!;
const bounds = field.getBoundingClientRect();
return [...field.querySelectorAll("button")].map((button) => {
const r = button.getBoundingClientRect();
return {
left: r.left,
right: r.right,
width: r.width,
fieldLeft: bounds.left,
fieldRight: bounds.right,
fits: r.width > 0 && r.left >= bounds.left - 1 && r.right <= bounds.right + 1,
};
});
});
expect(
geometry.every((r) => r.fits),
JSON.stringify({ width, geometry }),
).toBe(true);
}
});
}
test("Python interpreter controls stay separate and visible in a narrow settings pane", async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.evaluate(async () => {
const path = "/src/features/settings/model/staging.ts";
const { selectPage, stage } = await import(/* @vite-ignore */ path);
stage(
"python.interpreter",
"project",
"languages.python.interpreter",
"/project/" + "long-path/".repeat(15) + "python",
);
selectPage("python.interpreter");
});
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
const interpreter = dialog.getByRole("button", { name: "Python interpreter", exact: true });
const add = dialog.getByRole("button", { name: "Add Interpreter…", exact: true });
await expect(interpreter).toBeVisible();
for (const width of [1000, 800, 520]) {
await page.setViewportSize({ width, height: 760 });
const combo = await interpreter.boundingBox();
const button = await add.boundingBox();
const row = await add.evaluate((el) => {
const r = el.parentElement!.getBoundingClientRect();
return { left: r.left, right: r.right };
});
expect(combo!.width).toBeGreaterThan(20);
expect(combo!.x).toBeGreaterThanOrEqual(row.left - 1);
expect(combo!.x + combo!.width).toBeLessThanOrEqual(row.right + 1);
expect(button!.x + button!.width).toBeLessThanOrEqual(row.right + 1);
const overlap =
Math.min(combo!.x + combo!.width, button!.x + button!.width) > Math.max(combo!.x, button!.x) &&
Math.min(combo!.y + combo!.height, button!.y + button!.height) > Math.max(combo!.y, button!.y);
expect(overlap).toBe(false);
}
});
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from "./fixtures";
for (const [id, key, name] of [
["languages.node", "languages.node.interpreter", "Node interpreter"],
["languages.java", "languages.java.jdk", "Project JDK"],
["languages.php", "languages.php.interpreter", "PHP interpreter"],
]) {
test(`${name} dropdown keeps long custom paths inside the viewport`, async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
const value = "/project/" + "long-path/".repeat(30) + "runtime";
await page.evaluate(
async ({ id, key, value }) => {
const path = "/src/features/settings/model/staging.ts";
const { selectPage, stage } = await import(/* @vite-ignore */ path);
stage(id, "project", key, value);
selectPage(id);
},
{ id, key, value },
);
const combo = page.getByRole("button", { name, exact: true });
for (const width of [1000, 640, 520]) {
await page.setViewportSize({ width, height: 760 });
await combo.click();
const list = page.getByRole("listbox", { name, exact: true });
await expect(list).toBeVisible();
await expect
.poll(() =>
list.evaluate((list) => {
const popup = list.closest("[data-popup]")!;
const r = popup.getBoundingClientRect();
return r.left >= 0 && r.right <= innerWidth && list.scrollWidth <= list.clientWidth + 1;
}),
)
.toBe(true);
const option = list.getByRole("option").filter({ hasText: value });
await expect(option).toBeVisible();
await expect(option.locator("[title]").first()).toHaveAttribute("title", value);
await option.click();
await expect(list).toHaveCount(0);
await expect(combo).toHaveText(value);
}
});
}
+40
View File
@@ -1,5 +1,45 @@
import { expect, test } from "./fixtures";
test("Add Python Interpreter keeps environment labels and fields inside their rows", async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
await settings.getByRole("textbox", { name: "Search settings" }).fill("Python Interpreter");
await settings
.getByRole("tree", { name: "Settings pages" })
.getByRole("treeitem", { name: "Python Interpreter", exact: true })
.click();
await settings.getByRole("button", { name: "Add Interpreter…", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Add Python Interpreter", exact: true });
await expect(dialog).toBeVisible();
for (const width of [1000, 800, 520]) {
await page.setViewportSize({ width, height: 760 });
await expect(async () => {
const labelsFit = await dialog.getByRole("list", { name: "Interpreter type" }).evaluate((list) =>
[...list.querySelectorAll("button")].every((button) => {
const box = button.getBoundingClientRect();
const range = document.createRange();
range.selectNodeContents(button);
return [...range.getClientRects()].every(
(r) => r.top >= box.top - 1 && r.bottom <= box.bottom + 1 && r.right <= box.right + 1,
);
}),
);
expect(labelsFit).toBe(true);
}).toPass({ timeout: 3000 });
const location = dialog.getByRole("textbox", { name: "Location:", exact: true });
const fieldSpacing = await location.evaluate((input) => ({
rowHeight: input.parentElement!.getBoundingClientRect().height,
fieldHeight: input.getBoundingClientRect().height,
}));
expect(fieldSpacing.rowHeight).toBeLessThanOrEqual(fieldSpacing.fieldHeight + 1);
const base = await dialog.getByRole("button", { name: "Base interpreter", exact: true }).boundingBox();
const loc = await location.boundingBox();
expect(base!.y - loc!.y - loc!.height).toBeLessThan(50);
}
});
test("Project Structure keeps edits across repeated shortcuts and applies them", async ({ workbench: page }) => {
await page.keyboard.press("Meta+;");
const dialog = page.getByRole("dialog", { name: "Project Structure", exact: true });
+36
View File
@@ -0,0 +1,36 @@
import { expect, test } from "./fixtures";
test("dirty settings keep scope labels and navigation inside the page header", async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.evaluate(async () => {
const path = "/src/features/settings/model/staging.ts";
const { selectPage, stage } = await import(/* @vite-ignore */ path);
stage("languages.node", "project", "languages.node.interpreter", "/custom/node");
selectPage("languages.node");
});
const scope = page.getByRole("radiogroup", { name: "Settings scope" });
const reset = page.getByRole("button", { name: "Reset", exact: true });
await expect(reset).toBeVisible();
for (const width of [1000, 640, 520]) {
await page.setViewportSize({ width, height: 760 });
const fits = await scope.evaluate((scope) => {
const header = scope.parentElement!;
const bounds = header.getBoundingClientRect();
return [...header.querySelectorAll("button")].every((button) => {
const box = button.getBoundingClientRect();
const range = document.createRange();
range.selectNodeContents(button);
return (
box.left >= bounds.left &&
box.right <= bounds.right &&
[...range.getClientRects()].every((r) => r.top >= box.top - 1 && r.bottom <= box.bottom + 1)
);
});
});
expect(fits).toBe(true);
}
await reset.click();
await expect(reset).toHaveCount(0);
await expect(scope.getByRole("radio", { name: "This project" })).toBeVisible();
});
+73
View File
@@ -0,0 +1,73 @@
import { expect, test } from "./fixtures";
for (const kind of ["external", "ssh", "target"] as const) {
test(`${kind} settings keep long names and details inside their selection row`, async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.evaluate(async (kind) => {
const path = "/src/features/settings/model/staging.ts";
const { selectPage, stage } = await import(/* @vite-ignore */ path);
const remotePath = "/src/platform/remoteSettings.ts";
const workflowPath = "/src/platform/workflowSettings.ts";
const { DEFAULT_SSH_CONFIG, DEFAULT_RUN_TARGET } = await import(/* @vite-ignore */ remotePath);
const { DEFAULT_EXTERNAL_TOOL } = await import(/* @vite-ignore */ workflowPath);
const name = "Run integration tests for the current project with the configured environment";
const detail = "Shared infrastructure for the staging environment";
if (kind === "external") {
stage("tools.externalTools", "global", "externalTools", [
{ ...DEFAULT_EXTERNAL_TOOL, id: "qa", name, group: detail },
]);
selectPage("tools.externalTools");
} else if (kind === "ssh") {
stage("tools.sshConfigurations", "global", "ssh.configs", [
{ ...DEFAULT_SSH_CONFIG, id: "qa", name, host: detail, user: "qa" },
]);
selectPage("tools.sshConfigurations");
} else {
stage("build.runTargets", "global", "runTargets", [
{ ...DEFAULT_RUN_TARGET, id: "qa", name, kind: "docker", image: detail },
]);
selectPage("build.runTargets");
}
}, kind);
const row = page.getByRole("dialog", { name: "Settings", exact: true }).getByRole("option").first();
await expect(row).toBeVisible();
for (const width of [1000, 640, 520]) {
await page.setViewportSize({ width, height: 760 });
const fits = await row.evaluate((row) => {
const box = row.getBoundingClientRect();
return [...row.querySelectorAll(":scope > span")].every((span) => {
const range = document.createRange();
range.selectNodeContents(span);
return [...range.getClientRects()].every((r) => r.top >= box.top - 1 && r.bottom <= box.bottom + 1);
});
});
expect(fits).toBe(true);
}
await expect(row.locator(":scope > span").first()).toHaveAttribute(
"title",
"Run integration tests for the current project with the configured environment",
);
await page.evaluate(async (kind) => {
const path = "/src/features/settings/model/staging.ts";
const { stage } = await import(/* @vite-ignore */ path);
const [id, key] =
kind === "external"
? ["tools.externalTools", "externalTools"]
: kind === "ssh"
? ["tools.sshConfigurations", "ssh.configs"]
: ["build.runTargets", "runTargets"];
stage(id, "global", key, []);
}, kind);
const empty = page.getByRole("dialog", { name: "Settings", exact: true }).getByRole("listbox");
await expect(row).toHaveCount(0);
const hintFits = await empty.evaluate((list) => {
const hint = list.firstElementChild!;
const box = hint.getBoundingClientRect();
const range = document.createRange();
range.selectNodeContents(hint);
return [...range.getClientRects()].every((r) => r.top >= box.top - 1 && r.bottom <= box.bottom + 1);
});
expect(hintFits).toBe(true);
});
}
+98
View File
@@ -0,0 +1,98 @@
import { expect, test } from "./fixtures";
for (const [id, selector] of [
["build.deployment", "[class*='layout'] > [class*='note']"],
["editor.inspections", "[class*='split'] > [class*='details']"],
["languages.servers", "[class*='body'] > [class*='details']"],
["plugins", "[class*='body'] > [class*='details']"],
["tools.agents", "section[aria-label='Codex executable']"],
]) {
test(`${id} keeps its details usable beside the list`, async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
await expect(dialog).toBeVisible();
await page.evaluate(async (id) => {
if (id === "tools.agents") {
const path = "/src/features/agents/registry.ts";
const { tauriCommands } = await import(/* @vite-ignore */ path);
tauriCommands.providers = async () => [
{
id: "codex",
name: "Codex",
program: "codex",
defaultProgram: "codex",
args: [],
defaultArgs: [],
installed: true,
commandOverridden: false,
env: {},
},
];
}
const path = "/src/features/settings/model/staging.ts";
(await import(/* @vite-ignore */ path)).selectPage(id);
}, id);
const details = dialog.locator(selector).first();
await expect(details).toBeAttached();
for (const width of [1000, 640, 520]) {
await page.setViewportSize({ width, height: 760 });
expect((await details.boundingBox())!.width).toBeGreaterThan(180);
}
});
}
test("every color scheme page keeps its attribute tree visible", async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.setViewportSize({ width: 520, height: 760 });
for (const section of [
"general",
"language-defaults",
"debugger",
"python",
"markdown",
"regexp",
"java",
"kotlin",
"php",
]) {
await page.evaluate(async (section) => {
const path = "/src/features/settings/model/staging.ts";
(await import(/* @vite-ignore */ path)).selectPage(`editor.colorScheme.${section}`);
}, section);
const tree = page.getByRole("tree", { name: "Color scheme attributes" });
await expect(tree).toBeAttached();
expect((await tree.boundingBox())!.width).toBeGreaterThan(150);
}
});
for (const id of ["editor.fileTemplates", "editor.liveTemplates"]) {
test(`${id} keeps its editor reachable beside the template list`, async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.evaluate(async (id) => {
const path = "/src/features/settings/model/staging.ts";
(await import(/* @vite-ignore */ path)).selectPage(id);
}, id);
await page.setViewportSize({ width: 520, height: 760 });
const editor = page.locator("[class*='split'] > [class*='editor']").first();
await expect(editor).toBeAttached();
expect((await editor.boundingBox())!.width).toBeGreaterThan(200);
});
}
test("Project Structure keeps every details pane usable in a compact window", async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
await page.evaluate(async () => {
const path = "/src/features/settings/model/staging.ts";
(await import(/* @vite-ignore */ path)).selectPage("projectStructure");
});
await page.setViewportSize({ width: 520, height: 760 });
for (const section of ["Modules", "Libraries", "Facets", "Artifacts", "SDKs"]) {
await page.getByRole("tab", { name: section, exact: true }).click();
const details = page.locator("[class*='splitLayout'] > [class*='detailPane']");
await expect(details).toBeAttached();
expect((await details.boundingBox())!.width).toBeGreaterThan(200);
}
});
+11
View File
@@ -26,6 +26,17 @@ args = ["language-server"]
root-markers = ["composer.json", ".phpactor.json"]
install-hint = "Install phpactor (https://phpactor.readthedocs.io/en/master/usage/standalone.html)"
# Laravel projects only: hover, definition and completion inside
# config('…'), route('…') and view('…'). Last: Intelephense keeps PHP hover,
# rename and symbols. https://github.com/laravel/lsp
[[language-servers]]
name = "laravel-lsp"
command = "laravel-lsp"
args = ["--stdio"]
root-markers = ["artisan"]
require-root-marker = true
install-hint = "composer global require laravel/lsp"
[debugger]
adapter = "php-debug"
command = "php-debug-adapter"
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.38",
"version": "0.1.39",
"license": "Apache-2.0",
"type": "module",
"scripts": {
@@ -0,0 +1,75 @@
package openidea:[email protected];
/// The commit being prepared in the window the current command or event came
/// from (the Commit tool window, or the Commit dialog while it is open): the
/// changes it holds and its message.
interface commit {
use types.{error};
/// A file the commit changes.
record commit-change {
/// Absolute path of the working tree root of its repository.
repository: string,
/// Absolute path (after a rename, the new one).
path: string,
/// The path before a rename or copy.
old-path: option<string>,
/// `added`, `modified`, `deleted`, `renamed`, `copied` or
/// `type-changed` (more may be added).
status: string,
/// What the commit changes in the file, in `git diff` format against
/// HEAD: only the changed blocks included in the commit. `none` for
/// binary content.
diff: option<string>,
/// `diff` was cut at the size limit (1 MiB for all the changes).
truncated: bool,
}
/// What Commit would commit right now.
record commit-draft {
/// Changes whenever the message, the included changes or Amend do.
revision: u64,
message: string,
/// The commit replaces the last one (Amend); `changes` are still
/// against HEAD, so they don't include the last commit's own.
amend: bool,
changes: list<commit-change>,
}
/// The commit being prepared, as Commit would make it: the included
/// files and changed blocks only. Unsaved editors of the included files
/// are saved first, as Commit does; nothing is staged or committed.
/// `none` when the window's project has no repository. Fails with
/// `invalid` outside a command or event of a window, and in a project that
/// is not trusted. Requires `vcs.read`.
pending-commit: func() -> result<option<commit-draft>, error>;
/// Replaces the commit message, only if the draft is still at `revision`
/// (else `stale`, and nothing changes): text the user typed meanwhile is
/// never overwritten. The user reviews it and commits. Same windows as
/// `pending-commit`. Requires `vcs.write`.
set-commit-message: func(revision: u64, message: string) -> result<_, error>;
}
/// Before-commit checks (`[[contributes.commitChecks]]`), run when the user
/// commits, after the IDE's own checks, for the checks enabled in Commit
/// Options. Problems stop the commit and show beside the message until the
/// user changes it or chooses Commit Anyway. Requires `vcs.read`.
interface commit-checker {
use types.{error, location};
use invocation.{invocation};
use commit.{commit-draft};
/// Something the user should look at before committing.
record commit-problem {
/// One sentence, shown after the check's title.
message: string,
/// Where it is, opened from the problem; `none` for the commit as a
/// whole (its message, for instance).
location: option<location>,
}
/// Runs the declared check `check` on `draft`: no problems lets the commit
/// go on. A call that fails or runs out of time is reported as a problem
/// of the check.
check: func(check: string, draft: commit-draft, ctx: borrow<invocation>) -> result<list<commit-problem>, error>;
}
@@ -0,0 +1,50 @@
package openidea:[email protected];
/// What the pane beside the Services tree shows for a plugin's node.
interface detail-types {
/// A switch above a log; when on, `args` are appended and the program
/// starts again.
record log-toggle {
id: string,
label: string,
args: list<string>,
}
/// A program the IDE runs, its output followed in a log (ANSI stripped,
/// the last 5000 lines) until it exits or the pane closes. Needs the
/// plugin's `process:<program>` grant; runs in the project root.
record process-log {
program: string,
args: list<string>,
toggles: list<log-toggle>,
}
/// The body of a tab.
variant pane {
/// A centred message: an empty state or why there is nothing to show.
message(string),
/// Read-only text the user can select and copy, at most 1 MiB.
text(string),
log(process-log),
}
/// A tab of a node's pane; the IDE switches tabs without calling the plugin.
record tab {
id: string,
label: string,
body: pane,
}
}
/// The detail pane of the nodes of a plugin's `location = "services"` views.
/// The nodes themselves, the toolbar and their events come from
/// `view-provider` (`render` / `handle` of the view).
interface detail-provider {
use types.{error};
use invocation.{invocation};
use detail-types.{tab};
/// The tabs of node `key` of view `view` (at most 8), rendered when the
/// node is selected and again after `views.refresh(view)`.
detail: func(view: string, key: string, ctx: borrow<invocation>) -> result<list<tab>, error>;
}
@@ -0,0 +1,22 @@
package openidea:[email protected];
/// Optional export (world `plugin-diagnostics`): problems in the current,
/// possibly unsaved document, for the languages declared with the
/// `diagnostics` provider kind. Asked shortly after a document opens or
/// changes, under the editor-provider budget; it can't ask the user.
interface diagnostics-provider {
use diagnostics.{diagnostic};
/// The document to check (the editor's text, unsaved edits included).
record document {
/// Absolute path.
path: string,
language: string,
version: u64,
text: string,
}
/// The document's problems, at most 5,000; they replace what this
/// provider reported before, and an empty list removes them.
diagnostics: func(doc: document) -> list<diagnostic>;
}
@@ -0,0 +1,26 @@
package openidea:[email protected];
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
interface endpoints-provider {
use types.{error, location};
use invocation.{invocation};
/// One HTTP route.
record endpoint {
/// `none` accepts any HTTP method.
method: option<string>,
/// Path parameters use braces, e.g. `/photos/{id}`.
path: string,
/// Shown next to the route, e.g. `Rails`.
framework: string,
/// Display name, e.g. `PhotosController#show`.
handler: option<string>,
/// Where the route is declared, inside the project.
location: location,
}
/// The project's routes, complete. Called when the IDE first needs them and
/// again after a file matching `files` changes; cached in between. Runs in
/// the background: it can't ask the user.
endpoints: func(ctx: borrow<invocation>) -> result<list<endpoint>, error>;
}
@@ -1,4 +1,4 @@
package openidea:plugin@1.2.0;
package openidea:plugin@1.3.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
@@ -29,18 +29,25 @@ interface events {
/// Delivers `kind` events from now on, for the rest of the session (also
/// across idle reclaim: an event activates the plugin again). Declare
/// `activation = ["on-event:<kind>"]` to get them before first use.
/// Kinds: `file-opened`, `file-saved`, `editor-focused` (the focused editor
/// Kinds: `file-opened`, `file-saved`, `files-changed` (batched disk changes
/// as `other`, with created/modified/removed path lists in JSON), `editor-focused` (the focused editor
/// changed), `vcs-changed` (a repository's state changed: commit,
/// checkout, fetch…), `action-performed` (an IDE action ran) and
/// `scheduled` (the `on-schedule` period elapsed); later IDEs add more,
/// and an IDE that doesn't know one fails with `invalid`.
/// Requires `events` (`events.input` for `action-performed`).
subscribe: func(kind: string) -> result<_, error>;
/// Replaces this kind's subscription with project-relative globs (OR).
/// Empty globs pause delivery; `subscribe` restores unfiltered delivery.
/// Kinds: file-opened, file-saved, files-changed. Requires `events`.
subscribe-paths: func(kind: string, globs: list<string>) -> result<_, error>;
unsubscribe: func(kind: string);
}
/// Long-running work with a progress indicator. While a task is open, the
/// call that began it may run for the background budget (60 s) instead of 5 s.
/// call that began it may run for the background budget (60 s) instead of 5 s,
/// and each `report` or `is-cancelled` renews that budget: work that keeps
/// saying it is alive is not cut off, while work that goes quiet is.
interface tasks {
resource task {
/// `done` of `total` units (`none`: indeterminate), with a short status.
@@ -1,4 +1,4 @@
package openidea:plugin@1.2.0;
package openidea:plugin@1.3.0;
interface lifecycle {
use types.{context, error};
@@ -0,0 +1,80 @@
package openidea:[email protected];
/// Optional export (world `plugin-file-actions`): quick fixes and
/// refactorings that create, rename or delete files, next to the text edits
/// `providers.code-actions` offers. Declare the `code-actions` provider kind in
/// `[[contributes.providers]]`; the host calls `code-actions` here with the same
/// budget and lists the results with the others.
interface file-actions {
use types.{error, range, text-edit};
/// The document the actions are for (the editor's text while it is open).
record document {
/// Absolute path.
path: string,
language: string,
version: u64,
text: string,
}
/// Text edits to one file.
record file-edit {
path: string,
edits: list<text-edit>,
}
/// A file change, applied before the action's text edits.
variant file-operation {
/// Fails when the file exists, unless `overwrite`.
create(create-file),
rename(rename-file),
/// Deletes the file; undoing the action restores it.
delete(delete-file),
}
/// A new file with its text; missing parent directories are created.
record create-file {
path: string,
text: string,
overwrite: bool,
}
/// Moves `path` to `new-path`.
record rename-file {
path: string,
new-path: string,
}
/// The file to delete.
record delete-file {
path: string,
}
/// A code action that changes files. Paths are absolute or relative to the
/// project root, and stay inside it: a path outside disables the action.
record file-action {
title: string,
/// `quickfix`, `refactor`, …
kind: option<string>,
preferred: bool,
/// Applied first, in order, as one workspace edit with `edits`: if
/// any operation or edit fails, nothing is applied, and one undo
/// reverses all of it.
operations: list<file-operation>,
/// Applied after the operations; may target files they create.
edits: list<file-edit>,
/// Opened once the action is applied.
open: option<string>,
/// The plugin's own reference: with it and no `operations` or `edits`,
/// they are computed by `resolve` once the user picks the action.
data: option<string>,
}
/// Actions for `selection`, under the edit-path budget.
code-actions: func(doc: document, selection: range) -> list<file-action>;
/// The operations and edits of an action listed with `data` and nothing
/// to apply, once the user picks it (the command budget applies, not the
/// editor's 50 ms). The same four prompts as `providers.resolve-code-action`
/// work here, in the editor's window and excluding the wait from the budget.
resolve: func(doc: document, action: file-action) -> result<file-action, error>;
}
@@ -0,0 +1,55 @@
package openidea:[email protected];
/// Files outside the open projects: the configuration of the program a
/// plugin integrates with, a tool installed elsewhere, a licence file.
///
/// A plugin's own WASI has no preopened directory. Every path outside its
/// own `plugin-dir` is checked against its `fs.read:<path>` (reading) or
/// `fs.write:<path>`
/// (writing; it doesn't imply reading) grants; without one covering the path a
/// call returns `denied` naming the capability the manifest has to declare. A
/// file inside a project is `workspace.project.read-file` / `write-file`.
/// `path` is absolute, or starts with `~/` for the user's home.
interface fs {
use types.{error};
use workspace.{file-kind, file-stat};
/// The plugin's persistent private directory, created owner-only (0700,
/// owner-only ACL on Windows), removed on uninstall. Reading and writing
/// under it needs no fs.* grant; files created there are owner-only.
plugin-dir: func() -> result<string, error>;
/// A text file, decoded as the editor decodes it (BOM and encoding
/// detection), at most 16 MiB. Unsaved editor contents win, as they do
/// for `project.read-file`.
read-file: func(path: string) -> result<string, error>;
/// The bytes of a file (images, class files, archives), at most 16 MiB.
read-bytes: func(path: string) -> result<list<u8>, error>;
/// Metadata of an existing path, as `project.stat` reports it; a missing
/// path is `not-found`.
stat: func(path: string) -> result<file-stat, error>;
/// An entry of a directory.
record dir-entry {
/// The entry's name, without its directory.
name: string,
kind: file-kind,
}
/// The entries directly under a directory, sorted by name, at most 4096
/// (like `find-files`); a file is `invalid`.
read-dir: func(path: string) -> result<list<dir-entry>, error>;
/// Replaces the text of a file, or creates it, at most 16 MiB: in one step
/// (a reader never sees half of it), keeping the file's encoding and BOM;
/// a new file is UTF-8. `invalid` when the text can't be written in that
/// encoding, the directory is missing, the path is a directory, or an
/// editor holds unsaved changes for the file (save them first).
write-file: func(path: string, text: string) -> result<_, error>;
/// Replaces the bytes of a file, or creates it, at most 16 MiB, in one
/// step; fails like `write-file`.
write-bytes: func(path: string, bytes: list<u8>) -> result<_, error>;
}
@@ -1,4 +1,4 @@
package openidea:plugin@1.2.0;
package openidea:plugin@1.3.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
@@ -33,9 +33,23 @@ interface ui {
text(string),
}
/// A notification button that runs one of the plugin's commands.
record notification-action {
/// Up to 256 bytes, one line.
label: string,
/// The id of a command the plugin declares.
command: string,
/// Passed to the command as its argument (up to 64 KiB).
argument: option<string>,
}
/// Shows a notification balloon in the window the current command or
/// event came from (every window when there is none).
show-notification: func(level: notification-level, title: string, body: string);
/// `show-notification` with up to three action buttons. Choosing one
/// dismisses the balloon and runs the command in its window. An invalid
/// action or an undeclared command fails the call and shows nothing.
notify: func(level: notification-level, title: string, body: string, actions: list<notification-action>) -> result<_, error>;
/// Shows or updates the plugin's status bar item `id` in every window.
set-status-item: func(id: string, item: status-item);
remove-status-item: func(id: string);
@@ -51,24 +65,36 @@ interface ui {
copy-to-clipboard: func(text: string) -> result<_, error>;
/// Opens an `http://` or `https://` URL in the user's default browser.
open-url: func(url: string) -> result<_, error>;
/// Shows a list in the window the current command or event came from and
/// waits for the key of the chosen item, or `none` when the user dismisses
/// it. Needs no capability. Returns `invalid` outside a command or event of
/// a window (including in editor providers). Waiting doesn't count against
/// the call's budget.
/// Shows a list and waits for the chosen key, or `none` when dismissed.
/// Works in a command/event of a window, or while resolving a selected code
/// or file action in its editor's window; `invalid` otherwise.
/// Needs no capability. Waiting doesn't count against the call's budget.
pick: func(title: string, items: list<pick-item>) -> result<option<string>, error>;
/// Native folder chooser; an absolute path, or `none` when dismissed.
/// Only from a command or event of a window; waiting is excluded from the budget.
pick-directory: func(title: string, initial: option<string>) -> result<option<string>, error>;
/// Like `pick`, with any number of items chosen.
pick-many: func(title: string, items: list<pick-item>) -> result<option<list<string>>, error>;
/// Asks for a line of text, pre-filled with `value`; `none` when dismissed.
/// Same rules as `pick`.
input: func(prompt: string, value: option<string>) -> result<option<string>, error>;
/// Asks for text over several lines, pre-filled with `value`; `none` when
/// dismissed. Only from a command or event of a window; the dialog confirms
/// on Cmd/Ctrl+Enter.
input-multiline: func(prompt: string, value: option<string>) -> result<option<string>, error>;
/// Asks for confirmation with OK and Cancel: `true` on OK, `false` on
/// Cancel or Escape. Same rules as `pick`.
confirm: func(title: string, body: string) -> result<bool, error>;
/// The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`, …); it
/// follows a change of language. For the text the plugin builds itself:
/// the strings of its manifest and views are translated from
/// `locales/<tag>.json`.
locale: func() -> string;
}
/// Persistent key-value storage of the plugin (survives restarts and idle
/// reclaim; instance memory does not). Requires `storage`. At most 1 MiB.
/// reclaim; instance memory does not). Requires `storage`. At most 64 MiB,
/// counting UTF-8 bytes of keys and values; rejected writes preserve the data.
interface storage {
use types.{error};
@@ -0,0 +1,29 @@
package openidea:[email protected];
/// Optional export (world `plugin-hover`): Markdown added to the hover of
/// documents of the languages declared with the `hover` provider kind,
/// including languages the plugin doesn't contribute. It shows below the
/// language server's hover, under the edit-path budget.
interface hover-provider {
use types.{position, range};
/// The document hovered (the editor's text while it is open).
record document {
/// Absolute path.
path: string,
language: string,
version: u64,
text: string,
}
/// What the plugin adds to the hover.
record hover-content {
/// At most 64 KiB.
markdown: string,
/// The text the hover describes; used when the server names none.
range: option<range>,
}
/// The hover at `at`, or `none` to add nothing.
hover: func(doc: document, at: position) -> option<hover-content>;
}
@@ -1,4 +1,4 @@
package openidea:plugin@1.2.0;
package openidea:plugin@1.3.0;
/// Cross-file associations a plugin publishes for the files it declares under
/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable
@@ -0,0 +1,39 @@
package openidea:[email protected];
/// Optional export (world `plugin-inlay-hints`): inline hints in documents of
/// the languages declared with the `inlay-hints` provider kind, including
/// languages the plugin doesn't contribute. They are shown next to the
/// language server's hints.
interface inlay-hints-provider {
use types.{position, range};
/// The document the hints are for (the editor's text while it is open).
record document {
path: string,
language: string,
version: u64,
text: string,
}
/// A hint shown at `position`, before the character there.
record inlay-hint {
/// UTF-16 position (0-based line and column).
position: position,
/// The text shown, e.g. `GET /orders` or `: string`.
label: string,
/// `type` or `parameter` follow the Inlay Hints settings of that kind;
/// anything else counts as `other`.
kind: string,
/// Shown when the pointer rests on the hint.
tooltip: option<string>,
/// A space before the label.
padding-left: bool,
/// A space after the label.
padding-right: bool,
}
/// The hints within `range` (the visible part of the document), under the
/// edit-path budget. Hints outside `range` are dropped; at most 10,000 per
/// call.
inlay-hints: func(doc: document, range: range) -> list<inlay-hint>;
}
@@ -1,4 +1,4 @@
package openidea:plugin@1.2.0;
package openidea:plugin@1.3.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
@@ -75,8 +75,10 @@ interface secrets {
is-set: func(name: string) -> bool;
}
/// One-off runs of programs granted with `process:<program>` (the file the
/// user approved, checked before every run).
/// Runs of programs granted with `process:<program>` (the file the user
/// approved, checked before every run): once and awaited with `run`, once
/// with an environment with `run-command`, or kept running behind a `child`
/// handle started with `start`.
interface process {
use types.{error};
use workspace.{project};
@@ -89,8 +91,56 @@ interface process {
stderr: string,
}
/// What to run (API 1.3). `env` only adds variables: an existing name in
/// the prepared child environment (including the login-shell PATH) is an
/// error naming that key; nothing is replaced or removed. Names compare
/// case-insensitively on Windows. At most 64 entries, nonempty names of
/// at most 256 bytes without '=' or NUL, values at most 4 KiB without NUL.
record command {
program: string,
args: list<string>,
env: list<tuple<string, string>>,
}
/// What a kept-running program printed since the last call, and its exit
/// code once it ended (`none` while it still runs).
record chunk {
/// Up to 1 MiB per stream; unpolled output keeps the last 1 MiB.
/// `poll` takes up to `max-bytes`, or one whole character when that
/// limit is smaller. Incomplete characters wait for the next read.
stdout: string,
stderr: string,
status: option<s32>,
}
/// A program left running. Dropping the handle kills it if it still
/// runs, so a plugin cannot leak one; a plugin has at most 8 at a time
/// (the ninth `start` is `invalid`). They also end with the plugin:
/// stopping, disabling or reclaiming it kills what it started.
resource child {
/// What it printed since the last call, and its status once it ends.
poll: func(max-bytes: u32) -> result<chunk, error>;
/// Waits up to `timeout-ms` for it to end (0 only checks), then
/// reports what it printed since the last call.
wait: func(timeout-ms: u32) -> result<chunk, error>;
/// Writes to its stdin; fails once it ended or stdin was closed.
write: func(text: string) -> result<_, error>;
/// Closes stdin; fails once the program ended.
close-stdin: func() -> result<_, error>;
/// Kills it and reaps it, so its status is available afterwards.
kill: func() -> result<_, error>;
}
/// Runs `program` with `args` in `cwd`'s root (else the plugin's data
/// directory) and waits; cancelled with the task or call. Call it inside
/// a task for anything slow.
run: func(program: string, args: list<string>, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>;
/// The same as `run`, with append-only environment variables (API 1.3).
run-command: func(cmd: command, cwd: option<borrow<project>>, stdin: option<string>) -> result<output, error>;
/// Starts `cmd` with piped stdin and stdout/stderr, and returns the
/// handle to it without waiting (API 1.3). Closing the invoking window
/// also ends its processes.
start: func(cmd: command, cwd: option<borrow<project>>) -> result<child, error>;
}
@@ -3,20 +3,23 @@
/// API"); a 2.0 keeps 1.0 running through an adapter in the host.
///
/// A plugin is a WebAssembly component targeting the `plugin` world (or
/// `plugin-syntax`). It runs sandboxed: no filesystem, network, environment
/// or processes beyond WASI's clocks and randomness; everything else goes
/// through the imports below, gated by the capabilities granted in
/// `plugin.toml` (a missing one returns `error::denied`).
package openidea:[email protected];
/// `plugin-syntax`). It runs sandboxed: no file system, network, environment
/// or processes beyond WASI's clocks and randomness; files are read through
/// `fs` and `workspace`, and everything else goes through the imports below,
/// gated by the capabilities granted in `plugin.toml` (a missing one returns
/// `error::denied`).
package openidea:[email protected];
world plugin {
import log;
import ui;
import storage;
import fs;
import workspace;
import editor;
import invocation;
import vcs;
import commit;
import events;
import tasks;
import diagnostics;
@@ -56,6 +59,43 @@ world plugin-indexing {
export document-indexer;
}
/// A plugin with semantic tokens (`semantic-tokens` under `[[contributes.providers]]`).
world plugin-semantic-tokens {
include plugin;
export semantic-tokens-provider;
}
/// A plugin adding to hovers (`hover` under `[[contributes.providers]]`).
world plugin-hover {
include plugin;
export hover-provider;
}
/// A plugin with inlay hints (`inlay-hints` under `[[contributes.providers]]`).
world plugin-inlay-hints {
include plugin;
export inlay-hints-provider;
}
/// A plugin whose views show in the Services tool window
/// (`location = "services"`): their nodes' detail pane.
world plugin-details {
include plugin;
export detail-provider;
}
/// A plugin with before-commit checks (`[[contributes.commitChecks]]`).
world plugin-commit-checks {
include plugin;
export commit-checker;
}
/// A plugin with file-changing code actions (`file-actions`, next to `[[contributes.providers]]`).
world plugin-file-actions {
include plugin;
export file-actions;
}
/// Only the `syntax-provider` export: guests generate it next to `plugin`
/// (the component tools merge both into one component).
world syntax {
@@ -76,3 +116,55 @@ world views-provider {
world indexing {
export document-indexer;
}
/// Only the `semantic-tokens-provider` export, generated next to `plugin` like `syntax`.
world semantic-tokens {
export semantic-tokens-provider;
}
/// Only the `hover-provider` export, generated next to `plugin` like `syntax`.
world hover {
export hover-provider;
}
/// Only the `inlay-hints-provider` export, generated next to `plugin` like `syntax`.
world inlay-hints {
export inlay-hints-provider;
}
/// Only the `detail-provider` export, generated next to `plugin` like `syntax`.
world details {
export detail-provider;
}
/// Only the `commit-checker` export, generated next to `plugin` like `syntax`.
world commit-checks {
export commit-checker;
}
/// Only the `file-actions` export, generated next to `plugin` like `syntax`.
world file-actions-provider {
export file-actions;
}
/// A plugin with project HTTP endpoints (`[[contributes.endpoints]]`).
world plugin-endpoints {
include plugin;
export endpoints-provider;
}
/// Only the `endpoints-provider` export, generated next to `plugin` like `syntax`.
world endpoints {
export endpoints-provider;
}
/// A plugin with diagnostics under `[[contributes.providers]]`.
world plugin-diagnostics {
include plugin;
export diagnostics-provider;
}
/// `diagnostics` is already the released host interface name.
world editor-diagnostics {
export diagnostics-provider;
}
@@ -1,4 +1,4 @@
package openidea:plugin@1.2.0;
package openidea:plugin@1.3.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
@@ -75,6 +75,9 @@ interface providers {
code-actions: func(doc: document, selection: range) -> list<code-action>;
/// The edits of an action `code-actions` returned with `data`, when the
/// user picks it (the command budget applies, not the editor's 50 ms).
/// `ui.pick`, `pick-many`, `input` and `confirm` ask in the editor's window;
/// waiting is excluded from the budget. Other command/event-only APIs
/// remain unavailable.
resolve-code-action: func(doc: document, action: code-action) -> result<code-action, error>;
definition: func(doc: document, at: position) -> list<location>;
line-markers: func(doc: document) -> list<line-marker>;
@@ -0,0 +1,31 @@
package openidea:[email protected];
/// Optional export (world `plugin-semantic-tokens`): semantic colouring of
/// documents of the languages declared with the `semantic-tokens` provider
/// kind, including languages the plugin doesn't contribute. The tokens join
/// the language server's and win where they overlap one.
interface semantic-tokens-provider {
use types.{range};
/// The document to colour (the editor's text while it is open).
record document {
/// Absolute path.
path: string,
language: string,
version: u64,
text: string,
}
/// A token: part of one line.
record semantic-token {
range: range,
/// An LSP token type in kebab-case (`method`, `property`, `type`, …).
kind: string,
/// LSP token modifiers in kebab-case (`static`, `deprecated`, …).
modifiers: list<string>,
}
/// The document's tokens, at most 65,536; asked again after each edit,
/// under the edit-path budget.
semantic-tokens: func(doc: document) -> list<semantic-token>;
}
@@ -1,4 +1,4 @@
package openidea:plugin@1.2.0;
package openidea:plugin@1.3.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
@@ -1,4 +1,4 @@
package openidea:plugin@1.2.0;
package openidea:plugin@1.3.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
@@ -123,6 +123,7 @@ interface view-types {
/// What a plugin's code can ask of its views.
interface views {
use types.{error};
use detail-types.{process-log};
/// The view (or every custom editor of that id) shows outdated content:
/// the IDE renders it again.
@@ -136,9 +137,18 @@ interface views {
/// `program` (pinned by a `process:<program>` grant) with `args` in the
/// project root: long-running output (logs) and interactive sessions.
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
/// Opens a read-only log tab in the editor area of the window the call
/// came from, following `log`'s program (pinned by a `process:<program>`
/// grant) in the project root, its toggles above the output: the tab a
/// web view opens with `open-log-tab`. Reopening `key` focuses that tab.
/// Only from a command or event of a window; `title` at most 256
/// characters, `key` 128, at most 32 toggles.
open-log-tab: func(key: string, title: string, log: process-log) -> result<_, error>;
/// Opens `path` (project-relative or absolute, inside the project) in an
/// editor of the window the call came from, the caret at `line` and
/// `column` (0-based; UTF-16 column): a view's links to code.
/// An absolute path inside the plugin's own fs.plugin-dir also works,
/// without project.read or an open project, from a command or event.
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
}
@@ -1,7 +1,43 @@
package openidea:plugin@1.2.0;
package openidea:plugin@1.3.0;
interface workspace {
use types.{error};
use types.{error, location};
/// Requests File > Open in this command/event's window (or a new window),
/// including the usual project trust prompt. Needs no capability.
/// `path` is an absolute directory path on the UI machine.
open-project: func(path: string, new-window: bool) -> result<_, error>;
/// The open project containing an absolute document path, or `none`.
/// Reading the returned project still requires `project.read`.
project-of: func(path: string) -> option<project>;
/// What a path inside a project is.
enum file-kind {
file,
directory,
symlink,
other,
}
/// Metadata of a path inside a project, as `stat` reports it.
record file-stat {
kind: file-kind,
/// Bytes, for a file.
size: u64,
/// Seconds since the Unix epoch; `none` when the file system does
/// not track it.
modified: option<u64>,
}
/// A symbol in the host's current in-memory project index (new in 1.3).
record symbol-info {
name: string,
/// Host symbol kind, in camelCase (`class`, `method`, `enumMember`, …).
kind: string,
container: option<string>,
location: location,
}
/// A project open in the IDE.
resource project {
@@ -9,12 +45,40 @@ interface workspace {
root: func() -> result<string, error>;
/// Reads a UTF-8 text file inside the project (absolute, or relative
/// to the root); paths leaving the root are refused. Unsaved editor
/// contents win over the disk. Requires `project.read`.
/// contents win over the disk. A missing path is `not-found`.
/// Requires `project.read`.
read-file: func(path: string) -> result<string, error>;
/// Replaces the text of a file inside the project (absolute, or
/// relative to the root), or creates it with missing parent
/// directories; paths leaving the root are refused. Written in one
/// step (a reader never sees half of it), keeping the file's encoding
/// and BOM; a new file is UTF-8. An editor holding unsaved changes for
/// the file makes it `invalid` (save them first). Requires
/// `project.write`.
write-file: func(path: string, text: string) -> result<_, error>;
/// Whether `path` (absolute, or relative to the root) names an
/// existing file or directory — including hidden and ignored ones,
/// without reading any content. Paths leaving the root are refused.
/// Requires `project.read`.
exists: func(path: string) -> result<bool, error>;
/// Metadata of an existing path inside the project (absolute, or
/// relative to the root); a missing path is `not-found` and paths
/// leaving the root are refused. Requires `project.read`.
stat: func(path: string) -> result<file-stat, error>;
/// Project files whose root-relative path matches `glob` (`**/*Mapper.xml`),
/// at most `limit`, as absolute paths. Ignored and excluded files are
/// skipped. Requires `project.read`.
find-files: func(glob: string, limit: u32) -> result<list<string>, error>;
/// Fuzzy symbol search (CamelHump and container-qualified queries) over the
/// current index only; never starts or waits for a language server. Results
/// may be incomplete while indexing and ranges reflect the indexed text,
/// not necessarily unsaved edits. Empty queries return no symbols. Queries
/// over 256 UTF-8 bytes are invalid; limit is capped at 1000 (zero is
/// empty). Only paths readable under the root are returned; external
/// content roots are excluded. Cancellation returns cancelled; exceeding
/// the current call's deadline fails. A closed project is not-found.
/// Requires `project.read`.
symbols: func(query: string, limit: u32) -> result<list<symbol-info>, error>;
}
/// Projects open in any window. Requires `project.read`.
+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", "NSToolbar", "NSView", "NSWindow", "NSWorkspace"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSEvent", "NSGraphics", "NSResponder", "NSScreen", "NSTitlebarAccessoryViewController", "NSToolbar", "NSView", "NSViewController", "NSWindow", "NSWindowTabGroup", "NSWorkspace"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString"] }
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
-1
View File
@@ -87,7 +87,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
window_group::window_group_close,
window_group::window_group_close_tabs,
window_group::window_group_cancel_close,
window_group::window_group_before_close,
window_group::window_group_reorder,
window_group::window_group_restore,
app::light_edit_open,
+14 -11
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};
use tauri::{AppHandle, State, WebviewWindow, Window};
/// Opens `path` in a new window (`new_window`) or tells the caller to open it
/// itself (`current`). A project already open in some window is focused instead.
@@ -193,17 +193,10 @@ pub fn window_open_projects(
.collect())
}
/// Whether this window fills the screen: in native full screen, or (macOS) shown in its window
/// group's full-screen Space.
/// Whether this window is in native full screen (on macOS, also a tab of a fullscreen group).
#[tauri::command]
#[specta::specta]
pub fn window_is_fullscreen(window: WebviewWindow) -> bool {
#[cfg(target_os = "macos")]
{
use tauri::Manager;
crate::window_groups::in_fullscreen(window.app_handle(), &window)
}
#[cfg(not(target_os = "macos"))]
window.is_fullscreen().unwrap_or(false)
}
@@ -218,8 +211,18 @@ pub struct CloseContext {
#[tauri::command]
#[specta::specta]
pub fn window_close_context(app: AppHandle, windows: State<'_, WindowsState>) -> CloseContext {
CloseContext { quitting: windows.quitting(), windows: windows::project_window_labels(&app).len() as u32 }
pub fn window_close_context(app: AppHandle, window: Window, windows: State<'_, WindowsState>) -> CloseContext {
let all = windows::project_window_labels(&app);
let pending: Vec<String> = windows::sorted_labels(
windows.pending_closes().iter().map(String::as_str).filter(|l| all.iter().any(|a| a == l)),
);
// Members of a tab group asking to close together are one window closing: the first of them
// is the last window left (it returns to the welcome screen); the others just close.
let count = match pending.first() {
Some(keeper) if pending.len() > 1 && keeper == window.label() => all.len() - pending.len() + 1,
_ => all.len(),
};
CloseContext { quitting: windows.quitting(), windows: count as u32 }
}
/// Quits: closes every window, each of which runs its own unsaved-changes check.
-14
View File
@@ -55,20 +55,6 @@ pub fn window_group_cancel_close(window: WebviewWindow) {
groups::cancel_close(window.app_handle(), window.label());
}
/// This window closes next (its prompts passed): a fullscreen group's host first hands its Space
/// to the tab that takes over.
#[tauri::command]
#[specta::specta]
pub async fn window_group_before_close(window: WebviewWindow) -> IdeResult<()> {
#[cfg(target_os = "macos")]
return groups::before_close(window.app_handle(), window.label()).await;
#[cfg(not(target_os = "macos"))]
{
let _ = window;
Ok(())
}
}
#[tauri::command]
#[specta::specta]
pub async fn window_group_reorder(window: WebviewWindow, labels: Vec<String>) -> IdeResult<()> {
+11 -2
View File
@@ -190,6 +190,10 @@ pub fn run() {
#[cfg(target_os = "macos")]
macos::intercept_system_quit(app.handle());
#[cfg(target_os = "macos")]
if let Some(mtm) = objc2::MainThreadMarker::new() {
macos::prepare_window_tabbing(mtm);
}
#[cfg(target_os = "macos")]
application_focus::observe(app.handle());
app.manage(menu_state);
if let Some(paths) = setup_opens.lock().take() {
@@ -201,8 +205,13 @@ pub fn run() {
Ok(())
})
.on_window_event(|window, event| match event {
#[cfg(target_os = "linux")]
tauri::WindowEvent::CloseRequested { .. } => linux_window_state::save_before_close(window.app_handle()),
tauri::WindowEvent::CloseRequested { .. } => {
#[cfg(target_os = "linux")]
linux_window_state::save_before_close(window.app_handle());
if let Some(w) = window.try_state::<WindowsState>() {
w.close_requested(window.label());
}
}
tauri::WindowEvent::Focused(true) => {
application_focus::changed(window.app_handle(), true);
if let Some(w) = window.try_state::<WindowsState>() {
+59 -350
View File
@@ -1,15 +1,13 @@
//! macOS window tab preferences, applied to application-drawn project tabs.
//! macOS project tabs: native window tab groups behind the page's own tab bar.
use crate::windows::{Launch, WindowsState};
use ide_core::{IdeError, IdeResult};
use ide_workspace::window_groups::{TabbingPreference, should_group_new_project};
use objc2::{
MainThreadMarker,
rc::Retained,
runtime::{AnyClass, AnyObject, Imp, ProtocolObject, Sel},
runtime::{AnyClass, AnyObject, Imp, Sel},
};
use objc2_app_kit::{NSWindow, NSWindowAnimationBehavior, NSWindowUserTabbingPreference};
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSRect};
use std::cell::Cell;
use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference};
use tauri::{AppHandle, Manager, WebviewWindow};
fn tabbing_preference(app: &AppHandle) -> IdeResult<TabbingPreference> {
@@ -47,33 +45,13 @@ pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult<Opt
let host = app.state::<crate::state::AppState>();
host.project(w.label()).is_ok_and(|p| !p.light) || host.remote_dev.get(w.label()).is_some()
});
// A group member other than its fullscreen host is not itself in native fullscreen.
let fullscreen = active.as_ref().is_some_and(|w| crate::window_groups::in_fullscreen(app, w));
let fullscreen = active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false));
let light_edit = launch.project.is_none() && launch.remote.is_none();
Ok(should_group_new_project(tabbing_preference(app)?, fullscreen, active.is_some(), light_edit)
.then_some(active)
.flatten())
}
struct FullscreenObserver(Retained<ProtocolObject<dyn NSObjectProtocol>>);
impl Drop for FullscreenObserver {
fn drop(&mut self) {
let observer: &ProtocolObject<dyn NSObjectProtocol> = &self.0;
unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) };
}
}
thread_local! {
/// Fullscreen hosts of window groups, watched for leaving fullscreen.
static FULLSCREEN_HOSTS: std::cell::RefCell<std::collections::HashMap<String, FullscreenObserver>> = std::cell::RefCell::default();
}
/// Stops watching `label`; true when it hosted a fullscreen window group.
pub fn forget_fullscreen_host(label: &str) -> bool {
FULLSCREEN_HOSTS.with_borrow_mut(|hosts| hosts.remove(label)).is_some()
}
/// Runs `f` on the main thread before returning, so it takes effect before what the caller does next.
fn on_main<R: Send + 'static>(window: &WebviewWindow, f: impl FnOnce() -> R + Send + 'static) -> tauri::Result<R> {
if MainThreadMarker::new().is_some() {
@@ -86,182 +64,74 @@ fn on_main<R: Send + 'static>(window: &WebviewWindow, f: impl FnOnce() -> R + Se
rx.recv().map_err(|_| tauri::Error::FailedToReceiveMessage)
}
/// The native `toggleFullScreen:`, bypassing the group handling installed on the window class.
pub fn native_toggle_fullscreen(window: &WebviewWindow) -> tauri::Result<()> {
let target = window.clone();
on_main(window, move || {
let (Ok(pointer), Some(native)) = (target.ns_window(), NATIVE_TOGGLE_FULLSCREEN.get()) else { return };
unsafe { native(pointer.cast(), objc2::sel!(toggleFullScreen:), std::ptr::null_mut()) };
})
/// Project tabs are native window tabs, so a group shares one frame and enters full screen as one
/// window, with the system's window buttons on whichever tab is shown. The tab bar is the page's
/// own; AppKit's is hidden, and windows never join a tab group on their own.
pub fn prepare_window_tabbing(mtm: MainThreadMarker) {
NSWindow::setAllowsAutomaticWindowTabbing(false, mtm);
}
/// A window group in fullscreen shares its host's Space: every other member joins it as an
/// auxiliary window over the host's frame, without a frame, window buttons or dragging, like the host.
/// Without a host the window is an ordinary one again.
pub fn set_fullscreen_auxiliary(window: &WebviewWindow, host: Option<&WebviewWindow>) -> tauri::Result<()> {
use objc2_app_kit::{NSWindowButton, NSWindowCollectionBehavior as B};
let target = window.clone();
let host = host.cloned();
on_main(window, move || {
let Ok(pointer) = target.ns_window() else { return };
let native = unsafe { &*pointer.cast::<NSWindow>() };
let frame =
host.and_then(|host| host.ns_window().ok()).map(|host| unsafe { &*host.cast::<NSWindow>() }.frame());
let mut behavior = native.collectionBehavior();
if frame.is_some() {
behavior.remove(B::FullScreenPrimary);
behavior.insert(B::FullScreenAuxiliary | B::MoveToActiveSpace);
} else {
behavior.remove(B::FullScreenAuxiliary | B::MoveToActiveSpace);
behavior.insert(B::FullScreenPrimary);
}
native.setCollectionBehavior(behavior);
// Without a title bar the window has square corners and no frame, like the host.
let mut style = native.styleMask();
style.set(objc2_app_kit::NSWindowStyleMask::Titled, frame.is_none());
native.setStyleMask(style);
native.setMovable(frame.is_none());
// Shown over the host it must appear at once, as a tab does, not zoom in like a new window.
native.setAnimationBehavior(if frame.is_some() {
objc2_app_kit::NSWindowAnimationBehavior::None
} else {
objc2_app_kit::NSWindowAnimationBehavior::Default
});
for kind in [NSWindowButton::CloseButton, NSWindowButton::MiniaturizeButton, NSWindowButton::ZoomButton] {
if let Some(button) = native.standardWindowButton(kind) {
button.setHidden(frame.is_some());
fn native(window: &WebviewWindow) -> Option<&'static NSWindow> {
let pointer = window.ns_window().ok()?;
Some(unsafe { &*pointer.cast::<NSWindow>() })
}
fn same_group(a: &NSWindow, b: &NSWindow) -> bool {
match (a.tabGroup(), b.tabGroup()) {
(Some(a), Some(b)) => Retained::as_ptr(&a) == Retained::as_ptr(&b),
_ => false,
}
}
/// Puts `members` in one native tab group and shows `selected`.
pub fn sync_tab_group(members: Vec<WebviewWindow>, selected: &WebviewWindow) -> tauri::Result<()> {
let target = selected.clone();
on_main(selected, move || {
let Some(shown) = native(&target) else { return };
for member in members.iter().filter(|m| m.label() != target.label()) {
if let Some(window) = native(member)
&& !same_group(shown, window)
{
shown.addTabbedWindow_ordered(window, objc2_app_kit::NSWindowOrderingMode::Above);
}
}
if let Some(frame) = frame {
native.setFrame_display(frame, true);
if let Some(group) = shown.tabGroup() {
group.setSelectedWindow(Some(shown));
}
target.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
})
}
/// Puts a group member shown in its host's fullscreen Space in front of the host, which AppKit
/// otherwise keeps on top of the other windows in its Space.
pub fn raise_over_host(window: &WebviewWindow, host: &WebviewWindow) -> tauri::Result<()> {
let target = window.clone();
let host = host.clone();
on_main(window, move || {
let (Ok(pointer), Ok(host)) = (target.ns_window(), host.ns_window()) else { return };
let native = unsafe { &*pointer.cast::<NSWindow>() };
let host = unsafe { &*host.cast::<NSWindow>() };
native.orderWindow_relativeTo(objc2_app_kit::NSWindowOrderingMode::Above, host.windowNumber());
})
}
/// Watches the group's fullscreen `host` and runs `left` once it is back on the desktop.
pub fn watch_fullscreen_host(host: &WebviewWindow, left: impl Fn() + Send + 'static) -> tauri::Result<()> {
let label = host.label().to_owned();
let target = host.clone();
on_main(host, move || unsafe {
let Ok(pointer) = target.ns_window() else { return };
let native = &*pointer.cast::<NSWindow>();
let key = label.clone();
let block = block2::RcBlock::new(move |_| {
forget_fullscreen_host(&key);
left();
});
let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
Some(objc2_app_kit::NSWindowDidExitFullScreenNotification),
Some(native),
None,
&block,
);
FULLSCREEN_HOSTS.with_borrow_mut(|hosts| hosts.insert(label, FullscreenObserver(observer)));
})
}
thread_local! {
/// Windows being waited on to finish entering fullscreen.
static ENTERING_FULLSCREEN: std::cell::RefCell<std::collections::HashMap<String, FullscreenObserver>> = std::cell::RefCell::default();
}
/// Toggles `window` into native fullscreen and resolves once it is there, or after `timeout`.
pub async fn enter_fullscreen_and_wait(window: &WebviewWindow, timeout: std::time::Duration) -> tauri::Result<()> {
let (tx, rx) = tokio::sync::oneshot::channel();
let label = window.label().to_owned();
let target = window.clone();
on_main(window, move || unsafe {
let Ok(pointer) = target.ns_window() else { return };
let native = &*pointer.cast::<NSWindow>();
let tx = std::cell::Cell::new(Some(tx));
let block = block2::RcBlock::new(move |_| {
if let Some(tx) = tx.take() {
let _ = tx.send(());
shown.makeKeyAndOrderFront(None);
for member in &members {
if let Some(window) = native(member) {
hide_tab_bar(window);
}
});
let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
Some(objc2_app_kit::NSWindowDidEnterFullScreenNotification),
Some(native),
None,
&block,
);
ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.insert(label, FullscreenObserver(observer)));
})?;
let toggled = native_toggle_fullscreen(window);
if toggled.is_ok() {
let _ = tokio::time::timeout(timeout, rx).await;
}
let label = window.label().to_owned();
}
})
}
/// Takes `window` out of its tab group into a window of its own; in full screen, a Space of its own.
pub fn move_tab_to_new_window(window: &WebviewWindow) -> tauri::Result<()> {
let target = window.clone();
on_main(window, move || {
ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.remove(&label));
})?;
toggled
let Some(native) = native(&target) else { return };
if native.tabbedWindows().is_some_and(|tabs| tabs.count() > 1) {
native.moveTabToNewWindow(None);
}
hide_tab_bar(native);
})
}
/// An observer on the workspace's notification center, removed when dropped.
struct WorkspaceObserver(Retained<NSNotificationCenter>, Retained<ProtocolObject<dyn NSObjectProtocol>>);
impl Drop for WorkspaceObserver {
fn drop(&mut self) {
let observer: &ProtocolObject<dyn NSObjectProtocol> = &self.1;
unsafe { self.0.removeObserver(observer.as_ref()) };
}
fn has_tab_bar(view: &objc2_app_kit::NSView) -> bool {
let class = view.class().name().to_string_lossy();
class.contains("TabBar") || view.subviews().iter().any(|v| has_tab_bar(&v))
}
/// Work waiting for the active Space to change, with the observer that runs it.
struct SpaceChange {
_observer: WorkspaceObserver,
then: Box<dyn FnOnce()>,
}
thread_local! {
static SPACE_CHANGE: std::cell::RefCell<Option<SpaceChange>> = const { std::cell::RefCell::new(None) };
}
/// Runs `then` on the main thread once the active Space has changed, or after `timeout` when it
/// changed before this was called. AppKit drops a fullscreen request made while a fullscreen
/// Space is being torn down.
pub fn after_space_change(app: &AppHandle, timeout: std::time::Duration, then: impl FnOnce() + Send + 'static) {
fn run() {
if let Some(pending) = SPACE_CHANGE.with_borrow_mut(Option::take) {
(pending.then)();
/// Hides AppKit's tab bar, which a window shows while its group has more than one tab. It moves to
/// whichever tab is shown and comes back when the group changes, so this runs after each change.
pub fn hide_tab_bar(window: &NSWindow) {
for accessory in window.titlebarAccessoryViewControllers().iter() {
if !accessory.isHidden() && has_tab_bar(&accessory.view()) {
accessory.setHidden(true);
}
}
let _ = app.run_on_main_thread(move || {
run();
let block = block2::RcBlock::new(|_| run());
let center = objc2_app_kit::NSWorkspace::sharedWorkspace().notificationCenter();
let observer = unsafe {
center.addObserverForName_object_queue_usingBlock(
Some(objc2_app_kit::NSWorkspaceActiveSpaceDidChangeNotification),
None,
None,
&block,
)
};
let observer = WorkspaceObserver(center, observer);
SPACE_CHANGE
.with_borrow_mut(|pending| *pending = Some(SpaceChange { _observer: observer, then: Box::new(then) }));
});
let app = app.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(timeout).await;
let _ = app.run_on_main_thread(run);
});
}
type ShouldTerminate = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize;
@@ -301,165 +171,4 @@ unsafe extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _selector: S
TERMINATE_CANCEL
}
type ToggleFullScreen = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject);
static NATIVE_TOGGLE_FULLSCREEN: std::sync::OnceLock<ToggleFullScreen> = std::sync::OnceLock::new();
static APP: std::sync::OnceLock<AppHandle> = std::sync::OnceLock::new();
/// The green window button, the Globe-F shortcut and the system's Enter Full Screen item all
/// send `toggleFullScreen:`; a grouped window answers it for the whole group.
pub fn intercept_toggle_fullscreen(window: &WebviewWindow) {
let _ = APP.set(window.app_handle().clone());
let _ = window.with_webview(|webview| unsafe {
let class = objc2::ffi::object_getClass(webview.ns_window().cast()) as *mut AnyClass;
let selector = objc2::sel!(toggleFullScreen:);
if NATIVE_TOGGLE_FULLSCREEN.get().is_some() {
return;
}
let Some(method) = (*class).instance_method(selector) else { return };
let native: ToggleFullScreen = std::mem::transmute(method.implementation());
let types = objc2::ffi::method_getTypeEncoding(method);
let _ = NATIVE_TOGGLE_FULLSCREEN.set(native);
let replacement: Imp = std::mem::transmute(toggle_fullscreen as ToggleFullScreen);
objc2::ffi::class_replaceMethod(class, selector, replacement, types);
let selector = objc2::sel!(constrainFrameRect:toScreen:);
let Some(method) = (*class).instance_method(selector) else { return };
let native: ConstrainFrameRect = std::mem::transmute(method.implementation());
let types = objc2::ffi::method_getTypeEncoding(method);
let _ = NATIVE_CONSTRAIN_FRAME_RECT.set(native);
let replacement: Imp = std::mem::transmute(constrain_frame_rect as ConstrainFrameRect);
objc2::ffi::class_replaceMethod(class, selector, replacement, types);
});
}
type ConstrainFrameRect = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, NSRect, *mut AnyObject) -> NSRect;
static NATIVE_CONSTRAIN_FRAME_RECT: std::sync::OnceLock<ConstrainFrameRect> = std::sync::OnceLock::new();
thread_local! {
/// Native window whose next frame assignment must keep the rectangle it was given.
static EXACT_FRAME: Cell<usize> = const { Cell::new(0) };
}
struct ExactFrame(usize);
impl ExactFrame {
fn begin(native: usize) -> Self {
EXACT_FRAME.with(|slot| slot.set(native));
Self(native)
}
}
impl Drop for ExactFrame {
fn drop(&mut self) {
EXACT_FRAME.with(|slot| {
if slot.get() == self.0 {
slot.set(0);
}
});
}
}
/// Copies `from`'s frame onto `to`. `setFrame:display:animate:` finishes before it returns;
/// tao's position and size setters only schedule the change.
pub fn copy_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> {
apply_window_frame(from, to, false)
}
/// Copies the frame and orders `to` front before AppKit can animate or constrain it.
/// A later `show` runs `constrainFrameRect:toScreen:` and slides a titled window into place.
pub fn present_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> {
apply_window_frame(from, to, true)
}
fn apply_window_frame(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> {
if MainThreadMarker::new().is_some() {
return apply_window_frame_on_main(from, to, show);
}
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let from = from.clone();
let to = to.clone();
to.clone().run_on_main_thread(move || {
let _ = tx.send(apply_window_frame_on_main(&from, &to, show));
})?;
rx.recv().map_err(|error| tauri::Error::Io(std::io::Error::other(error.to_string())))?
}
fn apply_window_frame_on_main(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> {
let source_pointer = from.ns_window()?;
let target_pointer = to.ns_window()?;
unsafe {
let source = &*source_pointer.cast::<NSWindow>();
let target = &*target_pointer.cast::<NSWindow>();
let frame = source.frame();
let current = target.frame();
let differs = frame.origin.x != current.origin.x
|| frame.origin.y != current.origin.y
|| frame.size.width != current.size.width
|| frame.size.height != current.size.height;
if !differs && !show {
return Ok(());
}
// Zoom and ordering use the window's animation behavior. `animate: false` alone does not
// stop a zoomed window from sliding to the new frame as it is shown.
let behavior = target.animationBehavior();
target.setAnimationBehavior(NSWindowAnimationBehavior::None);
let result = (|| {
let _exact = ExactFrame::begin(target_pointer as usize);
if differs {
target.setFrame_display_animate(frame, false, false);
}
if show {
to.show()?;
to.set_focus()?;
}
Ok(())
})();
target.setAnimationBehavior(behavior);
result
}
}
/// AppKit keeps a titled window's top edge below the menu bar, and on a notched display below the
/// camera housing, whenever the window is placed, shown or resized. A group-tab frame copy keeps
/// the rectangle it was given, so the window about to be shown is not shifted.
unsafe extern "C-unwind" fn constrain_frame_rect(
this: *mut AnyObject,
selector: Sel,
frame: NSRect,
screen: *mut AnyObject,
) -> NSRect {
if EXACT_FRAME.with(|slot| slot.get() == this as usize) {
return frame;
}
match NATIVE_CONSTRAIN_FRAME_RECT.get() {
Some(native) => unsafe { native(this, selector, frame, screen) },
None => frame,
}
}
unsafe extern "C-unwind" fn toggle_fullscreen(this: *mut AnyObject, selector: Sel, sender: *mut AnyObject) {
if let Some(window) = APP.get().and_then(|app| grouped_window(app, this)) {
if let Err(error) = crate::window_groups::toggle_fullscreen(&window) {
tracing::warn!(message = error.message, "cannot toggle fullscreen");
}
return;
}
if let Some(native) = NATIVE_TOGGLE_FULLSCREEN.get() {
unsafe { native(this, selector, sender) };
}
}
fn project_window(app: &AppHandle, native: *mut AnyObject) -> Option<WebviewWindow> {
app.webview_windows()
.into_values()
.find(|w| w.ns_window().is_ok_and(|pointer| pointer.cast::<AnyObject>() == native))
}
/// `native` as a project window in a window group.
fn grouped_window(app: &AppHandle, native: *mut AnyObject) -> Option<WebviewWindow> {
let window = project_window(app, native)?;
let grouped = app.state::<WindowsState>().groups.lock().group(window.label()).is_some();
grouped.then_some(window)
}
+14 -5
View File
@@ -12,8 +12,9 @@ use objc2::{
runtime::ProtocolObject,
};
use objc2_app_kit::{
NSToolbar, NSWindow, NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification,
NSWindowStyleMask, NSWindowToolbarStyle, NSWindowWillEnterFullScreenNotification,
NSToolbar, NSWindow, NSWindowDidBecomeKeyNotification, NSWindowDidEnterFullScreenNotification,
NSWindowDidExitFullScreenNotification, NSWindowStyleMask, NSWindowToolbarStyle,
NSWindowWillEnterFullScreenNotification,
};
use objc2_foundation::{NSNotificationCenter, NSObjectProtocol};
use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
@@ -56,7 +57,7 @@ impl Drop for Observer {
struct Tracked {
state: Rc<Fullscreen>,
_observers: [Observer; 3],
_observers: [Observer; 4],
}
thread_local! {
@@ -85,9 +86,15 @@ fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked {
change(&state);
if let Some(window) = weak.load() {
apply(&window, &state);
crate::macos::hide_tab_bar(&window);
}
// The web title bar shows the window buttons itself in full screen.
app.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
// The web title bar releases the room kept for the window buttons in full screen.
// Closing a window posts this while the runtime still holds its window table, and an
// emit from here evaluates script in that table; send it from outside the notification.
let app = app.clone();
tauri::async_runtime::spawn(async move {
app.state::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
});
});
let observer = unsafe {
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
@@ -104,6 +111,8 @@ fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked {
observe(NSWindowWillEnterFullScreenNotification, Fullscreen::will_enter),
observe(NSWindowDidEnterFullScreenNotification, Fullscreen::will_enter),
observe(NSWindowDidExitFullScreenNotification, Fullscreen::did_exit),
// A tab shown in its group: AppKit's tab bar moves to it, and it may be in full screen.
observe(NSWindowDidBecomeKeyNotification, |_| {}),
]
};
Tracked { state, _observers: observers }
+54 -504
View File
@@ -8,22 +8,13 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use std::sync::atomic::Ordering;
use tauri::{AppHandle, Manager, WebviewWindow};
/// Wait for the selection's asynchronous `hide` calls to take effect before moving the peers.
const FRAME_SETTLE_AFTER_SELECT: Duration = Duration::from_millis(50);
/// Wait for a drag/resize to stop before copying its frame to the hidden peers.
const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100);
/// Longest wait for a closed host's fullscreen Space to go before the next tab takes the screen.
#[cfg(target_os = "macos")]
const SPACE_TEARDOWN: Duration = Duration::from_millis(1500);
/// Longest wait for the tab taking over from a closing host to enter its own fullscreen Space.
#[cfg(target_os = "macos")]
const SPACE_HANDOVER: Duration = Duration::from_millis(2000);
/// Coalesces the move/resize events a drag emits.
static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0);
#[cfg(not(target_os = "macos"))]
mod frames;
type Group = ide_workspace::window_groups::WindowGroup;
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -62,410 +53,57 @@ fn changed(app: &AppHandle) {
app.state::<AppState>().sink.emit(IdeEvent::WindowGroupsChanged);
}
/// Outer frame in physical pixels. A maximized window is represented by the rectangle it
/// occupies, not by the maximized flag: that flag can match while the rectangles do not.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct OuterFrame {
x: i32,
y: i32,
width: u32,
height: u32,
}
/// Where a group member can be placed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FrameHost {
/// The client can move and resize the window.
Desktop,
/// Wayland does not let clients place top-level windows.
#[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))]
Wayland,
/// Native fullscreen owns a Space. A different frame is transferred, not assigned.
NativeFullscreen,
}
/// What to write before the target window is shown.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FrameWrite {
/// Already the same rectangle, or this host must not be placed.
Leave,
TransferFullscreen,
Assign(OuterFrame),
}
/// Two windows are aligned only when their outer rectangles match. Maximized or fullscreen
/// flags are not a substitute: `show` presents the current rectangle, and a later move is visible.
fn frame_write(host: FrameHost, from: OuterFrame, to: OuterFrame) -> FrameWrite {
match host {
FrameHost::Wayland => FrameWrite::Leave,
FrameHost::NativeFullscreen if from == to => FrameWrite::Leave,
FrameHost::NativeFullscreen => FrameWrite::TransferFullscreen,
FrameHost::Desktop if from == to => FrameWrite::Leave,
FrameHost::Desktop => FrameWrite::Assign(from),
}
}
fn outer_frame(window: &WebviewWindow) -> IdeResult<OuterFrame> {
let err = |error: tauri::Error| IdeError::internal(error.to_string());
let position = window.outer_position().map_err(err)?;
let size = window.outer_size().map_err(err)?;
Ok(OuterFrame { x: position.x, y: position.y, width: size.width, height: size.height })
}
fn frame_host(from: &WebviewWindow) -> IdeResult<FrameHost> {
#[cfg(target_os = "linux")]
if crate::linux_window_state::is_wayland() {
return Ok(FrameHost::Wayland);
}
if from.is_fullscreen().map_err(|error| IdeError::internal(error.to_string()))? {
return Ok(FrameHost::NativeFullscreen);
}
Ok(FrameHost::Desktop)
}
/// Moves a hidden window onto `from`'s monitor. A restored window keeps its normal position on
/// the monitor it last occupied; maximizing or going fullscreen there would make the window
/// visibly jump across displays when its tab is selected. macOS copies the AppKit frame instead.
#[cfg(not(target_os = "macos"))]
fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
// Wayland does not let clients place top-level windows; maximizing stays on that output.
#[cfg(target_os = "linux")]
if crate::linux_window_state::is_wayland() {
return Ok(());
}
let err = |e: tauri::Error| IdeError::internal(e.to_string());
if let Some(monitor) = from.current_monitor().map_err(err)? {
to.set_position(*monitor.position()).map_err(err)?;
}
Ok(())
}
/// Gives `to` the source window's outer frame before `to` is shown.
///
/// Tauri's position, size and maximize calls are queued. On macOS the AppKit calls return
/// before the frame changes, while `show` orders the window front immediately, so the old
/// rectangle appears and then jumps. The frame is written synchronously instead.
fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
let host = frame_host(from)?;
if host == FrameHost::Wayland {
return Ok(());
}
// Tauri's physical rectangles round through the scale factor. On macOS the AppKit frame is
// the one `show` will present, so compare that instead of skipping a real difference.
#[cfg(target_os = "macos")]
if host == FrameHost::Desktop {
return crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string()));
}
match frame_write(host, outer_frame(from)?, outer_frame(to)?) {
FrameWrite::Leave => Ok(()),
FrameWrite::TransferFullscreen => transfer_native_fullscreen(from, to),
FrameWrite::Assign(frame) => assign_outer_frame(from, to, frame),
}
}
fn transfer_native_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
let err = |error: tauri::Error| IdeError::internal(error.to_string());
// A macOS group in fullscreen shares its host's Space, so no member copies the host's frame.
#[cfg(target_os = "macos")]
let _ = (from, to, err);
#[cfg(not(target_os = "macos"))]
{
to.set_fullscreen(false).map_err(err)?;
place_on_monitor(from, to)?;
from.set_fullscreen(false).map_err(err)?;
to.set_fullscreen(true).map_err(err)?;
}
Ok(())
}
fn assign_outer_frame(from: &WebviewWindow, to: &WebviewWindow, _frame: OuterFrame) -> IdeResult<()> {
#[cfg(target_os = "macos")]
{
crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string()))
}
#[cfg(not(target_os = "macos"))]
{
let err = |error: tauri::Error| IdeError::internal(error.to_string());
to.set_fullscreen(false).map_err(err)?;
if from.is_maximized().map_err(err)? {
// Maximize on the source's monitor. The rectangles differed, so the maximized flags
// alone are not enough; a hidden peer would otherwise appear at its old size.
to.unmaximize().map_err(err)?;
place_on_monitor(from, to)?;
to.maximize().map_err(err)?;
} else {
to.unmaximize().map_err(err)?;
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
// `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland.
#[cfg(target_os = "linux")]
let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?;
#[cfg(not(target_os = "linux"))]
let size = from.inner_size().map_err(err)?;
#[cfg(target_os = "linux")]
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
to.set_size(size).map_err(err)?;
}
Ok(())
}
}
/// Shows `to` in place of `from`, at `from`'s frame.
fn show_at_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
let err = |error: tauri::Error| IdeError::internal(error.to_string());
// Order the target front in the same AppKit turn as the frame write. A separate `show` runs
// after that turn and constrains the titled window, which is the jump. Native fullscreen is
// handed over by `copy_frame` instead.
#[cfg(target_os = "macos")]
if !from.is_fullscreen().map_err(err)? {
to.unminimize().map_err(err)?;
return crate::macos::present_window_frame(from, to).map_err(err);
}
copy_frame(from, to)?;
to.unminimize().map_err(err)?;
to.show().map_err(err)?;
to.set_focus().map_err(err)
}
/// Keeps hidden group members at the selected window's frame. Selecting a tab then only
/// changes which window is visible instead of moving or resizing one on screen.
fn sync_member_frames(app: &AppHandle, selected: &str) {
let Some(group) = app.state::<WindowsState>().groups.lock().group(selected).cloned() else { return };
if group.selected != selected {
return;
}
let Some(from) = app.get_webview_window(selected) else { return };
if from.is_fullscreen().unwrap_or(false)
|| from.is_minimized().unwrap_or(false)
|| fullscreen_host(app, &group.members).is_some()
{
return;
}
for member in group.members.iter().filter(|member| member.as_str() != selected) {
let Some(to) = app.get_webview_window(member) else { continue };
// A visible peer is the one being presented; never move it behind the user's back.
if to.is_visible().unwrap_or(false) {
continue;
}
if let Err(error) = copy_frame(&from, &to) {
tracing::warn!("cannot align window {member} with {selected}: {}", error.message);
}
}
}
/// A presented group window moved or resized. Align its hidden peers after the user stops, so
/// the next tab selection is only a visibility change.
/// A presented group window moved or resized. Native tabs (macOS) share one frame already.
pub fn frame_changed(app: &AppHandle, label: &str) {
let Some(group) = app.state::<WindowsState>().groups.lock().group(label).cloned() else { return };
if group.selected != label {
return;
}
let generation = FRAME_SYNC_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
let app = app.clone();
let label = label.to_owned();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(FRAME_SETTLE_AFTER_MOVE).await;
if FRAME_SYNC_GENERATION.load(Ordering::Relaxed) != generation {
return;
}
if !app.state::<WindowsState>().quitting() {
sync_member_frames(&app, &label);
}
});
}
/// The member of `members` in native fullscreen. On macOS the other members of its group join
/// its Space; elsewhere each member of a fullscreen group is fullscreen itself.
fn fullscreen_host(app: &AppHandle, members: &[String]) -> Option<WebviewWindow> {
#[cfg(target_os = "macos")]
return members.iter().filter_map(|m| app.get_webview_window(m)).find(|w| w.is_fullscreen().unwrap_or(false));
#[cfg(not(target_os = "macos"))]
{
let _ = (app, members);
None
}
frames::frame_changed(app, label);
#[cfg(target_os = "macos")]
let _ = (app, label);
}
/// The fullscreen host of `label`'s group, when that is another window.
#[cfg(target_os = "macos")]
pub fn fullscreen_host_of(app: &AppHandle, label: &str) -> Option<WebviewWindow> {
let members = app.state::<WindowsState>().groups.lock().group(label).map(|g| g.members.clone())?;
fullscreen_host(app, &members).filter(|host| host.label() != label)
}
/// Whether `window` is shown fullscreen: itself, or as a member of a fullscreen group.
#[cfg(target_os = "macos")]
pub fn in_fullscreen(app: &AppHandle, window: &WebviewWindow) -> bool {
if window.is_fullscreen().unwrap_or(false) {
return true;
}
let members = app.state::<WindowsState>().groups.lock().group(window.label()).map(|g| g.members.clone());
members.is_some_and(|members| fullscreen_host(app, &members).is_some())
}
/// Fullscreen for `window`. On macOS a group shares one fullscreen Space: the selected member
/// enters native fullscreen and the others join its Space as auxiliary windows over it, so
/// switching projects only shows one window and hides the others.
/// Fullscreen for `window`. On macOS a group is one native tab group, which AppKit takes into a
/// fullscreen Space as a whole.
pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult<bool> {
let err = |e: tauri::Error| IdeError::internal(e.to_string());
#[cfg(target_os = "macos")]
{
let app = window.app_handle();
let group = app.state::<WindowsState>().groups.lock().group(window.label()).cloned();
if let Some(group) = group {
if let Some(host) = fullscreen_host(app, &group.members) {
// The host's watch brings the members back with it.
crate::macos::native_toggle_fullscreen(&host).map_err(err)?;
return Ok(false);
}
let host = app.get_webview_window(&group.selected).unwrap_or_else(|| window.clone());
host_fullscreen(app, &host)?;
return Ok(true);
}
}
let on = !window.is_fullscreen().map_err(err)?;
window.set_fullscreen(on).map_err(err)?;
Ok(on)
}
/// Makes `host` the fullscreen window of its group, or of its own Space when ungrouped.
#[cfg(target_os = "macos")]
fn host_fullscreen(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> {
prepare_host(app, host)?;
crate::macos::native_toggle_fullscreen(host).map_err(|e| IdeError::internal(e.to_string()))
}
/// `host` and the other members of its group, ready for `host` to enter fullscreen.
#[cfg(target_os = "macos")]
fn prepare_host(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> {
let err = |e: tauri::Error| IdeError::internal(e.to_string());
let members = app.state::<WindowsState>().groups.lock().group(host.label()).map(|g| g.members.clone());
crate::macos::set_fullscreen_auxiliary(host, None).map_err(err)?;
for member in members.iter().flatten().filter(|m| *m != host.label()) {
if let Some(other) = app.get_webview_window(member) {
crate::macos::set_fullscreen_auxiliary(&other, Some(host)).map_err(err)?;
}
}
if members.is_some() {
watch_host(app, host)?;
}
host.show().map_err(err)
}
/// A fullscreen group's host is about to close, past its prompts. The tab that takes over leaves
/// for a Space of its own first, so the screen moves straight to it; were the host closed first,
/// its Space would end and show the desktop until the next one opened.
#[cfg(target_os = "macos")]
pub async fn before_close(app: &AppHandle, caller: &str) -> IdeResult<()> {
let state = app.state::<WindowsState>();
let Some(members) = state.groups.lock().group(caller).map(|g| g.members.clone()) else { return Ok(()) };
if state.quitting() || fullscreen_host(app, &members).is_none_or(|host| host.label() != caller) {
return Ok(());
}
// Detached now, the closing host leaves nothing for `destroyed` to hand over.
let next = state.groups.lock().detach(caller);
changed(app);
let Some(next) = next.and_then(|label| app.get_webview_window(&label)) else { return Ok(()) };
prepare_host(app, &next)?;
crate::macos::enter_fullscreen_and_wait(&next, SPACE_HANDOVER)
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
windows::focus(&next);
Ok(())
}
#[cfg(target_os = "macos")]
fn watch_host(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> {
let app = app.clone();
let label = host.label().to_owned();
crate::macos::watch_fullscreen_host(host, move || left_fullscreen(&app, &label))
.map_err(|e| IdeError::internal(e.to_string()))
}
/// The group's fullscreen host is back on the desktop: the members leave its Space with it at
/// its frame, and the selected one is shown.
#[cfg(target_os = "macos")]
fn left_fullscreen(app: &AppHandle, host: &str) {
let Some(group) = app.state::<WindowsState>().groups.lock().group(host).cloned() else { return };
let Some(host) = app.get_webview_window(host) else { return };
for member in group.members.iter().filter(|m| *m != host.label()) {
if let Some(window) = app.get_webview_window(member) {
let _ = crate::macos::set_fullscreen_auxiliary(&window, None);
let _ = copy_frame(&host, &window);
}
}
let _ = select(app, &group.selected, &group.selected);
}
pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> {
let to = app.get_webview_window(target).ok_or_else(|| IdeError::not_found("Window closed"))?;
let state = app.state::<WindowsState>();
let previous = state.groups.lock().select(caller, target)?;
// In a fullscreen group the target joins the host's Space, covering its screen.
let host = fullscreen_host(app, &previous.members);
let result = (|| {
#[cfg(target_os = "macos")]
if let Some(host) = &host {
// Also a window that was fullscreen alone before it was grouped.
watch_host(app, host)?;
if host.label() != target {
crate::macos::set_fullscreen_auxiliary(&to, Some(host))
.map_err(|e| IdeError::internal(e.to_string()))?;
}
}
if host.is_none()
&& previous.selected != target
&& let Some(from) = app.get_webview_window(&previous.selected)
{
show_at_frame(&from, &to)?;
} else {
to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?;
to.show().map_err(|e| IdeError::internal(e.to_string()))?;
#[cfg(target_os = "macos")]
if let Some(host) = host.as_ref().filter(|h| h.label() != target) {
crate::macos::raise_over_host(&to, host).map_err(|e| IdeError::internal(e.to_string()))?;
}
to.set_focus().map_err(|e| IdeError::internal(e.to_string()))?;
}
for member in &previous.members {
// The host stays in its Space behind the selected member; hiding it would end the Space.
if member != target
&& host.as_ref().is_none_or(|h| h.label() != member)
&& let Some(window) = app.get_webview_window(member)
{
window.hide().map_err(|e| IdeError::internal(e.to_string()))?;
}
}
Ok(())
})();
#[cfg(target_os = "macos")]
let result = native_select(app, &previous, &to);
#[cfg(not(target_os = "macos"))]
let result = frames::present(app, &previous, target, &to);
if result.is_err() {
let _ = state.groups.lock().select(caller, &previous.selected);
for member in &previous.members {
if let Some(window) = app.get_webview_window(member) {
if member == &previous.selected {
let _ = window.show();
let _ = window.set_focus();
} else {
let _ = window.hide();
}
}
#[cfg(target_os = "macos")]
if let Some(window) = app.get_webview_window(&previous.selected) {
let _ = native_select(app, &previous, &window);
}
#[cfg(not(target_os = "macos"))]
frames::restore_selection(app, &previous);
} else if !state.quitting() {
// Align the members that are now hidden while they stay invisible, so the next
// selection does not move or resize a window on screen. Hiding is asynchronous;
// wait for it before moving a peer that is still on screen.
let app = app.clone();
let target = target.to_owned();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(FRAME_SETTLE_AFTER_SELECT).await;
if !app.state::<WindowsState>().quitting() {
sync_member_frames(&app, &target);
}
});
#[cfg(not(target_os = "macos"))]
frames::selected(app, target);
}
changed(app);
// A tab shown in a fullscreen group is in full screen too; its title bar asks again.
app.state::<AppState>().sink.emit(IdeEvent::WindowFullscreenChanged);
result
}
/// Shows `to` as the selected tab of the group's native tab group.
#[cfg(target_os = "macos")]
fn native_select(app: &AppHandle, group: &Group, to: &WebviewWindow) -> IdeResult<()> {
let members = group.members.iter().filter_map(|m| app.get_webview_window(m)).collect();
to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?;
crate::macos::sync_tab_group(members, to).map_err(|e| IdeError::internal(e.to_string()))
}
#[cfg(target_os = "macos")]
pub fn join(app: &AppHandle, anchor: &str, new_window: &str) -> IdeResult<()> {
let state = app.state::<WindowsState>();
@@ -492,88 +130,43 @@ pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> {
}
pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> {
let err = |e: tauri::Error| IdeError::internal(e.to_string());
let state = app.state::<WindowsState>();
#[cfg(target_os = "macos")]
let host = {
let members = state.groups.lock().group(caller).map(|g| g.members.clone()).unwrap_or_default();
fullscreen_host(app, &members)
};
let next = state.groups.lock().detach(caller);
#[cfg(target_os = "macos")]
if let Some(host) = host {
detach_fullscreen(app, caller, &host, next.as_deref())?;
let Some(next) = state.groups.lock().detach(caller) else {
changed(app);
return Ok(());
};
let window = app.get_webview_window(caller);
// A tab moved out of a fullscreen group stays fullscreen in a Space of its own.
#[cfg(target_os = "macos")]
if let Some(window) = &window {
crate::macos::move_tab_to_new_window(window).map_err(err)?;
}
if let Some(next) = next {
if let Some(window) = app.get_webview_window(&next) {
window.show().map_err(|e| IdeError::internal(e.to_string()))?;
if let Some(next) = app.get_webview_window(&next) {
#[cfg(target_os = "macos")]
if state.groups.lock().group(next.label()).is_some() {
select(app, next.label(), next.label())?;
}
if let Some(window) = app.get_webview_window(caller) {
if let Ok(position) = window.outer_position() {
let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32));
}
windows::focus(&window);
#[cfg(not(target_os = "macos"))]
next.show().map_err(err)?;
}
if let Some(window) = window {
if !window.is_fullscreen().map_err(err)?
&& let Ok(position) = window.outer_position()
{
let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32));
}
windows::focus(&window);
}
changed(app);
Ok(())
}
/// A tab moved out of a fullscreen group stays fullscreen in a Space of its own, as a macOS
/// tab moved to a new window does; the tabs left keep theirs.
#[cfg(target_os = "macos")]
fn detach_fullscreen(app: &AppHandle, caller: &str, host: &WebviewWindow, next: Option<&str>) -> IdeResult<()> {
let err = |e: tauri::Error| IdeError::internal(e.to_string());
let next = next.and_then(|label| app.get_webview_window(label));
let grouped = |label: &str| app.state::<WindowsState>().groups.lock().group(label).is_some();
if host.label() == caller {
// The host keeps its Space; the tabs left need a host of their own.
crate::macos::forget_fullscreen_host(caller);
if let Some(next) = &next {
host_fullscreen(app, next)?;
}
return Ok(());
}
if let Some(next) = &next {
if grouped(next.label()) {
select(app, next.label(), next.label())?;
} else {
// The group dissolved; its last window is fullscreen alone.
crate::macos::forget_fullscreen_host(next.label());
crate::macos::set_fullscreen_auxiliary(next, None).map_err(err)?;
}
}
if let Some(window) = app.get_webview_window(caller) {
host_fullscreen(app, &window)?;
}
Ok(())
}
pub fn destroyed(app: &AppHandle, caller: &str) {
let Some(state) = app.try_state::<WindowsState>() else { return };
#[cfg(target_os = "macos")]
let hosted = crate::macos::forget_fullscreen_host(caller);
let next = state.groups.lock().detach(caller);
if !state.quitting() {
if let Some(window) = next.and_then(|label| app.get_webview_window(&label)) {
// The Space ended with its host; the tabs left take a new one once it is gone.
#[cfg(target_os = "macos")]
if hosted {
let host_app = app.clone();
crate::macos::after_space_change(app, SPACE_TEARDOWN, move || {
if let Err(error) = host_fullscreen(&host_app, &window) {
tracing::warn!("cannot keep window group fullscreen: {}", error.message);
}
windows::focus(&window);
});
} else {
windows::focus(&window);
if let Some(host) = fullscreen_host_of(app, window.label()) {
let _ = crate::macos::raise_over_host(&window, &host);
}
}
#[cfg(not(target_os = "macos"))]
windows::focus(&window);
}
let app = app.clone();
@@ -621,6 +214,7 @@ pub fn close_tabs(app: &AppHandle, caller: &str, keep: &str, labels: Vec<String>
/// The closing tab's project vetoed (its unsaved-changes prompt was cancelled): leave the rest open.
pub fn cancel_close(app: &AppHandle, caller: &str) {
let state = app.state::<WindowsState>();
state.close_settled(caller);
let mut batch = state.tab_closes.lock();
if batch.as_ref().is_some_and(|b| b.pending.front().is_some_and(|l| l == caller)) {
*batch = None;
@@ -773,47 +367,3 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{FrameHost, FrameWrite, OuterFrame, frame_write};
fn frame(x: i32, y: i32, width: u32, height: u32) -> OuterFrame {
OuterFrame { x, y, width, height }
}
#[test]
fn matching_outer_rects_are_left_alone() {
let frame = frame(100, 80, 1200, 800);
assert_eq!(frame_write(FrameHost::Desktop, frame, frame), FrameWrite::Leave);
}
#[test]
fn a_different_outer_rect_is_assigned_before_the_window_is_shown() {
let from = frame(100, 80, 1200, 800);
let to = frame(40, 40, 900, 600);
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
}
#[test]
fn maximized_windows_with_different_rects_still_assign() {
let from = frame(0, 25, 1728, 1055);
let to = frame(0, 25, 1512, 900);
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
}
#[test]
fn wayland_does_not_place_a_window() {
let from = frame(0, 0, 1200, 800);
let to = frame(10, 10, 800, 600);
assert_eq!(frame_write(FrameHost::Wayland, from, to), FrameWrite::Leave);
}
#[test]
fn native_fullscreen_transfers_only_when_the_rects_differ() {
let from = frame(0, 0, 1728, 1117);
let other = frame(100, 80, 1200, 800);
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, from), FrameWrite::Leave);
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, other), FrameWrite::TransferFullscreen);
}
}
+285
View File
@@ -0,0 +1,285 @@
//! Window groups without native tabs: one member is shown at a time, and the hidden ones are kept
//! at its frame so that selecting a tab only changes which window is visible.
use super::Group;
use crate::windows::WindowsState;
use ide_core::{IdeError, IdeResult};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tauri::{AppHandle, Manager, WebviewWindow};
/// Wait for the selection's asynchronous `hide` calls to take effect before moving the peers.
const FRAME_SETTLE_AFTER_SELECT: Duration = Duration::from_millis(50);
/// Wait for a drag/resize to stop before copying its frame to the hidden peers.
const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100);
/// Coalesces the move/resize events a drag emits.
static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0);
/// Outer frame in physical pixels. A maximized window is represented by the rectangle it
/// occupies, not by the maximized flag: that flag can match while the rectangles do not.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct OuterFrame {
x: i32,
y: i32,
width: u32,
height: u32,
}
/// Where a group member can be placed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FrameHost {
/// The client can move and resize the window.
Desktop,
/// Wayland does not let clients place top-level windows.
#[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))]
Wayland,
/// Native fullscreen owns a Space. A different frame is transferred, not assigned.
NativeFullscreen,
}
/// What to write before the target window is shown.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FrameWrite {
/// Already the same rectangle, or this host must not be placed.
Leave,
TransferFullscreen,
Assign(OuterFrame),
}
/// Two windows are aligned only when their outer rectangles match. Maximized or fullscreen
/// flags are not a substitute: `show` presents the current rectangle, and a later move is visible.
fn frame_write(host: FrameHost, from: OuterFrame, to: OuterFrame) -> FrameWrite {
match host {
FrameHost::Wayland => FrameWrite::Leave,
FrameHost::NativeFullscreen if from == to => FrameWrite::Leave,
FrameHost::NativeFullscreen => FrameWrite::TransferFullscreen,
FrameHost::Desktop if from == to => FrameWrite::Leave,
FrameHost::Desktop => FrameWrite::Assign(from),
}
}
fn outer_frame(window: &WebviewWindow) -> IdeResult<OuterFrame> {
let err = |error: tauri::Error| IdeError::internal(error.to_string());
let position = window.outer_position().map_err(err)?;
let size = window.outer_size().map_err(err)?;
Ok(OuterFrame { x: position.x, y: position.y, width: size.width, height: size.height })
}
fn frame_host(from: &WebviewWindow) -> IdeResult<FrameHost> {
#[cfg(target_os = "linux")]
if crate::linux_window_state::is_wayland() {
return Ok(FrameHost::Wayland);
}
if from.is_fullscreen().map_err(|error| IdeError::internal(error.to_string()))? {
return Ok(FrameHost::NativeFullscreen);
}
Ok(FrameHost::Desktop)
}
/// Moves a hidden window onto `from`'s monitor. A restored window keeps its normal position on
/// the monitor it last occupied; maximizing or going fullscreen there would make the window
/// visibly jump across displays when its tab is selected.
fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
// Wayland does not let clients place top-level windows; maximizing stays on that output.
#[cfg(target_os = "linux")]
if crate::linux_window_state::is_wayland() {
return Ok(());
}
let err = |e: tauri::Error| IdeError::internal(e.to_string());
if let Some(monitor) = from.current_monitor().map_err(err)? {
to.set_position(*monitor.position()).map_err(err)?;
}
Ok(())
}
/// Gives `to` the source window's outer frame before `to` is shown.
fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
let host = frame_host(from)?;
if host == FrameHost::Wayland {
return Ok(());
}
match frame_write(host, outer_frame(from)?, outer_frame(to)?) {
FrameWrite::Leave => Ok(()),
FrameWrite::TransferFullscreen => transfer_native_fullscreen(from, to),
FrameWrite::Assign(_) => assign_outer_frame(from, to),
}
}
fn transfer_native_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
let err = |error: tauri::Error| IdeError::internal(error.to_string());
to.set_fullscreen(false).map_err(err)?;
place_on_monitor(from, to)?;
from.set_fullscreen(false).map_err(err)?;
to.set_fullscreen(true).map_err(err)?;
Ok(())
}
fn assign_outer_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
let err = |error: tauri::Error| IdeError::internal(error.to_string());
to.set_fullscreen(false).map_err(err)?;
if from.is_maximized().map_err(err)? {
// Maximize on the source's monitor. The rectangles differed, so the maximized flags
// alone are not enough; a hidden peer would otherwise appear at its old size.
to.unmaximize().map_err(err)?;
place_on_monitor(from, to)?;
to.maximize().map_err(err)?;
} else {
to.unmaximize().map_err(err)?;
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
// `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland.
#[cfg(target_os = "linux")]
let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?;
#[cfg(not(target_os = "linux"))]
let size = from.inner_size().map_err(err)?;
#[cfg(target_os = "linux")]
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
to.set_size(size).map_err(err)?;
}
Ok(())
}
/// Shows `to` in place of `from`, at `from`'s frame.
fn show_at_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
let err = |error: tauri::Error| IdeError::internal(error.to_string());
copy_frame(from, to)?;
to.unminimize().map_err(err)?;
to.show().map_err(err)?;
to.set_focus().map_err(err)
}
/// Keeps hidden group members at the selected window's frame. Selecting a tab then only
/// changes which window is visible instead of moving or resizing one on screen.
fn sync_member_frames(app: &AppHandle, selected: &str) {
let Some(group) = app.state::<WindowsState>().groups.lock().group(selected).cloned() else { return };
if group.selected != selected {
return;
}
let Some(from) = app.get_webview_window(selected) else { return };
if from.is_fullscreen().unwrap_or(false) || from.is_minimized().unwrap_or(false) {
return;
}
for member in group.members.iter().filter(|member| member.as_str() != selected) {
let Some(to) = app.get_webview_window(member) else { continue };
// A visible peer is the one being presented; never move it behind the user's back.
if to.is_visible().unwrap_or(false) {
continue;
}
if let Err(error) = copy_frame(&from, &to) {
tracing::warn!("cannot align window {member} with {selected}: {}", error.message);
}
}
}
/// A presented group window moved or resized. Align its hidden peers after the user stops, so
/// the next tab selection is only a visibility change.
pub(super) fn frame_changed(app: &AppHandle, label: &str) {
let Some(group) = app.state::<WindowsState>().groups.lock().group(label).cloned() else { return };
if group.selected != label {
return;
}
let generation = FRAME_SYNC_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
let app = app.clone();
let label = label.to_owned();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(FRAME_SETTLE_AFTER_MOVE).await;
if FRAME_SYNC_GENERATION.load(Ordering::Relaxed) != generation {
return;
}
if !app.state::<WindowsState>().quitting() {
sync_member_frames(&app, &label);
}
});
}
/// Shows `to` in place of the group's previously selected member and hides the others.
pub(super) fn present(app: &AppHandle, previous: &Group, target: &str, to: &WebviewWindow) -> IdeResult<()> {
let err = |e: tauri::Error| IdeError::internal(e.to_string());
if previous.selected != target
&& let Some(from) = app.get_webview_window(&previous.selected)
{
show_at_frame(&from, to)?;
} else {
to.unminimize().map_err(err)?;
to.show().map_err(err)?;
to.set_focus().map_err(err)?;
}
for member in &previous.members {
if member != target
&& let Some(window) = app.get_webview_window(member)
{
window.hide().map_err(err)?;
}
}
Ok(())
}
/// Shows the previously selected member again after a failed selection.
pub(super) fn restore_selection(app: &AppHandle, previous: &Group) {
for member in &previous.members {
if let Some(window) = app.get_webview_window(member) {
if member == &previous.selected {
let _ = window.show();
let _ = window.set_focus();
} else {
let _ = window.hide();
}
}
}
}
/// Aligns the members that are now hidden while they stay invisible, so the next selection does
/// not move or resize a window on screen. Hiding is asynchronous; wait for it before moving a
/// peer that is still on screen.
pub(super) fn selected(app: &AppHandle, target: &str) {
let app = app.clone();
let target = target.to_owned();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(FRAME_SETTLE_AFTER_SELECT).await;
if !app.state::<WindowsState>().quitting() {
sync_member_frames(&app, &target);
}
});
}
#[cfg(test)]
mod tests {
use super::{FrameHost, FrameWrite, OuterFrame, frame_write};
fn frame(x: i32, y: i32, width: u32, height: u32) -> OuterFrame {
OuterFrame { x, y, width, height }
}
#[test]
fn matching_outer_rects_are_left_alone() {
let frame = frame(100, 80, 1200, 800);
assert_eq!(frame_write(FrameHost::Desktop, frame, frame), FrameWrite::Leave);
}
#[test]
fn a_different_outer_rect_is_assigned_before_the_window_is_shown() {
let from = frame(100, 80, 1200, 800);
let to = frame(40, 40, 900, 600);
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
}
#[test]
fn maximized_windows_with_different_rects_still_assign() {
let from = frame(0, 25, 1728, 1055);
let to = frame(0, 25, 1512, 900);
assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from));
}
#[test]
fn wayland_does_not_place_a_window() {
let from = frame(0, 0, 1200, 800);
let to = frame(10, 10, 800, 600);
assert_eq!(frame_write(FrameHost::Wayland, from, to), FrameWrite::Leave);
}
#[test]
fn native_fullscreen_transfers_only_when_the_rects_differ() {
let from = frame(0, 0, 1728, 1117);
let other = frame(100, 80, 1200, 800);
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, from), FrameWrite::Leave);
assert_eq!(frame_write(FrameHost::NativeFullscreen, from, other), FrameWrite::TransferFullscreen);
}
}
+25 -2
View File
@@ -140,6 +140,9 @@ pub struct WindowsState {
last_focused: Mutex<Option<String>>,
/// Set by Quit while windows close one by one.
quitting: AtomicBool,
/// Windows asked to close whose guards have not finished. AppKit closes a whole tab group at
/// once, so its members ask together.
close_requests: Mutex<Vec<String>>,
/// Project tabs closing one by one (Close Other Tabs and friends).
pub tab_closes: Mutex<Option<crate::window_groups::TabCloses>>,
/// Relaunch once the quit completes (after installing an update).
@@ -149,6 +152,21 @@ pub struct WindowsState {
}
impl WindowsState {
pub fn close_requested(&self, label: &str) {
let mut requests = self.close_requests.lock();
if !requests.iter().any(|l| l == label) {
requests.push(label.to_owned());
}
}
pub fn close_settled(&self, label: &str) {
self.close_requests.lock().retain(|l| l != label);
}
pub fn pending_closes(&self) -> Vec<String> {
self.close_requests.lock().clone()
}
pub fn set_launch(&self, label: &str, launch: Launch) {
self.launches.lock().insert(label.to_owned(), launch);
}
@@ -187,6 +205,7 @@ impl WindowsState {
}
pub fn forget(&self, label: &str) {
self.close_settled(label);
self.launches.lock().remove(label);
let mut last = self.last_focused.lock();
if last.as_deref() == Some(label) {
@@ -286,6 +305,9 @@ pub fn settings_label(parent: &str) -> String {
format!("{}-{parent}", AuxKind::Settings.name())
}
/// Held from choosing a project window's label until the window exists.
static CREATING: Mutex<()> = Mutex::new(());
/// Windows being built to join a window group, which takes their frame from the group.
#[cfg(target_os = "macos")]
static JOINING_GROUP: Mutex<Vec<String>> = Mutex::new(Vec::new());
@@ -665,6 +687,8 @@ fn create_window_for(
) -> IdeResult<WebviewWindow> {
#[cfg(target_os = "macos")]
let tab_target = if _restoring { None } else { crate::macos::new_project_tab_target(app, &launch)? };
// The label is free only until the window exists, so concurrent opens take turns.
let creating = CREATING.lock();
let windows = app.webview_windows();
let label = next_label(windows.keys().map(String::as_str));
let mut config = window_config(app)?;
@@ -691,6 +715,7 @@ fn create_window_for(
config.visible = false;
}
let window = build_project_window(app, config);
drop(creating);
// Plugins hear of the window from the main thread's queue; forget it after them.
#[cfg(target_os = "macos")]
if tab_target.is_some() {
@@ -699,8 +724,6 @@ fn create_window_for(
}
let window = window?;
#[cfg(target_os = "macos")]
crate::macos::intercept_toggle_fullscreen(&window);
#[cfg(target_os = "macos")]
if let Some(target) = tab_target {
crate::window_groups::join(app, target.label(), window.label())?;
return Ok(window);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenIDEA",
"version": "0.1.38",
"version": "0.1.39",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
+1 -9
View File
@@ -42,11 +42,6 @@ export const commands = {
windowGroupCloseTabs: (keep: string, labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })),
/** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */
windowGroupCancelClose: () => __TAURI_INVOKE<void>("window_group_cancel_close"),
/**
* This window closes next (its prompts passed): a fullscreen group's host first hands its Space
* to the tab that takes over.
*/
windowGroupBeforeClose: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_before_close")),
windowGroupReorder: (labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_reorder", { labels })),
windowGroupRestore: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_restore")),
/**
@@ -925,10 +920,7 @@ export const commands = {
windowFlashTaskbar: (flash: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("window_flash_taskbar", { flash })),
/** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */
windowSetTitlebarHeight: (height: number | null) => typedError<null, IdeError>(__TAURI_INVOKE("window_set_titlebar_height", { height })),
/**
* Whether this window fills the screen: in native full screen, or (macOS) shown in its window
* group's full-screen Space.
*/
/** Whether this window is in native full screen (on macOS, also a tab of a fullscreen group). */
windowIsFullscreen: () => __TAURI_INVOKE<boolean>("window_is_fullscreen"),
/**
* Starts dragging `window` while the primary mouse button is still down; returns whether a drag began.
@@ -1,5 +1,6 @@
.split {
display: flex;
min-width: 640px;
flex: none;
min-height: 320px;
border: 1px solid var(--border-strong);
+23 -22
View File
@@ -132,21 +132,19 @@
/* Scrolled up: one click back to the latest message, floating over the stream's end. */
.jumpToLatest {
position: absolute;
left: 50%;
bottom: var(--space-3);
transform: translateX(-50%);
right: var(--space-3);
bottom: var(--space-2);
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
width: var(--h-control-small);
height: var(--h-control-small);
padding: 0;
border: 1px solid var(--border-popup);
border-radius: 50%;
background: var(--bg-popup);
color: var(--fg);
box-shadow: var(--shadow-popup);
border: 1px solid var(--border);
border-radius: var(--radius-toolbar-button);
background: var(--bg-app);
color: var(--fg-muted);
cursor: pointer;
}
.jumpToLatest:hover {
@@ -230,18 +228,14 @@
}
.conversationRow > .process,
.conversationRow > .permissionLine {
max-width: min(var(--session-card-width), calc(var(--session-content-width) * 0.8), 760px);
max-width: 100%;
}
/* Replies read as plain text on the panel, like other tool-window content. */
.agentCard {
box-sizing: border-box;
width: fit-content;
max-width: min(var(--session-card-width), calc(var(--session-content-width) * 0.8), 760px);
max-width: 100%;
min-width: 0;
overflow-wrap: anywhere;
padding: var(--space-2) calc(var(--space-2) + var(--space-2) / 4);
border: 1px solid var(--border);
border-radius: var(--radius-small);
background: var(--bg-elev);
}
@container (max-width: 640px) {
.stream {
@@ -258,6 +252,14 @@
-webkit-user-select: none;
user-select: none;
}
.messageFooter > .messageTime {
opacity: 0;
}
.conversationRow:nth-last-child(1 of [data-message]) .messageFooter > .messageTime,
.message:hover .messageFooter > .messageTime,
.agentMessage:hover .messageFooter > .messageTime,
.message:focus-within .messageFooter > .messageTime,
.agentMessage:focus-within .messageFooter > .messageTime,
.conversationRow:nth-last-child(1 of [data-message]) .messageActions,
.message:hover .messageActions,
.reply:hover .messageActions,
@@ -327,11 +329,10 @@
.user {
width: 100%;
box-sizing: border-box;
padding: var(--space-2) calc(var(--space-2) + var(--space-2) / 4);
border: 1px solid var(--border);
border-radius: var(--radius-small);
background: var(--bg-tree-selected-inactive);
color: var(--fg-strong);
padding: 6px 10px;
border-radius: var(--radius-tab);
background: var(--bg-hover);
color: var(--fg);
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
+1 -1
View File
@@ -189,6 +189,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
// screen (to open another one); closing the welcome screen, or quitting, closes the window.
if (!ctx.quitting && ctx.windows <= 1 && (s.project || s.lightEdit)) {
if (await confirmLeave(deps)) await closeProject();
await commands.windowGroupCancelClose();
return false;
}
const confirmTerminate = workflowSettings().run.confirmTerminate;
@@ -205,7 +206,6 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
deps,
);
if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose());
else if (!ctx.quitting) await commands.windowGroupBeforeClose();
return ok;
} catch (e) {
maybeEditorService()?.cancelClose?.();
+6 -2
View File
@@ -350,11 +350,15 @@
/* Dialogs. */
.form {
display: grid;
grid-template-columns: max-content 1fr;
grid-template-columns: fit-content(40%) minmax(0, 1fr);
align-items: center;
gap: 8px 12px;
min-width: 440px;
min-width: 0;
padding: 14px;
overflow-wrap: anywhere;
}
.form > * {
min-width: 0;
}
.formLabel {
color: var(--fg-secondary);
+1 -11
View File
@@ -3,14 +3,11 @@
* (opened by `openPullRequestFileDiff` / the details panel's commit diffs, context
* `PULL_REQUEST_DIFF`) get their review threads as inlays, the gutter "+" with single- and
* multi-line comments, Start Review / Add Single Comment, the review toolbar and the viewed
* toggle, through the vcs diff viewer provider point. Also registers the editor actions and
* Settings › Version Control › GitHub. The editor actions also serve review mode's editors.
* toggle, through the vcs diff viewer provider point. The editor actions also serve review mode's editors.
*/
import { registerDiffViewerProvider } from "@/features/vcs/diffViewerProviders";
import { registerActions } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { lazyComponent } from "@/ui";
import { registerSettingsPage } from "@/workbench/contributions";
import { useGhRepository } from "../data/repository";
import { activeReviewEditor } from "../reviewMode/editor";
import { clearNewComments } from "./newComments";
@@ -82,13 +79,6 @@ export function activateReviewDiff(): () => void {
run: () => reviewTarget()?.refresh(),
},
]),
registerSettingsPage({
id: "vcs.github",
title: "GitHub",
order: 9,
keywords: ["pull request", "viewed", "review"],
component: lazyComponent(() => import("./GithubSettingsPage").then((m) => m.GithubSettingsPage)),
}),
useGhRepository.subscribe((s, prev) => {
const a = s.connection?.coordinates;
const b = prev.connection?.coordinates;
+4
View File
@@ -46,6 +46,10 @@ function load(): ReviewDiffSettings {
export const useReviewDiffSettings = create<ReviewDiffSettings>(load);
globalThis.addEventListener?.("storage", (event: StorageEvent) => {
if (event.key === KEY || event.key === null) useReviewDiffSettings.setState(load(), true);
});
export function setReviewDiffSettings(patch: Partial<ReviewDiffSettings>) {
useReviewDiffSettings.setState(patch);
try {
+4
View File
@@ -22,6 +22,9 @@ import {
import { useGithubShell } from "./shell";
import { githubHandler, handlerContextKey } from "./toolwindow/handlers";
import { GithubDialogHost } from "./ui/dialogs";
import { activateSettingsPages } from "./settingsPages";
export { activateSettingsPages } from "./settingsPages";
const WINDOW = "pullRequests";
const TIMELINE_KIND = "github.timeline";
@@ -255,6 +258,7 @@ export function activate(): () => void {
active = true;
setContext("githubPrTab", "list");
const disposers = [
activateSettingsPages(),
registerCloneSource({
id: "github",
label: "GitHub",
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { useSettingsPages } from "@/workbench/contributions";
import { activateSettingsPages } from "./settingsPages";
import { GithubSettingsPage } from "./diff/GithubSettingsPage";
import { setReviewDiffSettings, useReviewDiffSettings } from "./diff/settings";
const KEY = "openidea.github.reviewDiff.v1";
beforeEach(() => {
localStorage.removeItem(KEY);
window.dispatchEvent(new StorageEvent("storage", { key: KEY }));
});
describe("GitHub settings pages", () => {
it("registers independently of the pull request runtime", () => {
const dispose = activateSettingsPages();
expect(useSettingsPages.getState().pages.get("vcs.github")).toMatchObject({ title: "GitHub" });
expect(useSettingsPages.getState().pages.get("vcs.github")?.component).toBeDefined();
dispose();
expect(useSettingsPages.getState().pages.has("vcs.github")).toBe(false);
});
it("updates an open page when another window saves or clears review settings", () => {
render(<GithubSettingsPage />);
const checkbox = screen.getByRole("checkbox", { name: "Automatically mark opened files as viewed" });
expect(checkbox).not.toBeChecked();
act(() => {
localStorage.setItem(KEY, JSON.stringify({ autoMarkViewed: true, threadsView: "all" }));
window.dispatchEvent(new StorageEvent("storage", { key: KEY }));
});
expect(checkbox).toBeChecked();
expect(useReviewDiffSettings.getState().threadsView).toBe("all");
act(() => {
localStorage.clear();
window.dispatchEvent(new StorageEvent("storage", { key: null }));
});
expect(checkbox).not.toBeChecked();
fireEvent.click(checkbox);
expect(JSON.parse(localStorage.getItem(KEY)!)).toMatchObject({ autoMarkViewed: true });
});
it("ignores unrelated storage changes and recovers from invalid saved settings", () => {
setReviewDiffSettings({ autoMarkViewed: true });
localStorage.removeItem(KEY);
window.dispatchEvent(new StorageEvent("storage", { key: "other" }));
expect(useReviewDiffSettings.getState().autoMarkViewed).toBe(true);
localStorage.setItem(KEY, "invalid JSON");
window.dispatchEvent(new StorageEvent("storage", { key: KEY }));
expect(useReviewDiffSettings.getState().autoMarkViewed).toBe(false);
});
});
+12
View File
@@ -0,0 +1,12 @@
import { lazyComponent } from "@/ui";
import { registerSettingsPage } from "@/workbench/contributions";
export function activateSettingsPages(): () => void {
return registerSettingsPage({
id: "vcs.github",
title: "GitHub",
order: 9,
keywords: ["pull request", "viewed", "review"],
component: lazyComponent(() => import("./diff/GithubSettingsPage").then((m) => m.GithubSettingsPage)),
});
}
+2 -1
View File
@@ -43,7 +43,7 @@ import { activate as notebook } from "./notebook";
import { activate as web } from "./web";
import { activate as analysis } from "./analysis";
import { activate as services } from "./services";
import { activate as github } from "./github";
import { activate as github, activateSettingsPages as githubPages } from "./github";
import { activate as httpClient } from "./http-client";
import { activate as database } from "./database";
import { activate as graphql } from "./graphql";
@@ -166,6 +166,7 @@ export function activateSettingsWindowFeatures(parent: string): () => void {
["agents", agentsPages],
["vim", vimPages],
["vcs", vcsPages],
["github", githubPages],
];
return activateAll("settings pages", pages);
}
@@ -43,6 +43,7 @@
}
.body {
display: flex;
min-width: 640px;
flex: 1;
min-height: 0;
border-radius: var(--radius-small);
+21 -2
View File
@@ -193,13 +193,32 @@ describe("context configuration", () => {
it("runs a JS test file through the closest test script", () => {
const c = contextConfig("web/src/cart.test.ts", configs)!;
expect(c).toMatchObject({
args: ["run", "test", "--", "src/cart.test.ts"],
args: ["run", "test", "src/cart.test.ts"],
cwd: "web",
isTest: true,
name: "cart.test.ts",
});
expect(c.id).toBe("context:npm:web:test:web/src/cart.test.ts");
expect(contextConfig("src/a.spec.js", configs)!.args).toEqual(["run", "test", "--", "src/a.spec.js"]);
expect(contextConfig("src/a.spec.js", configs)!.args).toEqual(["run", "test", "src/a.spec.js"]);
});
it.each([
["npm", ["--"]],
["pnpm", []],
["/opt/bin/pnpm", []],
["C:\\tools\\pnpm.CMD", []],
["yarn", ["--"]],
["bun", ["--"]],
])("forwards test files through %s", (command, separator) => {
const base = cfg({ id: "js:test", kind: "npm", command, args: ["run", "test"], isTest: true });
expect(contextConfig("tests/cart.test.ts", [base])!.args).toEqual([
"run",
"test",
...separator,
"tests/cart.test.ts",
]);
const explicit = { ...base, args: ["run", "test", "--", "--reporter=verbose"] };
expect(contextConfig("tests/cart.test.ts", [explicit])!.args).toEqual([...explicit.args, "tests/cart.test.ts"]);
});
it("narrows rspec and rake test runs to the file", () => {
+2 -2
View File
@@ -11,6 +11,7 @@ import { goBuildType } from "./goTypes";
import { isWindows } from "./jvmTypes";
import { typeFor } from "./types";
import { runTemplate } from "./runTemplates";
import { appendScriptArgs } from "./scriptArgs";
export type FileRole = "test" | "main" | "script" | null;
@@ -216,8 +217,7 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC
switch (base.kind) {
case "npm":
case "bun": {
// `npm run test -- file` works for npm, pnpm, yarn and bun.
const args = base.args.includes("--") ? [...base.args, file] : [...base.args, "--", file];
const args = appendScriptArgs(base.command, base.args, [file]);
return derived(base, relPath, { args });
}
case "deno":
+7
View File
@@ -0,0 +1,7 @@
import { commandName } from "./commandName";
/** pnpm forwards `--` to the script, where it ends the test runner's option parsing. */
export function appendScriptArgs(command: string, args: string[], extra: string[]): string[] {
const separator = !args.includes("--") && ["npm", "yarn", "bun"].includes(commandName(command)) ? ["--"] : [];
return [...args, ...separator, ...extra];
}
-1
View File
@@ -170,7 +170,6 @@ describe("targetConfig", () => {
expect(first("describe('m', () => {\n it('adds', () => {});\n});", "src/a.test.ts").args).toEqual([
"run",
"test",
"--",
"src/a.test.ts",
"-t",
"^m adds$",
+19 -3
View File
@@ -86,15 +86,23 @@ describe("rerunFailedConfig", () => {
expect(c.args).toEqual(["test", "--", "--nocapture", "old", "a", "--exact"]);
});
it("jest/vitest via npm: files + -t pattern", () => {
const c = rerunFailedConfig(cfg("npm", "pnpm", ["run", "test"]), [
it.each([
["npm", ["--"]],
["pnpm", []],
["/opt/bin/pnpm", []],
["C:\\tools\\pnpm.CMD", []],
["yarn", ["--"]],
["bun", ["--"]],
["npx", []],
])("jest/vitest via %s: files + -t pattern", (command, separator) => {
const c = rerunFailedConfig(cfg("npm", command, ["run", "test"]), [
["src/a.test.ts", "math", "adds (1+1)"],
["src/b.test.ts"],
])!;
expect(c.args).toEqual([
"run",
"test",
"--",
...separator,
"src/a.test.ts",
"src/b.test.ts",
"-t",
@@ -102,6 +110,14 @@ describe("rerunFailedConfig", () => {
]);
});
it.each([
["run", "test", "-t", "old"],
["run", "test", "--", "--testNamePattern=old"],
])("replaces an existing pnpm test filter in %j", (...args) => {
const c = rerunFailedConfig(cfg("npm", "pnpm", args), [["src/a.test.ts", "new"]])!;
expect(c.args).toEqual(["run", "test", ...(args.includes("--") ? ["--"] : []), "src/a.test.ts", "-t", "^(?:new)$"]);
});
it("pytest: node ids replace explicit paths", () => {
const c = rerunFailedConfig(cfg("pytest", ".venv/bin/pytest", ["-q", "tests/", "-k", "slow"]), [
["tests/test_x.py", "TestC", "test_y[1]"],
+4 -4
View File
@@ -5,6 +5,7 @@
import { phpunitArgs, phpunitFilter } from "./phpunit";
import type { RunConfig } from "@/platform/ipc";
import { formatArgs } from "../configs";
import { appendScriptArgs } from "../scriptArgs";
import { djangoTestsOf } from "../djangoTypes";
import { goTestParts, withoutGoSelection } from "../goArguments";
import { frameworkFor } from "./index";
@@ -71,11 +72,10 @@ function jsArgs(config: RunConfig, paths: string[][]): string[] | null {
const names = unique(paths.filter((p) => p.length > 1).map((p) => p.slice(1).join(" ")));
if (!files.length) return null;
const { before, after } = splitDashDash(base);
const rest = dropFlag(after ?? [], ["-t", "--testNamePattern"]);
const flags = ["-t", "--testNamePattern"];
const args = after === null ? dropFlag(before, flags) : [...before, "--", ...dropFlag(after, flags)];
const filter = names.length ? ["-t", `^(?:${names.map(escapeRegExp).join("|")})$`] : [];
// npm/pnpm/yarn/bun pass everything after `--` to the script; deno/npx take them directly.
const needsDashDash = after !== null || /(^|\/)(npm|pnpm|yarn|bun)$/.test(config.command);
return needsDashDash ? [...before, "--", ...rest, ...files, ...filter] : [...before, ...files, ...filter];
return appendScriptArgs(config.command, args, [...files, ...filter]);
}
function pytestArgs(base: string[], paths: string[][]): string[] | null {
@@ -123,7 +123,7 @@ describe("theme tokens", () => {
* on purpose: colours the user assigns (project and data source colours), the
* platform's caption close and macOS window buttons, and the tint strength.
*/
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|mac-window-|tint-strength$)/;
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$)/;
describe("palette themes", () => {
const ISLANDS = ["light", "dark", "dark-hc"];
@@ -75,9 +75,10 @@
.pageHeader {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
height: 40px;
min-height: 40px;
padding: 0 12px 0 20px;
}
.history {
@@ -120,6 +121,7 @@
}
.scope {
display: inline-flex;
flex: none;
padding: 2px;
border-radius: var(--radius-small);
box-shadow: inset 0 0 0 1px var(--border-strong);
@@ -130,6 +132,7 @@
border-radius: 3px;
color: var(--fg-muted);
font-size: var(--fs-small);
white-space: nowrap;
}
.scopeButton:hover {
color: var(--fg-emphasis);
@@ -12,6 +12,8 @@
margin-bottom: 6px;
}
.presetLabel {
flex: none;
white-space: nowrap;
color: var(--fg);
}
.presetSelect {
@@ -226,7 +226,7 @@ export function AddInterpreterDialog({
<label htmlFor={ids.existing}>
<T>Interpreter:</T>
</label>
<div className={`${styles.pathRow} ${styles.existingPathRow}`}>
<div className={styles.pathRow}>
<Select
id={ids.existing}
className={styles.pathInput}
+14 -7
View File
@@ -9,21 +9,25 @@
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
min-width: 180px;
flex: 1;
max-width: 560px;
}
.sdkSelect {
flex: 1;
min-width: 260px;
min-width: 0;
}
.interpreterRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: var(--h-row);
}
.interpreterRow > .sdkField {
flex-basis: 260px;
}
.interpreterLabel {
white-space: nowrap;
color: var(--fg);
@@ -152,7 +156,7 @@
/* Add Python Interpreter dialog */
.dialogBody {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
grid-template-columns: minmax(150px, max-content) minmax(0, 1fr);
min-height: 300px;
}
.kinds {
@@ -174,6 +178,7 @@
color: var(--fg);
font: inherit;
text-align: left;
white-space: nowrap;
}
.kindOn {
background: var(--bg-list-selected);
@@ -187,12 +192,14 @@
}
.radios {
display: flex;
gap: 16px;
flex-wrap: wrap;
gap: 6px 16px;
}
.radio {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.pathRow {
display: flex;
@@ -201,7 +208,7 @@
flex: 1;
min-width: 0;
}
.existingPathRow {
.form > .pathRow {
flex: none;
}
.pathInput {
@@ -212,7 +219,7 @@
/* Project Structure */
.structure {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
grid-template-columns: minmax(240px, 1fr) 220px;
gap: 10px;
min-height: 420px;
}
@@ -418,7 +425,7 @@
}
.splitLayout {
display: grid;
grid-template-columns: 268px minmax(0, 1fr);
grid-template-columns: 268px minmax(300px, 1fr);
gap: 12px;
min-height: 360px;
}
@@ -27,6 +27,7 @@
/* Inline option groups, with custom font controls wrapping together. */
.fontRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 28px;
@@ -93,11 +94,14 @@
.radios {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
}
.radio {
display: inline-flex;
flex: none;
white-space: nowrap;
align-items: center;
gap: 6px;
}
@@ -111,6 +115,7 @@
border-collapse: collapse;
}
.table th {
white-space: nowrap;
text-align: left;
font-weight: normal;
color: var(--fg-muted);
@@ -119,6 +124,7 @@
border-bottom: 1px solid var(--border);
}
.table td {
white-space: nowrap;
height: 32px;
padding: 0 8px;
}
@@ -1,6 +1,6 @@
.layout {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
grid-template-columns: 200px minmax(220px, 1fr);
gap: 14px;
align-items: start;
}
@@ -25,11 +25,13 @@
/* "[x] Limit history to: [1000] rows" */
.fontFamily {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.inlineRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 28px;
@@ -166,6 +168,21 @@
padding: 0 8px;
cursor: default;
}
.toolRow > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolName {
flex: 1;
}
.toolRow.empty {
height: auto;
min-height: var(--h-list-row);
padding-block: 4px;
overflow-wrap: anywhere;
}
.toolRow:hover {
background: var(--bg-tree-hover);
}
@@ -174,6 +191,7 @@
background: var(--bg-tree-selected);
}
.toolGroup {
max-width: 45%;
color: var(--fg-subtle);
margin-left: auto;
}
@@ -176,7 +176,7 @@
}
.schemeBody {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 260px);
grid-template-columns: minmax(180px, 1fr) minmax(220px, 260px);
gap: 14px;
min-height: 320px;
}
@@ -14,6 +14,10 @@
.profileSelect {
min-width: 220px;
}
.profileRow > span {
flex: none;
white-space: nowrap;
}
.toolbar {
display: flex;
align-items: center;
@@ -27,6 +31,7 @@
}
.split {
flex: 1;
min-width: 520px;
min-height: 0;
display: flex;
gap: 12px;
+7 -2
View File
@@ -491,8 +491,13 @@ function RunTargetsEditor() {
className={clsx(styles.toolRow, t.id === selected && styles.toolRowSelected)}
onMouseDown={() => setSelected(t.id)}
>
<span>{t.name || t.id}</span>
<span className={styles.toolGroup}>
<span className={styles.toolName} title={t.name || t.id}>
{t.name || t.id}
</span>
<span
className={styles.toolGroup}
title={t.kind === "ssh" ? (t.sshConfig ? sshName(t.sshConfig) : "") : containerSummary(t)}
>
{t.kind === "ssh" ? (t.sshConfig ? sshName(t.sshConfig) : "") : containerSummary(t)}
</span>
</div>
+6 -2
View File
@@ -339,8 +339,12 @@ function SshConfigsEditor() {
className={clsx(styles.toolRow, c.id === selected && styles.toolRowSelected)}
onMouseDown={() => setSelected(c.id)}
>
<span>{c.name.trim() || sshLabel(c) || c.id}</span>
<span className={styles.toolGroup}>{sshLabel(c)}</span>
<span className={styles.toolName} title={c.name.trim() || sshLabel(c) || c.id}>
{c.name.trim() || sshLabel(c) || c.id}
</span>
<span className={styles.toolGroup} title={sshLabel(c)}>
{sshLabel(c)}
</span>
</div>
))}
</div>
+6 -2
View File
@@ -480,8 +480,12 @@ function ExternalToolsEditor() {
checked={t.enabled}
onChange={(on) => setTools(tools.map((x) => (x.id === t.id ? { ...x, enabled: on } : x)))}
/>
<span>{t.name}</span>
<span className={styles.toolGroup}>{t.group}</span>
<span className={styles.toolName} title={t.name}>
{t.name}
</span>
<span className={styles.toolGroup} title={t.group}>
{t.group}
</span>
</div>
))}
</div>
@@ -112,7 +112,9 @@ export function LiveTemplatesPage() {
<span className={clsx(styles.itemLabel, isModifiedBuiltin(t.id) && styles.modified)}>
{t.abbrev}
</span>
<span className={styles.itemDetail}>{t.description}</span>
<span className={styles.itemDetail} title={t.description}>
{t.description}
</span>
</div>
))}
</div>
@@ -63,7 +63,9 @@ export function PostfixPage() {
aria-label={tx`Enable .${t.key}`}
/>
<span className={styles.mono}>.{t.key}</span>
<span className={styles.itemDetail}>{t.description}</span>
<span className={styles.itemDetail} title={t.description}>
{t.description}
</span>
</div>
))}
</div>
@@ -14,6 +14,7 @@
}
.split {
flex: 1;
min-width: 520px;
min-height: 320px;
display: flex;
border: 1px solid var(--border-strong);
@@ -80,6 +81,8 @@
color: var(--fg-emphasis);
}
.itemDetail {
min-width: 0;
white-space: nowrap;
color: var(--fg-subtle);
overflow: hidden;
text-overflow: ellipsis;
@@ -13,6 +13,7 @@
}
.body {
display: flex;
min-width: 600px;
flex: 1;
min-height: 0;
border-radius: var(--radius-small);
@@ -169,6 +170,7 @@
}
.header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
}
+22 -4
View File
@@ -24,6 +24,7 @@ import {
import { tx } from "@/platform/i18n";
import { fuzzyMatch } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { confirm } from "@/workbench/messages";
import { openRemoteProject } from "@/workbench/projectWindow";
interface RecentState {
@@ -115,6 +116,14 @@ export async function removeRecent(path: string) {
notifications().notify({ level: "error", title: "Cannot remove recent project", body: r.error.message });
}
/** An open failure's kind and message, whether it is an `IpcError` or a raw rejected invoke. */
function failure(e: unknown): { kind?: string; message: string } {
if (e instanceof IpcError) return { kind: e.kind, message: e.message };
if (e && typeof e === "object" && "message" in e)
return { kind: (e as { kind?: string }).kind, message: String((e as { message: unknown }).message) };
return { message: e instanceof Error ? e.message : String(e) };
}
/** Opens a project from the recent list; offers to remove entries whose folder is gone. */
export async function openRecent(project: Pick<RecentProject, "path" | "name">): Promise<boolean> {
if (!isTauri()) {
@@ -140,12 +149,21 @@ export async function openRecent(project: Pick<RecentProject, "path" | "name">):
return true;
} catch (e) {
if (isCancelled(e)) return false;
const message = e instanceof Error ? e.message : String(e);
const missing = e instanceof IpcError ? e.kind === "notFound" : /not found|no such/i.test(message);
const { kind, message } = failure(e);
if (kind === "notFound" || (!kind && /not found|no such/i.test(message))) {
// A balloon can be switched off for this group and the welcome screen has no log to open.
const remove = await confirm({
title: tx`Project folder not found: ${project.name}`,
message: project.path,
ok: "Remove from Recent Projects",
});
if (remove) await removeRecent(project.path);
return false;
}
notifications().notify({
level: "error",
title: missing ? `Project folder not found: ${project.name}` : `Cannot open ${project.name}`,
body: missing ? project.path : message,
title: `Cannot open ${project.name}`,
body: message,
key: `welcome.open:${project.path}`,
actions: [{ label: "Remove from Recent Projects", run: () => void removeRecent(project.path) }],
});
-1
View File
@@ -197,7 +197,6 @@
"window.caption.minimize": "Minimize",
"window.caption.maximize": "Maximize",
"window.caption.restore": "Restore Down",
"window.caption.exitFullScreen": "Exit Full Screen",
"window.caption.close": "Close",
"menu.help.keymapReference": "Keymap Reference",
"menu.help.showLog": "{os, select, macos {Show Log in Finder} windows {Show Log in Explorer} other {Show Log in Files}}",
-1
View File
@@ -197,7 +197,6 @@
"window.caption.minimize": "最小化",
"window.caption.maximize": "最大化",
"window.caption.restore": "向下还原",
"window.caption.exitFullScreen": "退出全屏",
"window.caption.close": "关闭",
"menu.help.keymapReference": "快捷键参考",
"menu.help.showLog": "{os, select, macos {在访达中显示日志} windows {在资源管理器中显示日志} other {在文件管理器中显示日志}}",
-12
View File
@@ -436,18 +436,6 @@ html {
--fg-caption-close-hover: #ffffff;
}
/* macOS window buttons drawn by the web title bar in full screen: the system colours in every theme. */
:root,
:root[data-theme="light"],
:root[data-theme="dark-hc"] {
--mac-window-close: #ff5f57;
--mac-window-minimize: #febc2e;
--mac-window-zoom: #28c840;
--mac-window-glyph: rgb(0 0 0 / 55%);
--mac-window-edge: rgb(0 0 0 / 12%);
--mac-window-inactive: color-mix(in srgb, var(--fg-strong) 22%, transparent);
}
/*
* Islands Light. Every colour token of :root is redefined here. Values are
* PyCharm's own; its secondary greys, comment / metadata colours, line numbers
+1
View File
@@ -2,6 +2,7 @@
.button {
--button-radius: var(--radius-control, 4px);
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
gap: 6px;
+4 -2
View File
@@ -96,9 +96,11 @@ function PopupInner({
shift({ padding: 6 }),
sizeMw({
padding: 6,
apply({ rects, elements, availableHeight }) {
apply({ rects, elements, availableHeight, availableWidth }) {
elements.floating.style.maxHeight = `${Math.max(120, availableHeight)}px`;
if (matchWidth) elements.floating.style.minWidth = `${rects.reference.width}px`;
const width = Math.max(0, availableWidth);
elements.floating.style.maxWidth = `${width}px`;
if (matchWidth) elements.floating.style.minWidth = `${Math.min(rects.reference.width, width)}px`;
},
}),
],
+2 -1
View File
@@ -91,7 +91,8 @@
overflow: hidden;
}
.optionLabel {
flex: 1 0 auto;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+3 -1
View File
@@ -268,7 +268,9 @@ export function Select<V extends string = string>({
>
<span className={styles.check}>{o.value === value && <Icon name="check" size={12} />}</span>
{o.icon && <Icon name={o.icon} size={14} />}
<span className={clsx(styles.optionLabel, o.detail && styles.withDetail)}>{given(o.label)}</span>
<span className={clsx(styles.optionLabel, o.detail && styles.withDetail)} title={given(o.label)}>
{given(o.label)}
</span>
{o.detail && (
<span className={styles.detail} title={o.detail}>
{given(o.detail)}
-3
View File
@@ -252,9 +252,6 @@ const ICONS = {
"window-maximize": "openidea/general/windowMaximize",
"window-restore": "openidea/general/windowRestore",
"window-close": "openidea/general/windowClose",
"mac-window-close": "openidea/general/macWindowClose",
"mac-window-minimize": "openidea/general/macWindowMinimize",
"mac-window-exit-full-screen": "openidea/general/macWindowExitFullScreen",
hide: "general/hide",
pin: "general/pin",
eye: "general/show",
@@ -1,4 +0,0 @@
<!-- OpenIDEA: macOS window button glyph (close), drawn in the full-screen title bar. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 4.5L11.5 11.5M11.5 4.5L4.5 11.5" stroke="#6C707E" stroke-width="2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 299 B

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

Before

Width:  |  Height:  |  Size: 319 B

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

Before

Width:  |  Height:  |  Size: 277 B

-96
View File
@@ -1,96 +0,0 @@
/**
* macOS window buttons in the title bar's slot while the window fills the screen. Native full
* screen moves the system's buttons into the bar revealed with the menu bar, and a project tab
* shown in its group's full-screen Space has none, so the slot kept for them would be empty.
* Close closes this project's tab, the green button leaves full screen, minimize is disabled.
* Like the system's, they take no keyboard focus.
*/
import { useEffect, useState } from "react";
import clsx from "clsx";
import { t } from "@/platform/i18n";
import { commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { Icon } from "@/ui";
import styles from "./TitleBar.module.css";
type AppWindow = import("@tauri-apps/api/window").Window;
function useFullscreenWindow(): { fullscreen: boolean; active: boolean } {
const [fullscreen, setFullscreen] = useState(false);
const [active, setActive] = useState(() => document.hasFocus());
useEffect(() => {
// The plain-browser shell and tests have no Tauri window to ask.
if (!isTauri()) return;
let disposed = false;
const offs: (() => void)[] = [];
const sync = () =>
void commands
.windowIsFullscreen()
.then((on) => !disposed && setFullscreen(on))
.catch(() => {});
sync();
offs.push(onIdeEvent("windowFullscreenChanged", sync));
void import("@tauri-apps/api/window")
.then(async ({ getCurrentWindow }) => {
const w: AppWindow = getCurrentWindow();
const listeners = await Promise.all([
w.onResized(sync),
w.onFocusChanged(({ payload }) => {
setActive(payload);
sync();
}),
]);
if (disposed) listeners.forEach((off) => off());
else offs.push(...listeners);
})
.catch(() => {});
return () => {
disposed = true;
offs.forEach((off) => off());
};
}, []);
return { fullscreen, active };
}
function closeWindow() {
if (!isTauri()) return;
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow().close());
}
export function MacWindowButtons() {
const { fullscreen, active } = useFullscreenWindow();
if (!fullscreen) return null;
return (
<div
className={clsx(styles.macButtons, !active && styles.macButtonsInactive)}
onMouseDown={(e) => e.preventDefault()}
>
<button
type="button"
tabIndex={-1}
className={clsx(styles.macButton, styles.macClose)}
aria-label={t("window.caption.close")}
onClick={closeWindow}
>
<Icon name="mac-window-close" size={8} />
</button>
<button
type="button"
tabIndex={-1}
className={clsx(styles.macButton, styles.macMinimize)}
aria-label={t("window.caption.minimize")}
disabled
>
<Icon name="mac-window-minimize" size={8} />
</button>
<button
type="button"
tabIndex={-1}
className={clsx(styles.macButton, styles.macZoom)}
aria-label={t("window.caption.exitFullScreen")}
onClick={() => void commands.windowToggleFullscreen()}
>
<Icon name="mac-window-exit-full-screen" size={8} />
</button>
</div>
);
}
+4 -49
View File
@@ -16,6 +16,10 @@
/* Keep the gear on the icon-only right stripe's centre line, including compact mode. */
padding-right: calc(var(--stripe-outer-padding) + (var(--stripe-icon-button) - var(--titlebar-tool-button)) / 2);
}
/* Native full screen moves the window buttons out of the bar. */
.overlayFullscreen {
padding-left: 10px;
}
/* Welcome window: plain title on the frame colour with a hairline, like IntelliJ's welcome frame. */
.minimal {
background: var(--bg-elev);
@@ -195,52 +199,3 @@
background: var(--caption-close-pressed);
color: var(--fg-caption-close-hover);
}
/* macOS window buttons drawn in the traffic-light slot while the window fills the screen. */
.macButtons {
position: fixed;
top: 0;
left: 0;
height: var(--h-titlebar);
display: flex;
align-items: center;
gap: 8px;
padding-left: 12px;
zoom: calc(1 / var(--titlebar-zoom, 1));
z-index: 1;
}
.macButton {
width: 12px;
height: 12px;
padding: 0;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--mac-window-glyph);
box-shadow: inset 0 0 0 0.5px var(--mac-window-edge);
}
.macButton > * {
opacity: 0;
}
.macButtons:hover .macButton:not(:disabled) > * {
opacity: 1;
}
.macClose {
background: var(--mac-window-close);
}
.macMinimize {
background: var(--mac-window-minimize);
}
.macZoom {
background: var(--mac-window-zoom);
}
.macButton:active:not(:disabled) {
filter: brightness(0.8);
}
/* Disabled in full screen, and every button of an inactive window, are grey as AppKit draws them. */
.macButton:disabled,
.macButtonsInactive:not(:hover) .macButton {
background: var(--mac-window-inactive);
}
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more