Compare commits

..
Author SHA1 Message Date
l0ng-ai 9a4ef162bb fix(keymap): open shortcut menu when activating an action (#2148)
Double-clicking an action in Settings > Keymap went straight to the Keyboard Shortcut dialog, where mouse clicks do nothing, so trying to bind Alt+Click there got no reaction. Add Mouse Shortcut was reachable only by right-click.

Double-click and Enter now open the same menu as right-click, at the pointer or the selected row.

Sibling covered: the keyboard dialog no longer inherits mouse recording mode from an earlier Add Mouse Shortcut.

Fixes #2126
2026-11-08 20:53:51 +08:00
Muuandmuutot 278936dd7d fix(vcs): delete every selected branch, not only the right-clicked one (#2108)
In the Git Log Branches pane, Delete removed only the right-clicked branch and ignored the multi-selection: the menu was built from the clicked node alone and the action read only the first selected ref.

Delete now acts on the whole selection when the right-clicked branch is part of it. Current branches are skipped, each repository is focused for its branches and the original focus is restored afterwards, and all remote branches share one confirmation.

Siblings covered: the toolbar button, and the View Commits and Delete Tracked Branch notification actions, which now stay on the repository the branch was deleted from.

Co-authored-by: muutot <[email protected]>
2026-11-08 20:45:49 +08:00
hiennq12 49a113c875 fix(terminal): deliver the whole text of a multi-character key event (#2122)
With a backspace-and-retype input method such as GoNhanh, Vietnamese letters were lost in the terminal: `không` arrived as `khô`. These tools post a retyped string such as `ông` as one key event, but xterm builds its keypress text from `charCode`, which holds only the first character, and cancels the event. Such events are now delivered from the input event's own `data`.

IME ownership is now reset on the next keydown, so the key after a multi-character event is not duplicated. Terminal sessions and the Run console share this handling.

Refs #1325

Co-authored-by: hiennq12 <[email protected]>
2026-11-08 20:44:10 +08:00
aconeshanaandaconeshana 2d6394f203 fix(editor): take a JSX block comment back out on the second toggling (#2121)
In a TSX file, a second Comment with Line Comment on a JSX line wrapped `{/* ... */}` in another comment, leaving `/* {/* ... */} */`, which no longer compiles. The comment tokens depend on the syntax node at a position, and the action read them only at the start of the range, so it could never find the comment already around the range.

Both Comment with Line Comment and Comment with Block Comment now read the token pairs at both ends of the range and past its last line, take the widest match, and fall back to CodeMirror only for the wrapping.

Siblings covered: multiple selections on JSX lines, and selections spanning separate comments such as `/* a */ b /* c */`, which are now left unchanged instead of having delimiters removed across live code.

Co-authored-by: aconeshana <[email protected]>
2026-11-08 20:36:07 +08:00
l0ng-ai b57fba8b43 fix(agents): preserve sign-in failure details (#2141)
Any agent error containing the substring "auth" was replaced by generic sign-in guidance and the agent's own message was dropped, so a real failure could not be told apart and errors about an "author" looked like a sign-in problem.

Sign-in failures are now recognised by ACP's auth-required code or by whole authentication words and phrases. The guidance keeps the agent's text after it, and the code and message are logged. A Registry agent that advertises a terminal login now gets that command for the exact binary being launched.

Siblings covered: session creation, prompts and the native Codex adapter all use the same check.

Refs #2080
2026-11-08 20:34:39 +08:00
l0ng-ai 6394f5904a feat(editor): make caret mouse gestures configurable (#2143)
The gesture that adds a caret was hardcoded to Alt+Shift+Click, so it could not be changed in the Keymap settings.

Adds the "Add or Remove Caret" action to Editor Actions, with Alt+Shift+Click as the default in every preset. `multiCaret.ts` now matches the event against that action's effective bindings, so rebinding and removal apply at once. A stroke bound to it takes precedence over Alt-drag column selection and over mouse-shortcut dispatch in the editor; column selection is unchanged otherwise.

Closes #2127
Refs #2126
2026-11-08 20:33:37 +08:00
arookieofc 7f62011a08 fix(notifications): cap toast height so a long body can't push its close off-screen (#2138)
A balloon reporting a long run/launch error (stack trace, build output) grew
taller than the window: the body had no height limit, so the whole toast
overflowed and its dismiss button and actions scrolled out of reach. Cap the
balloon at the window height and let the body scroll inside it. Fixes #2137.
2026-11-08 20:32:42 +08:00
时雨遥 5ea9a2a3a9 fix(terminal): send a Pinyin input-source switch commit once, as typed (#2110)
With macOS Pinyin, switching the input source mid-composition (CapsLock) sent the letters twice with spaces: `awd` reached the shell as `aw daw d`. WebKit reports a keyCode-0 keydown before `compositionend`; xterm took it for a key that ends the composition and sent the text at once, then sent it again on `compositionend`. The IME also commits its displayed text, with spaces between syllables.

`ownTerminalInput` keeps that keydown away from xterm and rewrites a commit that is the raw ASCII composition text to the letters typed. Candidate commits stay as they are.

Sibling covered: the embedded terminal (Run console) had no macOS input handling and now uses `ownTerminalInput` too.

Co-authored-by: StarryKira <[email protected]>
2026-11-08 20:24:39 +08:00
l0ng-aiandjaw52 0bfd4fa3cc feat(app): install the openidea command on macOS, Windows and Linux (#2009)
Adds Install/Uninstall 'openidea' Command actions. macOS uses the bundle launcher, Windows a user PATH wrapper and named pipe, and Linux the executable/AppImage path with a PATH hint. Files, folders, positions and `diff` go to the running app or start it.

Closes #1286

Co-authored-by: jaw52 <[email protected]>
2026-11-08 19:25:50 +08:00
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
152 changed files with 5346 additions and 1357 deletions

No files matched your search

Generated
+52 -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",
@@ -4168,9 +4168,23 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-cli"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
"windows-sys 0.61.2",
"winreg 0.56.0",
]
[[package]]
name = "ide-clipboard"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4179,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4196,7 +4210,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-run",
@@ -4210,7 +4224,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
@@ -4231,7 +4245,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4258,7 +4272,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"serde",
@@ -4271,7 +4285,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4291,7 +4305,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4303,7 +4317,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4316,7 +4330,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4378,7 +4392,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"parking_lot",
@@ -4396,7 +4410,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4418,7 +4432,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"encoding_rs",
"futures",
@@ -4435,7 +4449,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4491,7 +4505,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"futures",
@@ -4517,7 +4531,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
@@ -4540,7 +4554,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"ide-core",
@@ -4561,7 +4575,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-run",
@@ -4574,7 +4588,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"globset",
"ide-core",
@@ -4592,7 +4606,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4616,7 +4630,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"ide-core",
@@ -4635,7 +4649,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"criterion",
"globset",
@@ -4655,7 +4669,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"parking_lot",
@@ -4668,7 +4682,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4689,7 +4703,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-language",
@@ -4704,7 +4718,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"libc",
@@ -4717,7 +4731,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"criterion",
"ide-core",
@@ -4730,7 +4744,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"flate2",
"hex",
@@ -4751,7 +4765,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -4772,7 +4786,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"chardetng",
"dunce",
@@ -4796,7 +4810,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"open",
@@ -4812,7 +4826,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -6358,7 +6372,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6370,6 +6384,7 @@ dependencies = [
"ide-analysis",
"ide-android",
"ide-build",
"ide-cli",
"ide-clipboard",
"ide-core",
"ide-coverage",
+2 -1
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"
@@ -45,6 +45,7 @@ ide-host = { path = "crates/ide-host" }
ide-notebook = { path = "crates/ide-notebook" }
ide-extension = { path = "crates/ide-extension" }
ide-plugin = { path = "crates/ide-plugin" }
ide-cli = { path = "crates/ide-cli" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
+23
View File
@@ -27,6 +27,29 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use tokio::sync::oneshot;
/// ACP's error code for a request that needs the user to sign in.
pub(crate) const AUTH_REQUIRED: i64 = -32000;
// Covers signed-out users and refused accounts (Claude Code: "API Error: 403").
pub(crate) fn auth_required(code: i64, message: &str) -> bool {
if code == AUTH_REQUIRED {
return true;
}
let lower = message.to_lowercase();
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
words.iter().any(|w| {
*w == "auth"
|| w.starts_with("authenticat")
|| w.starts_with("authoriz")
|| w.starts_with("unauthenticat")
|| w.starts_with("unauthoriz")
|| w.starts_with("oauth")
}) || words.windows(3).any(|w| w == ["not", "logged", "in"])
|| words
.windows(2)
.any(|w| w == ["logged", "out"] || w == ["sign", "in"] || w == ["log", "in"] || w == ["login", "required"])
}
/// The capabilities this client offers, in v1 form.
fn client_capabilities() -> Value {
json!({
+36
View File
@@ -16,6 +16,42 @@ const CANCEL: &str = include_str!("../../tests/fixtures/acp-v2/cancel.jsonl");
const RESUME: &str = include_str!("../../tests/fixtures/acp-v2/resume.jsonl");
const INITIALIZE_V1: &str = include_str!("../../tests/fixtures/acp-v2/initialize-v1.jsonl");
#[test]
fn auth_required_matches_authentication_words_and_sign_in_phrases() {
for (code, message, expected) in [
(AUTH_REQUIRED, "This account is not eligible.", true),
(codes::INTERNAL_ERROR, "Auth required", true),
(codes::INTERNAL_ERROR, "Authentication failed", true),
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 401", true),
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 403", true),
(codes::INTERNAL_ERROR, "Not authenticated", true),
(codes::INTERNAL_ERROR, "Authorization failed", true),
(codes::INTERNAL_ERROR, "Authorize your account", true),
(codes::INTERNAL_ERROR, "Unauthenticated", true),
(codes::INTERNAL_ERROR, "Unauthentication error", true),
(codes::INTERNAL_ERROR, "Unauthorized", true),
(codes::INTERNAL_ERROR, "Unauthorization error", true),
(codes::INTERNAL_ERROR, "OAuth token has expired", true),
(codes::INTERNAL_ERROR, "OAuth2 token has expired", true),
(codes::INTERNAL_ERROR, "You are not logged in", true),
(codes::INTERNAL_ERROR, "You are logged out", true),
(codes::INTERNAL_ERROR, "Please sign in", true),
(codes::INTERNAL_ERROR, "Please log in", true),
(codes::INTERNAL_ERROR, "Login required", true),
(codes::INTERNAL_ERROR, "author", false),
(codes::INTERNAL_ERROR, "authors", false),
(codes::INTERNAL_ERROR, "authority", false),
(codes::INTERNAL_ERROR, "authored", false),
(codes::INTERNAL_ERROR, "reauthenticate", false),
(codes::INTERNAL_ERROR, "preauthorized", false),
(codes::INTERNAL_ERROR, "signal input failed", false),
(codes::INTERNAL_ERROR, "catalog index failed", false),
(codes::INTERNAL_ERROR, "login succeeded", false),
] {
assert_eq!(auth_required(code, message), expected, "code={code}, message={message:?}");
}
}
/// The messages of a fixture going `dir` (`in` or `out`).
pub(crate) fn frames(fixture: &str, dir: &str) -> Vec<Value> {
fixture
+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": [
+23 -14
View File
@@ -1394,13 +1394,22 @@ impl Task {
};
return Failure { message, limit: Some(resets) };
}
match e {
// Signed out, or the account was refused (Claude Code: "API Error: 403").
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message))
}
e => Failure::from(self.describe(e)),
let guidance = self.live.lock().as_ref().map(|live| live.sign_in_message.clone());
Failure::from(self.sign_in_error(&e, guidance.as_deref()).unwrap_or_else(|| self.describe(e)))
}
fn sign_in_error(&self, e: &RpcError, guidance: Option<&str>) -> Option<String> {
let RpcError::Server { code, message, .. } = e else { return None };
if !acp::auth_required(*code, message) {
return None;
}
tracing::warn!(task = %self.id, code, %message, "agent sign-in failed");
let Some(guidance) = guidance else { return Some(message.clone()) };
Some(if message.trim().is_empty() || guidance.contains(message) {
guidance.to_owned()
} else {
format!("{guidance}\n\n{message}")
})
}
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
@@ -1419,7 +1428,6 @@ impl Task {
let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd))
.await
.map_err(|e| e.to_string())?;
let sign_in_message = spec.sign_in_message();
self.stderr.lock().clear();
self.config_sent.lock().clear();
let weak = Arc::downgrade(self);
@@ -1439,6 +1447,7 @@ impl Task {
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", spec.name, spec.command))?;
let init = peer.initialize(&client_info()).await.map_err(|e| self.describe(e))?;
let sign_in_message = spec.sign_in_message(&init.auth_methods);
let can_load = init.load_session();
let (capabilities, embedded) = capabilities_of(&init);
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
@@ -1463,12 +1472,7 @@ impl Task {
let v: Value = peer
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
.await
.map_err(|e| match e {
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
sign_in_message.clone()
}
e => self.describe(e),
})?;
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
(id, v)
}
@@ -1581,6 +1585,7 @@ impl Task {
.collect()
};
if due.is_empty() {
self.approve_pending();
return;
}
let task = self.clone();
@@ -1610,6 +1615,7 @@ impl Task {
Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id),
}
}
task.approve_pending();
task.persist();
task.emit_task();
});
@@ -1726,6 +1732,9 @@ impl Task {
if kind == "config_option_update" && !self.replaying.load(Ordering::Acquire) {
self.apply_late_choices();
}
if !self.replaying.load(Ordering::Acquire) && kind == "current_mode_update" {
self.approve_pending();
}
return;
}
if self.replaying.load(Ordering::Acquire) {
@@ -1857,7 +1866,7 @@ impl Task {
fn approve_pending(&self) {
let s = self.state.lock();
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
return;
}
let mut pending = self.permissions.lock();
+2 -3
View File
@@ -8,6 +8,7 @@ mod translate;
mod tests;
use super::Shim;
use crate::acp::AUTH_REQUIRED;
use crate::provider::ProviderSpec;
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
use parking_lot::Mutex;
@@ -27,8 +28,6 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
const EARLY_CANCEL: Duration = Duration::from_secs(1);
/// How long the client's connection outlives codex.
const EXIT_GRACE: Duration = Duration::from_millis(100);
/// ACP's error code for a request that needs the user to sign in.
const AUTH_REQUIRED: i64 = -32000;
pub(crate) fn connect(
spec: &ProviderSpec,
@@ -204,7 +203,7 @@ fn from_codex(e: RpcError) -> ResponseError {
code,
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
);
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
let code = if !protocol && crate::acp::auth_required(code, &message) { AUTH_REQUIRED } else { code };
ResponseError { code, message, data }
}
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
@@ -623,6 +623,23 @@ async fn auth_failure_names_auth() {
assert!(start.get("jsonrpc").is_some(), "codex accepts the jsonrpc field");
}
#[test]
fn request_errors_classify_authentication_without_matching_author_or_protocol_errors() {
for (code, message, expected) in [
(codes::INTERNAL_ERROR, "Missing author field", codes::INTERNAL_ERROR),
(codes::INTERNAL_ERROR, "Unknown authority", codes::INTERNAL_ERROR),
(codes::INTERNAL_ERROR, "Authentication failed: expired token", AUTH_REQUIRED),
(codes::INTERNAL_ERROR, "Unauthorized", AUTH_REQUIRED),
(codes::METHOD_NOT_FOUND, "authentication method not found", codes::METHOD_NOT_FOUND),
(AUTH_REQUIRED, "Account is not eligible", AUTH_REQUIRED),
] {
let response = from_codex(RpcError::Server { code, message: message.into(), data: Some(json!({"detail": 1})) });
assert_eq!(response.code, expected, "{message}");
assert_eq!(response.message, message);
assert_eq!(response.data, Some(json!({"detail": 1})));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_stop_sent_right_after_the_prompt_interrupts_the_turn() {
let (results, _client, frames, _) = replay(Scenario::Interrupt).await;
+70 -10
View File
@@ -5,6 +5,7 @@
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -92,9 +93,26 @@ impl ProviderSpec {
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
}
pub(crate) fn sign_in_message(&self) -> String {
pub(crate) fn sign_in_message(&self, auth_methods: &[Value]) -> String {
let name = &self.name;
let hint = self.login_hint(Path::new(&self.command));
let program = Path::new(&self.command);
let terminal_args = matches!(self.source, AgentSource::Registry { .. })
.then(|| {
auth_methods.iter().find_map(|method| {
if method.get("type").and_then(Value::as_str) != Some("terminal") {
return None;
}
method.get("args")?.as_array()?.iter().map(Value::as_str).collect::<Option<Vec<_>>>()
})
})
.flatten();
let hint = match terminal_args {
Some(args) => {
let command = render_login_command(program, args, cfg!(windows));
format!("Run `{command}` in a terminal.")
}
None => self.login_hint(program),
};
format!("{name} needs you to sign in. {hint}")
}
@@ -124,14 +142,8 @@ fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>
if on_path.is_some_and(|path| same_path(path, program)) {
return spec.login_hint.clone();
}
let path = program.to_string_lossy();
let quoted = if windows {
format!("& '{}'", path.replace('\'', "''"))
} else {
format!("'{}'", path.replace('\'', "'\\''"))
};
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
let command = format!("{quoted}{args}");
let command = render_login_command(program, args.split_whitespace(), windows);
match suffix {
" in a terminal." => format!("Run `{command}` in a terminal."),
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
@@ -139,6 +151,22 @@ fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>
}
}
fn render_login_command<'a>(program: &Path, args: impl IntoIterator<Item = &'a str>, windows: bool) -> String {
let quote = |word: &str| {
if windows { format!("'{}'", word.replace('\'', "''")) } else { format!("'{}'", word.replace('\'', "'\\''")) }
};
let mut command = format!("{}{}", if windows { "& " } else { "" }, quote(&program.to_string_lossy()));
for arg in args {
command.push(' ');
if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_=./".contains(c)) {
command.push_str(arg);
} else {
command.push_str(&quote(arg));
}
}
command
}
/// Whether `id` names a built-in agent that needs no registry install (native drivers
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
@@ -370,6 +398,7 @@ fn is_executable(p: &Path) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
@@ -429,12 +458,43 @@ mod tests {
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
let resolved = cursor.clone().resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), program);
assert!(resolved.sign_in_message().contains(&*program.to_string_lossy()));
assert!(resolved.sign_in_message(&[]).contains(&*program.to_string_lossy()));
cursor.command = format!("tools/{command}");
let resolved = cursor.resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
}
#[test]
fn terminal_login_commands_quote_the_binary_and_each_argument() {
for (windows, expected) in [
(false, "'/agent'\\''s tools/$cli' auth login --account 'user name' 'a'\\''b' '$token' ''"),
(true, "& '/agent''s tools/$cli' auth login --account 'user name' 'a''b' '$token' ''"),
] {
assert_eq!(
render_login_command(
Path::new("/agent's tools/$cli"),
["auth", "login", "--account", "user name", "a'b", "$token", ""],
windows
),
expected
);
}
let mut spec = builtin_spec("cursor").unwrap();
spec.id = "custom".into();
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
spec.login_hint = "Sign in with the agent's own command line first.".into();
for method in [
json!({"type": "terminal"}),
json!({"type": "terminal", "args": ["login", null]}),
json!({"type": "agent", "args": ["login"]}),
] {
assert_eq!(
spec.sign_in_message(&[method]),
"Cursor needs you to sign in. Sign in with the agent's own command line first."
);
}
}
#[cfg(unix)]
#[test]
fn registry_login_hint_names_the_installed_executable() {
+275 -6
View File
@@ -35,6 +35,10 @@ struct FakeAgent {
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
request_error: Option<(i64, String)>,
auth_methods: Vec<Value>,
/// Hold the late mode reply until the prompt's permission request is already waiting.
hold_late_mode: bool,
empty_config: AtomicBool,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
@@ -126,7 +130,17 @@ impl Handler for FakeAgent {
if (method == "session/new" && self.auth_on_new)
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
{
return Err((codes::INTERNAL_ERROR, "Authentication required".into()));
return Err(self
.request_error
.clone()
.unwrap_or((codes::INTERNAL_ERROR, "Authentication required".into())));
}
if self.hold_late_mode
&& method == "session/set_config_option"
&& params.get("value").and_then(Value::as_str) == Some("full-access")
{
// Let the prompt's permission request reach the task before the mode changes.
std::thread::sleep(Duration::from_millis(100));
}
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
match method {
@@ -134,8 +148,10 @@ impl Handler for FakeAgent {
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
self.calls.lock().push("subagents".into());
}
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}))
Ok(
json!({"protocolVersion": 1, "authMethods": self.auth_methods, "agentCapabilities": {"loadSession": !self.no_load,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}),
)
}
"session/new" => {
if self.empty_config.load(Ordering::Acquire) {
@@ -200,6 +216,29 @@ impl Handler for FakeAgent {
self.say(&session, "ok");
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "late full access" {
// The mode option appears only now; the user's choice is applied late.
self.update(
&session,
json!({"sessionUpdate": "config_option_update", "configOptions": [
{"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]}
]}),
);
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session, "toolCall": {"toolCallId": "late", "title": "Run cargo test"},
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
None,
))
.unwrap();
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("late chose {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "ask" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
@@ -360,7 +399,110 @@ async fn authentication_failures_name_the_session_executable_at_start_and_during
.expect("authentication error");
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
assert!(error.contains(&*program.to_string_lossy()), "{error}");
assert!(error.ends_with(" login` in a terminal."), "{error}");
assert!(error.ends_with(" login` in a terminal.\n\nAuthentication required"), "{error}");
mgr.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn sign_in_errors_preserve_details_and_leave_unrelated_errors_unchanged() {
for auth_on_new in [true, false] {
for (code, message, sign_in) in [
(-32000, "This Google account is not eligible for this agent.", true),
(codes::INTERNAL_ERROR, "Authentication failed: token expired for account alice.", true),
(codes::INTERNAL_ERROR, "UNAUTHORIZED: account refused", true),
(codes::INTERNAL_ERROR, "You are not logged in to this installation.", true),
(codes::INTERNAL_ERROR, "Please sign in with your work account.", true),
(codes::INTERNAL_ERROR, "Missing author field", false),
(codes::INTERNAL_ERROR, "Unknown authority in configuration", false),
] {
let tmp = tempfile::tempdir().unwrap();
let agent =
Arc::new(FakeAgent { auth_on_new, request_error: Some((code, message.into())), ..Default::default() });
let mgr = manager(tmp.path(), agent);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "cursor".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
if !auth_on_new {
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.prompt(&task.id, text("auth"), false).await.unwrap();
}
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
let entries = mgr.entries(&task.id, None).unwrap();
let error = entries
.iter()
.find_map(|e| match e {
AgentEntry::Error { message, .. } => Some(message),
_ => None,
})
.unwrap();
if sign_in {
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
assert!(error.ends_with(&format!("\n\n{message}")), "{error}");
} else {
assert_eq!(error, message);
}
mgr.shutdown();
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn registry_sign_in_errors_use_the_advertised_terminal_login() {
for terminal in [true, false] {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent {
auth_on_new: true,
request_error: Some((-32000, "This account is not eligible.".into())),
auth_methods: vec![json!({"type": if terminal { "terminal" } else { "agent" },
"id": "cli", "name": "CLI", "args": ["auth", "login"]})],
..Default::default()
});
let mgr = manager(tmp.path(), agent);
let program = tmp.path().join("Registry Tools").join("antigravity.cmd");
let mut spec = provider::builtin_spec("cursor").unwrap();
spec.id = "antigravity".into();
spec.name = "Google Antigravity".into();
spec.command = program.to_string_lossy().into_owned();
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
spec.login_hint = "Sign in with the agent's own command line first.".into();
mgr.shared.providers.write().push(spec);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "antigravity".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
let entries = mgr.entries(&task.id, None).unwrap();
let error = entries
.iter()
.find_map(|e| match e {
AgentEntry::Error { message, .. } => Some(message),
_ => None,
})
.unwrap();
assert!(error.starts_with("Google Antigravity needs you to sign in. "), "{error}");
if terminal {
assert!(error.contains(&*program.to_string_lossy()), "{error}");
assert!(error.contains(" auth login` in a terminal."), "{error}");
} else {
assert!(error.contains("Sign in with the agent's own command line first."), "{error}");
}
assert!(error.ends_with("\n\nThis account is not eligible."), "{error}");
mgr.shutdown();
}
}
@@ -1438,12 +1580,12 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
mgr.prompt(&task.id, text("late option"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let t = mgr.task(&task.id).unwrap();
assert!(config::bypass_approvals("cursor", &t.state.lock().task.config));
assert!(config::bypass_approvals(&t.state.lock().task.config));
t.kill();
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
assert!(config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
assert!(config::bypass_approvals(&mgr.shared.known_config("cursor")));
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
@@ -1534,6 +1676,133 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_full_access_mode_approves_tool_requests() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = vec![json!({
"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]
})];
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
assert!(
mgr.entries(&task.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
// Choosing Full Access while the request is already waiting also approves it.
let waiting = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &waiting.id, AgentStatus::NeedsInput).await;
mgr.set_config(&waiting.id, "mode", "full-access".into()).await.unwrap();
wait_for(&mgr, &waiting.id, AgentStatus::Ready).await;
assert!(
mgr.entries(&waiting.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_late_full_access_option_approves_a_pending_request() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { hold_late_mode: true, ..Default::default() });
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("late full access"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let task = mgr.task(&task.id).unwrap();
let (status, busy) = {
let s = task.state.lock();
(s.task.status, s.busy)
};
if status == AgentStatus::Ready && !busy {
break;
}
if std::time::Instant::now() > deadline {
let _ = mgr.cancel(&task.id, None);
mgr.shutdown();
panic!("late Full Access did not approve the pending request");
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
assert!(
mgr.entries(&task.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn an_allow_all_permissions_option_approves_tool_requests() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = vec![json!({
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select", "currentValue": "off",
"options": [{"value": "off", "name": "Ask"}, {"value": "on", "name": "Full Access"}]
})];
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "allow_all".into(), value: "on".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"on\"")));
assert!(
mgr.entries(&task.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn archived_tasks_can_be_restored_and_continue_their_session() {
let tmp = tempfile::tempdir().unwrap();
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "ide-cli"
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
dirs.workspace = true
tracing.workspace = true
[target.'cfg(windows)'.dependencies]
tokio = { workspace = true, features = ["net"] }
winreg = "0.56"
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] }
[dev-dependencies]
tempfile.workspace = true
+234
View File
@@ -0,0 +1,234 @@
//! Command-line arguments of `openidea`.
use crate::Target;
use std::path::Path;
pub const USAGE: &str = "\
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
openidea diff <left> <right>
Opens folders as projects and files in the editor of the running OpenIDEA,
starting it if needed. Without paths, brings OpenIDEA to the front.
Lines and columns start at 1; --line and --column apply to the next path.
Options:
--line <n> Put the caret on line <n> of the next file
--column <n> Put the caret in column <n> of that line
-h, --help Show this help
-v, --version Show the version
";
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Help,
Version,
Open(Vec<Target>),
Diff(Vec<Target>),
}
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
parse_with(args, cwd, &|p| p.exists())
}
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
if args.first().is_some_and(|arg| arg == "diff") {
return match parse_paths(&args[1..], cwd, exists)? {
Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)),
Command::Help => Ok(Command::Help),
_ => Err("diff needs two files".into()),
};
}
parse_paths(args, cwd, exists)
}
fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
let mut targets = Vec::new();
let mut options = true;
let (mut line, mut column) = (None, None);
let mut args = args.iter();
while let Some(arg) = args.next() {
let (flag, inline) = match arg.split_once('=') {
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
_ => (arg.as_str(), None),
};
match flag {
"--" if options => options = false,
"-h" | "--help" if options => return Ok(Command::Help),
"-v" | "--version" if options => return Ok(Command::Version),
"--line" | "--column" if options => {
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
}
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
_ => {
let mut target = target(arg, cwd, exists)?;
if line.is_some() || column.is_some() {
target.line = line.take().or(target.line).or(Some(1));
target.column = column.take();
}
targets.push(target);
}
}
}
if line.is_some() || column.is_some() {
return Err("--line and --column need a file after them".into());
}
Ok(Command::Open(targets))
}
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
let path = cwd.join(arg);
if exists(&path) {
return Ok(Target { path, line: None, column: None });
}
for (base, line, column) in split_position(arg) {
let path = cwd.join(base);
if exists(&path) {
return Ok(Target { path, line: Some(line), column });
}
}
Err(format!("{arg}: no such file or directory"))
}
/// The readings of `arg` as `path:line:column`, then as `path:line`.
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
let Some(last) = position(last) else { return Vec::new() };
let mut readings = Vec::new();
if let Some((base, line)) = rest.rsplit_once(':')
&& let Some(line) = position(line)
&& !base.is_empty()
{
readings.push((base, line, Some(last)));
}
if !rest.is_empty() {
readings.push((rest, last, None));
}
readings
}
fn position(s: &str) -> Option<u32> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok().filter(|n| *n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_owned()).collect()
}
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
}
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
Target { path: path.into(), line, column }
}
#[test]
fn paths_resolve_against_cwd() {
assert_eq!(
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
);
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
}
#[test]
fn positions_split_only_when_the_whole_arg_is_missing() {
let files = ["/work/a.rs"];
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
assert!(parse_in(&["a.rs:0"], &files).is_err());
assert!(parse_in(&["a.rs:x"], &files).is_err());
assert!(parse_in(&["missing.rs:3"], &files).is_err());
}
// Windows file names cannot contain `:`, and `b:2` reads as a path on drive B.
#[cfg(unix)]
#[test]
fn an_existing_name_with_a_colon_wins_over_a_position() {
let files = ["/work/b:2"];
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
}
#[test]
fn windows_drive_colon_is_not_a_position() {
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
assert!(split_position(r"C:\a.rs").is_empty());
}
#[cfg(windows)]
#[test]
fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() {
let local = PathBuf::from(r"C:\项目\my file.rs");
let network = PathBuf::from(r"\\server\share\folder name");
let command = parse_with(
&args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]),
Path::new(r"D:\work"),
&|path| path == local || path == network,
);
assert_eq!(
command,
Ok(Command::Open(vec![
Target { path: local, line: Some(12), column: Some(5) },
Target { path: network, line: None, column: None },
]))
);
}
#[test]
fn line_and_column_flags_apply_to_the_next_path() {
let files = ["/work/a.rs", "/work/b.rs"];
assert_eq!(
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
);
assert_eq!(
parse_in(&["--line=3", "a.rs:9:9"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
);
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
assert!(parse_in(&["--line"], &files).is_err());
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
}
#[test]
fn options() {
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
assert!(parse_in(&["--nope"], &[]).is_err());
assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)])));
}
#[test]
fn existing_diff_command_is_supported() {
let files = ["/work/a.rs", "/work/b.rs"];
assert_eq!(
parse_in(&["diff", "a.rs", "b.rs"], &files),
Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),]))
);
assert!(parse_in(&["diff", "a.rs"], &files).is_err());
assert_eq!(
parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]),
Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),]))
);
assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)])));
assert_eq!(
parse_in(&["--column=4", "a.rs", "b.rs"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),]))
);
}
}
+476
View File
@@ -0,0 +1,476 @@
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
//! script in a macOS bundle, or a wrapper in a user's bin directory.
use std::ffi::OsStr;
use std::io;
use std::path::{Path, PathBuf};
pub const COMMAND: &str = "openidea";
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
const SYSTEM_DIR: &str = "/usr/local/bin";
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
launcher.is_file().then_some(launcher)
}
/// macOS runs a quarantined app from a random read-only copy until it is moved:
/// a link there would break on the next launch.
pub fn translocated(path: &Path) -> bool {
path.to_string_lossy().contains("/AppTranslocation/")
}
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
/// rights needed), else `/usr/local/bin`.
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
let local = home.map(|h| h.join(".local/bin"));
match (path_var, local) {
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
_ => PathBuf::from(SYSTEM_DIR),
}
}
/// Directories an earlier install may have used.
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
}
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
}
fn is_launcher_link(link: &Path) -> bool {
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
}
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
/// is not writable. Refuses to replace an `openidea` that is not ours.
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
let link = dir.join(COMMAND);
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists and is not an OpenIDEA command", link.display()),
));
}
let direct = std::fs::create_dir_all(dir).and_then(|()| {
if link.symlink_metadata().is_ok() {
std::fs::remove_file(&link)?;
}
symlink(launcher, &link)
});
match direct {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
}
result => result?,
}
Ok(link)
}
/// Removes `links`, asking for administrator rights where needed.
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
for link in links {
match std::fs::remove_file(link) {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
result => result?,
}
}
Ok(())
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(not(unix))]
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
}
/// Runs `command` as root after the system's password prompt. Cancelling it is
/// [`io::ErrorKind::Interrupted`].
fn privileged(command: &str) -> io::Result<()> {
if !cfg!(target_os = "macos") {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
}
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
if output.status.success() {
return Ok(());
}
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
// "User canceled. (-128)"
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
Err(io::Error::new(kind, message))
}
fn quote(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
}
fn applescript_escape(s: &str) -> String {
s.replace('\\', r"\\").replace('"', "\\\"")
}
const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1";
/// AppImages remount their executable on every launch. Keep the original image path.
pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result<PathBuf> {
let path = appimage.unwrap_or(exe);
if !path.is_absolute() || !path.is_file() {
return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location"));
}
Ok(path.to_path_buf())
}
pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf {
dir.join(if windows { "openidea.cmd" } else { COMMAND })
}
fn wrapper(exe: &Path, windows: bool) -> io::Result<String> {
let exe =
exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?;
if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters"));
}
Ok(if windows {
format!(
"@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n",
exe.replace('%', "%%")
)
} else {
format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe)))
})
}
pub fn is_wrapper(path: &Path, windows: bool) -> bool {
path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink())
&& std::fs::read_to_string(path).is_ok_and(|text| {
text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" }))
})
}
/// Never follows or overwrites a foreign file, even if it has the same command name.
pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result<PathBuf> {
use std::io::Write;
let text = wrapper(exe, windows)?;
let path = wrapper_path(dir, windows);
if path.symlink_metadata().is_ok() {
if !is_wrapper(&path, windows) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists and is not an OpenIDEA command", path.display()),
));
}
std::fs::remove_file(&path)?;
}
std::fs::create_dir_all(dir)?;
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o755))?;
}
file.write_all(text.as_bytes())?;
Ok(path)
}
pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result<Vec<PathBuf>> {
let path = wrapper_path(dir, windows);
if !is_wrapper(&path, windows) {
return Ok(vec![]);
}
std::fs::remove_file(&path)?;
Ok(vec![path])
}
/// Preserve every unrelated PATH entry, including expandable environment variables.
pub fn windows_path(current: &str, dir: &str, add: bool) -> Option<String> {
fn equal(a: &str, b: &str) -> bool {
a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/']))
}
let found = current.split(';').any(|entry| equal(entry, dir));
if add {
if found {
return None;
}
Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" }))
} else if found {
Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::<Vec<_>>().join(";"))
} else {
None
}
}
#[cfg(windows)]
pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> {
use winreg::{
RegKey,
enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ},
types::{FromRegValue, ToRegValue},
};
let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?;
let raw = match environment.get_raw_value("Path") {
Ok(raw) => Some(raw),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default();
let Some(updated) = windows_path(&current, &dir.to_string_lossy(), add) else { return Ok(()) };
let mut value = updated.to_reg_value();
value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone());
environment.set_raw_value("Path", &value)?;
let message: Vec<u16> = "Environment\0".encode_utf16().collect();
// SAFETY: the terminated string stays alive for this bounded synchronous broadcast.
unsafe {
windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW(
windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST,
windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE,
0,
message.as_ptr() as isize,
windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG,
1000,
std::ptr::null_mut(),
);
}
Ok(())
}
pub struct Installation {
pub path: PathBuf,
/// Some when the user needs to add this directory to their shell's PATH.
pub path_directory: Option<PathBuf>,
}
fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool {
path_var
.and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file()))
.is_some_and(|candidate| candidate == command)
}
fn user_bin() -> io::Result<PathBuf> {
if cfg!(windows) {
dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin"))
} else {
dirs::home_dir().map(|dir| dir.join(".local/bin"))
}
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command"))
}
pub fn install_current() -> io::Result<Installation> {
let exe = std::env::current_exe()?;
if cfg!(target_os = "macos") {
let launcher = bundled_launcher(&exe).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app")
})?;
if translocated(&launcher) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"move OpenIDEA to the Applications folder first",
));
}
let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref());
return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None });
}
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
let exe = stable_executable(&exe, image.as_deref())?;
let dir = user_bin()?;
let path = install_wrapper(&exe, &dir, cfg!(windows))?;
#[cfg(windows)]
update_user_path(&dir, true)?;
let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path);
Ok(Installation { path, path_directory: (!on_path).then_some(dir) })
}
pub fn uninstall_current() -> io::Result<Vec<PathBuf>> {
if cfg!(target_os = "macos") {
let links = installed(&link_dirs(dirs::home_dir().as_deref()));
uninstall(&links)?;
return Ok(links);
}
let dir = user_bin()?;
let path = wrapper_path(&dir, cfg!(windows));
if is_wrapper(&path, cfg!(windows)) {
#[cfg(windows)]
update_user_path(&dir, false)?;
return uninstall_wrapper(&dir, cfg!(windows));
}
Ok(vec![])
}
#[cfg(test)]
mod wrapper_tests {
use super::*;
#[test]
fn wrappers_install_update_and_remove_only_their_own_files() {
for windows in [false, true] {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("App folder/程序 & it's 100%.exe");
let path = install_wrapper(&exe, dir.path(), windows).unwrap();
assert!(is_wrapper(&path, windows));
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path);
assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]);
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
std::fs::write(&path, "foreign command").unwrap();
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command");
}
}
#[test]
fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() {
let dir = r"C:\Users\用户\OpenIDEA\bin";
let initial = r"%USERPROFILE%\tools;C:\other";
let updated = windows_path(initial, dir, true).unwrap();
assert_eq!(updated, format!("{dir};{initial}"));
assert_eq!(windows_path(&updated, dir, true), None);
assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial));
assert_eq!(
windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(),
Some(r"C:\other")
);
assert_eq!(windows_path(initial, dir, false), None);
}
#[test]
fn appimage_uses_the_persistent_image_not_the_mounted_binary() {
let dir = tempfile::tempdir().unwrap();
let image = dir.path().join("OpenIDEA.AppImage");
std::fs::write(&image, "image").unwrap();
assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image);
assert_eq!(stable_executable(&image, None).unwrap(), image);
assert!(stable_executable(Path::new("missing"), None).is_err());
}
#[test]
fn detects_a_system_binary_shadowing_the_linux_wrapper() {
let dir = tempfile::tempdir().unwrap();
let system = dir.path().join("system");
let user = dir.path().join("user");
for bin in [&system, &user] {
std::fs::create_dir_all(bin).unwrap();
std::fs::write(bin.join(COMMAND), "command").unwrap();
}
let path = std::env::join_paths([&system, &user]).unwrap();
assert!(!resolves_to(Some(&path), &user.join(COMMAND)));
let path = std::env::join_paths([&user, &system]).unwrap();
assert!(resolves_to(Some(&path), &user.join(COMMAND)));
}
#[cfg(unix)]
#[test]
fn posix_wrapper_preserves_arguments_and_exit_status() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("app & it's $dollar `quoted` \"double\"");
std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap();
let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n");
}
#[cfg(windows)]
#[test]
fn cmd_wrapper_preserves_arguments_and_exit_status() {
use std::os::windows::process::CommandExt;
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("程序 & 100% !special!.exe");
let source = dir.path().join("helper.rs");
std::fs::write(
&source,
r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#,
)
.unwrap();
let compiled = std::process::Command::new("rustc")
.args(["--crate-name", "launcher_test"])
.arg(&source)
.arg("-o")
.arg(&exe)
.output()
.unwrap();
assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr));
let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap();
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
let output = std::process::Command::new("cmd.exe")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n");
let check = dir.path().join("check.cmd");
std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap();
let output = std::process::Command::new("cmd.exe")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\"\"", check.display()))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
let app = root.join("OpenIDEA.app");
let exe = app.join("Contents/MacOS/openidea");
let launcher = app.join(LAUNCHER);
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
std::fs::write(&exe, "").unwrap();
std::fs::write(&launcher, "").unwrap();
(exe, launcher)
}
#[test]
fn finds_the_launcher_of_a_bundle_only() {
let dir = tempfile::tempdir().unwrap();
let (exe, launcher) = bundle(dir.path());
assert_eq!(bundled_launcher(&exe), Some(launcher));
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
}
#[test]
fn prefers_local_bin_on_path() {
let home = Path::new("/Users/u");
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
}
#[test]
fn install_replaces_its_own_link_but_not_other_files() {
let dir = tempfile::tempdir().unwrap();
let (_, launcher) = bundle(dir.path());
let bin = dir.path().join("bin");
let link = install(&launcher, &bin).unwrap();
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
assert_eq!(install(&launcher, &bin).unwrap(), link);
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
assert!(installed(std::slice::from_ref(&bin)).is_empty());
std::fs::write(&link, "someone else's").unwrap();
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn quoting() {
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
}
}
+72
View File
@@ -0,0 +1,72 @@
//! The `openidea` command.
//!
//! The command is the app's own executable run in CLI mode: it parses its
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
//! script into a directory on `PATH`.
pub mod args;
pub mod install;
pub mod socket;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Bumped when [`Request`] changes incompatibly; an app still running an older
/// version answers with an error instead of misreading it.
pub const PROTOCOL_VERSION: u32 = 1;
/// A file or folder to open, with an optional 1-based position in a file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Target {
/// Absolute.
pub path: PathBuf,
pub line: Option<u32>,
pub column: Option<u32>,
}
/// What the command asks the running app to do. No targets: bring the app to the front.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Request {
pub version: u32,
pub targets: Vec<Target>,
#[serde(default)]
pub diff: bool,
}
impl Request {
pub fn open(targets: Vec<Target>) -> Self {
Self { version: PROTOCOL_VERSION, targets, diff: false }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Response {
Ok,
Error { message: String },
}
/// A GUI subsystem executable inherits redirected handles, but needs its parent's
/// console for interactive help and errors. Batch launchers wait for this process.
#[cfg(windows)]
pub fn attach_console() {
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
use windows_sys::Win32::System::Console::{
ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
SetStdHandle,
};
// SAFETY: attaches only this CLI process, without allocating a new console.
unsafe {
let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| {
let handle = GetStdHandle(kind);
(kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE))
});
AttachConsole(ATTACH_PARENT_PROCESS);
for (kind, handle, keep) in redirected {
if keep {
SetStdHandle(kind, handle);
}
}
}
}
+266
View File
@@ -0,0 +1,266 @@
//! The local socket the running app listens on: one JSON [`Request`] line in,
//! one [`Response`] line out. Unix domain sockets or Windows named pipes.
use crate::{Request, Response};
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(10);
const MAX_MESSAGE: u64 = 1024 * 1024;
fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response {
match serde_json::from_str::<Request>(line) {
Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => {
handle(request)
}
Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() },
Err(e) => Response::Error { message: format!("invalid request: {e}") },
}
}
/// The socket of the app with bundle identifier `identifier`. The name is a hash:
/// socket paths are limited to about 100 bytes, and development identifiers are long.
pub fn socket_path(identifier: &str) -> Option<PathBuf> {
if cfg!(windows) {
let user = dirs::data_local_dir()?;
return Some(PathBuf::from(format!(
r"\\.\pipe\openidea-{:016x}",
fnv1a(&format!("{}:{identifier}", user.display()))
)));
}
let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) };
Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier))))
}
fn fnv1a(s: &str) -> u64 {
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
}
#[cfg(unix)]
mod imp {
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
let stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
write_line(&stream, request)?;
let mut reply = String::new();
BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?;
Ok(serde_json::from_str(&reply)?)
}
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
let listener = bind(socket)?;
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
for stream in listener.incoming().flatten() {
if let Err(e) = serve_one(&stream, &handle) {
tracing::warn!("cli request failed: {e}");
}
}
})?;
Ok(())
}
/// Binds `socket`, replacing a stale file no one answers on.
fn bind(socket: &Path) -> io::Result<UnixListener> {
if let Some(dir) = socket.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = match UnixListener::bind(socket) {
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
return Err(e);
}
std::fs::remove_file(socket)?;
UnixListener::bind(socket)?
}
bound => bound?,
};
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
Ok(listener)
}
fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> {
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut line = String::new();
BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?;
write_line(stream, &answer(&line, handle))
}
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
let mut line = serde_json::to_string(value)?;
line.push('\n');
stream.write_all(line.as_bytes())
}
}
#[cfg(windows)]
mod imp {
use super::*;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
fn runtime() -> io::Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread().enable_all().build()
}
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
runtime()?.block_on(async {
tokio::time::timeout(TIMEOUT, async {
let mut pipe = loop {
match ClientOptions::new().open(socket) {
Ok(pipe) => break pipe,
// All existing instances are busy; wait instead of starting another app.
Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await,
Err(e) => return Err(e),
}
};
let mut line = serde_json::to_vec(request)?;
line.push(b'\n');
pipe.write_all(&line).await?;
let mut reply = String::new();
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?;
pipe.write_all(b"\n").await?;
Ok(serde_json::from_str(&reply)?)
})
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))?
})
}
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
let socket = socket.to_owned();
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
std::thread::Builder::new().name("cli-pipe".into()).spawn(move || {
let runtime = match runtime() {
Ok(runtime) => runtime,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
runtime.block_on(async move {
let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) {
Ok(pipe) => pipe,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
let _ = ready.send(Ok(()));
loop {
if let Err(e) = pipe.connect().await {
tracing::warn!("CLI pipe connection failed: {e}");
return;
}
// Keep an instance bound while answering, so another process cannot take the name.
let next = match ServerOptions::new().create(&socket) {
Ok(next) => next,
Err(e) => {
tracing::warn!("cannot create CLI pipe: {e}");
return;
}
};
let reply = tokio::time::timeout(TIMEOUT, async {
let mut line = String::new();
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?;
let mut reply = serde_json::to_vec(&answer(&line, &handle))?;
reply.push(b'\n');
pipe.write_all(&reply).await?;
// Wait until the client has drained the reply before closing its instance.
pipe.read_exact(&mut [0u8; 1]).await.map(|_| ())
})
.await;
if !matches!(reply, Ok(Ok(()))) {
tracing::warn!("CLI pipe request failed: {reply:?}");
}
// Dropping after the write lets the client drain its reply before disconnecting.
drop(pipe);
pipe = next;
}
});
})?;
receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))?
}
}
/// Sends `request` to the app listening on `socket` and waits for its answer.
pub use imp::send;
/// Answers requests on `socket` with `handle`, on a thread of its own.
pub use imp::serve;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socket_names_separate_identifiers_and_stay_short() {
let release = socket_path("ai.l0ng.openidea").unwrap();
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
assert_ne!(release, dev);
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
}
#[test]
fn answers_only_its_protocol_version() {
let ok = |_: Request| Response::Ok;
assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok);
let old = r#"{"version":0,"targets":[]}"#;
assert!(matches!(answer(old, &ok), Response::Error { .. }));
assert!(matches!(answer("garbage", &ok), Response::Error { .. }));
}
#[cfg(windows)]
#[test]
fn named_pipe_round_trip() {
let socket = socket_path(&format!("test-{}", std::process::id())).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
serve(&socket, move |request| {
tx.send(request).unwrap();
Response::Ok
})
.unwrap();
for _ in 0..2 {
let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) };
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]);
}
assert!(serve(&socket, |_| Response::Ok).is_err());
}
#[cfg(unix)]
#[test]
fn request_round_trip_and_stale_socket() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
// A file left by an app that exited without removing it.
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
serve(&socket, move |request| {
tx.send(request).unwrap();
Response::Ok
})
.unwrap();
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
assert_eq!(rx.recv().unwrap().targets, vec![target]);
// A live server keeps its socket.
assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse);
}
#[cfg(unix)]
#[test]
fn refuses_to_remove_an_unrelated_file() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
std::fs::write(&socket, "keep").unwrap();
assert!(serve(&socket, |_| Response::Ok).is_err());
assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep");
}
}
+2
View File
@@ -214,6 +214,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
cmd,
"app_info"
| "app_build_info"
| "app_cli_install"
| "app_cli_uninstall"
| "app_log_location"
| "app_check_update"
| "app_startup_update_check"
+2
View File
@@ -33,6 +33,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
for command in [
"app_info",
"app_build_info",
"app_cli_install",
"app_cli_uninstall",
"app_log_location",
"app_check_update",
"app_startup_update_check",
+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",
+41
View File
@@ -0,0 +1,41 @@
# Command-line launcher
Install the `openidea` command from **OpenIDEA → Install 'openidea' Command** on
macOS, or **Tools → Install 'openidea' Command** on Windows and Linux. The same
action is available in Find Action. Open a new terminal after installation.
- **macOS:** move OpenIDEA to Applications first. The command links to the launcher
inside the app bundle. It uses `~/.local/bin` when that directory is on PATH;
otherwise it uses `/usr/local/bin` and may request an administrator password.
- **Windows:** installation creates `openidea.cmd` in
`%LOCALAPPDATA%\OpenIDEA\bin` and adds that directory to the user PATH. This
works with installed and portable copies, without administrator rights.
- **Linux:** installation creates `~/.local/bin/openidea`. If another command
takes precedence or this directory is absent from PATH, the notification asks
you to put it at the beginning of your shell's PATH. For Bash and Zsh, add
`export PATH="$HOME/.local/bin:$PATH"` to your shell profile. For Fish, run
`fish_add_path --prepend ~/.local/bin`. An AppImage launcher targets the original
AppImage; keep that file in a permanent location.
```sh
openidea .
openidea src/main.rs README.md
openidea --line 12 --column 5 src/main.rs
openidea src/main.rs:12:5
openidea diff before.txt after.txt
openidea --help
openidea --version
```
Folders open as projects. Files open in their project's window, or in LightEdit
when no project contains them. Lines and columns start at 1. With no paths, the
command focuses OpenIDEA. It starts the app if necessary and returns after handing
off the request. Unknown options and missing files exit with code 1.
Use `--` before a filename beginning with `-`, or before a file named `diff`.
The existing GUI executable and OS file associations still work.
**Uninstall 'openidea' Command** removes only OpenIDEA's launcher, and on Windows
its dedicated user PATH entry. A command belonging to another application is
never overwritten or removed. Reinstall the command after moving a portable
copy or AppImage to a different location.
+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);
+32
View File
@@ -0,0 +1,32 @@
import { expect, openFromTree, test } from "./fixtures";
// A JSX comment is `{/* … */}` among JSX children, while its own text parses as
// JavaScript, where the tokens are `/* … */`. Toggling must still take it back out.
const SRC = `export function App() {
return (
<div>
<b>x</b>
</div>
);
}
`;
test("⌘/ twice on a JSX line gives the line back", async ({ workbench: page }) => {
await page.evaluate(async (src) => {
const backendModule = "/src/editor/core/backend.ts";
const mod = await import(/* @vite-ignore */ backendModule);
const backend = mod.docBackend() as { files: Map<string, string> };
backend.files.set("/dev/acme-api/App.tsx", src);
}, SRC);
const editor = await openFromTree(page, "App.tsx");
const content = editor.locator(".cm-content");
await editor.locator(".cm-line", { hasText: "<b>x</b>" }).click();
await page.keyboard.press("Meta+/");
await expect(content).toContainText("{/* <b>x</b> */}");
await page.keyboard.press("Meta+/");
await expect(content).not.toContainText("/*");
await expect(editor.locator(".cm-line", { hasText: "<b>x</b>" })).toHaveText("<b>x</b>");
});
+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);
}
});
}
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from "./fixtures";
for (const activation of ["double-click", "Enter"] as const) {
test(`Keymap ${activation} offers mouse and keyboard shortcuts`, async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
await settings.getByRole("treeitem", { name: "Keymap", exact: true }).click();
await settings.getByRole("textbox", { name: "Search actions", exact: true }).fill("Find Action");
const row = settings.getByRole("tree", { name: "Actions" }).getByRole("treeitem", { name: /Find Action/ });
const activate = async () => {
if (activation === "double-click") await row.dblclick();
else {
await row.click();
await page.keyboard.press("Enter");
}
};
await activate();
const keyboard = page.getByRole("menuitem", { name: "Add Keyboard Shortcut", exact: true });
await expect(keyboard).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(page.getByRole("menuitem", { name: "Add Mouse Shortcut", exact: true })).toBeFocused();
await page.keyboard.press("Enter");
const mouseDialog = page.getByRole("dialog", { name: "Mouse Shortcut: Find Action…", exact: true });
await mouseDialog.getByRole("textbox", { name: "Mouse shortcut" }).click({ modifiers: ["Alt"] });
await expect(mouseDialog.getByRole("button", { name: "OK", exact: true })).toBeEnabled();
await mouseDialog.getByRole("button", { name: "OK", exact: true }).click();
await page
.getByRole("dialog", { name: "Warning", exact: true })
.getByRole("button", { name: "Leave", exact: true })
.click();
await expect(row).toContainText("Click");
await activate();
await keyboard.click();
const keyboardDialog = page.getByRole("dialog", { name: "Keyboard Shortcut: Find Action…", exact: true });
await expect(keyboardDialog.getByRole("textbox", { name: "First stroke" })).toBeFocused();
await keyboardDialog.getByRole("button", { name: "Cancel", exact: true }).click();
await row.click({ button: "right" });
await expect(keyboard).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Add Mouse Shortcut", exact: true })).toBeVisible();
});
}
+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);
}
});
}
+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`.
+3 -2
View File
@@ -37,6 +37,7 @@ tracing-subscriber.workspace = true
dirs.workspace = true
ide-core.workspace = true
ide-cli.workspace = true
dunce.workspace = true
ide-clipboard.workspace = true
ide-fonts.workspace = true
@@ -82,8 +83,8 @@ block2 = "0.6"
# Plugin secrets in the login keychain.
security-framework = "3.7"
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSEvent", "NSGraphics", "NSResponder", "NSScreen", "NSToolbar", "NSView", "NSWindow", "NSWorkspace"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSEvent", "NSGraphics", "NSResponder", "NSScreen", "NSTitlebarAccessoryViewController", "NSToolbar", "NSView", "NSViewController", "NSWindow", "NSWindowTabGroup", "NSWorkspace"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString"] }
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# The `openidea` command, linked onto PATH by "Install 'openidea' Command".
# Runs the app executable of this bundle in CLI mode (src-tauri/src/cli.rs).
self=$0
while [ -L "$self" ]; do
link=$(readlink "$self")
case $link in
/*) self=$link ;;
*) self=$(dirname "$self")/$link ;;
esac
done
contents=$(cd "$(dirname "$self")/../.." && pwd -P) || exit 1
exec "$contents/MacOS/openidea" --cli "$@"
+169
View File
@@ -0,0 +1,169 @@
//! The `openidea` command: this executable in CLI mode (started by the bundle's
//! launcher script with [`CLI_FLAG`]), the socket the app answers it on, and
//! installing the command on `PATH`.
use ide_cli::args::{self, Command};
use ide_cli::{Request, Response, socket};
use ide_core::{IdeError, IdeResult, IpcPath};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tauri::AppHandle;
/// First argument from `Contents/Resources/bin/openidea`.
const CLI_FLAG: &str = "--cli";
/// How long a starting app has to open its socket.
const START_TIMEOUT: Duration = Duration::from_secs(30);
/// Runs as the `openidea` command when started with [`CLI_FLAG`]; returns the exit code.
pub fn run(identifier: &str) -> Option<i32> {
let mut args = std::env::args().skip(1);
if args.next().as_deref() != Some(CLI_FLAG) {
return None;
}
let args: Vec<String> = args.collect();
#[cfg(windows)]
ide_cli::attach_console();
Some(match client(identifier, &args) {
Ok(()) => 0,
Err(message) => {
eprintln!("openidea: {message}");
1
}
})
}
fn client(identifier: &str, args: &[String]) -> Result<(), String> {
let cwd = std::env::current_dir().map_err(|e| format!("no working directory: {e}"))?;
let command = args::parse(args, &cwd)?;
let diff = matches!(command, Command::Diff(_));
let targets = match command {
Command::Help => {
print!("{}", args::USAGE);
return Ok(());
}
Command::Version => {
println!("OpenIDEA {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
Command::Open(targets) | Command::Diff(targets) => targets,
};
let socket = socket::socket_path(identifier).ok_or("no cache directory for the OpenIDEA socket")?;
let request = Request { diff, ..Request::open(targets) };
let response = match socket::send(&socket, &request) {
Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::ConnectionRefused) => {
start_app()?;
send_when_started(&socket, &request)?
}
sent => sent.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?,
};
match response {
Response::Ok => Ok(()),
Response::Error { message } => Err(message),
}
}
/// Starts the app detached from this terminal: through Launch Services for a
/// bundle, else (a development build) this executable without [`CLI_FLAG`].
fn start_app() -> Result<(), String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
let exe = ide_cli::install::stable_executable(&exe, image.as_deref()).map_err(|e| e.to_string())?;
let mut command = match app_bundle(&exe) {
Some(bundle) => {
let mut open = std::process::Command::new("/usr/bin/open");
open.arg("-a").arg(bundle);
open
}
None => std::process::Command::new(&exe),
};
command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
ide_core::process::host_env(&mut command);
#[cfg(unix)]
std::os::unix::process::CommandExt::process_group(&mut command, 0);
#[cfg(windows)]
std::os::windows::process::CommandExt::creation_flags(&mut command, 0x0000_0008 | 0x0000_0200);
command.spawn().map(drop).map_err(|e| format!("cannot start OpenIDEA: {e}"))
}
fn send_when_started(socket: &Path, request: &Request) -> Result<Response, String> {
let start = Instant::now();
loop {
match socket::send(socket, request) {
Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::ConnectionRefused) => {
if start.elapsed() > START_TIMEOUT {
return Err("OpenIDEA did not start in time".into());
}
std::thread::sleep(Duration::from_millis(100));
}
sent => return sent.map_err(|e| format!("cannot reach OpenIDEA: {e}")),
}
}
}
/// `….app` of the bundle executable `exe` (`….app/Contents/MacOS/openidea`).
fn app_bundle(exe: &Path) -> Option<PathBuf> {
let bundle = exe.parent()?.parent()?.parent()?;
(cfg!(target_os = "macos") && bundle.extension().is_some_and(|e| e == "app")).then(|| bundle.to_path_buf())
}
/// Answers the `openidea` command for this app: its targets open like paths from Finder.
pub fn serve(app: &AppHandle, identifier: &str) {
let Some(socket) = socket::socket_path(identifier) else { return };
let app = app.clone();
let served = socket::serve(&socket, move |request| {
let handle = app.clone();
match app.run_on_main_thread(move || {
if request.diff {
let mut targets = request.targets.into_iter();
if let (Some(left), Some(right)) = (targets.next(), targets.next()) {
crate::windows::open_diff_files(
&handle,
crate::windows::DiffFilesLaunch { left: left.path.into(), right: right.path.into() },
);
}
} else {
crate::windows::open_targets(&handle, request.targets);
}
}) {
Ok(()) => Response::Ok,
Err(e) => Response::Error { message: format!("OpenIDEA cannot open windows: {e}") },
}
});
if let Err(e) = served {
tracing::warn!("cannot listen for the openidea command on {}: {e}", socket.display());
}
}
fn install_error(e: std::io::Error) -> IdeError {
if e.kind() == ErrorKind::Interrupted { IdeError::cancelled() } else { IdeError::internal(e.to_string()) }
}
/// Links the `openidea` command onto `PATH`; returns the link. Blocks while the
/// administrator password prompt is up.
pub fn install() -> IdeResult<crate::commands::window::CliInstallation> {
let result = ide_cli::install::install_current().map_err(install_error)?;
Ok(crate::commands::window::CliInstallation {
path: result.path.into(),
path_directory: result.path_directory.map(Into::into),
})
}
/// Removes every installed `openidea` command link; returns them.
pub fn uninstall() -> IdeResult<Vec<IpcPath>> {
let links = ide_cli::install::uninstall_current().map_err(install_error)?;
Ok(links.into_iter().map(Into::into).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundle_of_the_app_executable() {
let exe = Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea");
let expected = cfg!(target_os = "macos").then(|| PathBuf::from("/Applications/OpenIDEA.app"));
assert_eq!(app_bundle(exe), expected);
assert_eq!(app_bundle(Path::new("/repo/target/debug/openidea")), None);
}
}
+2 -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,
@@ -446,6 +445,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
window::menu_model,
window::menu_set_disabled,
window::app_log_location,
window::app_cli_install,
window::app_cli_uninstall,
window::app_build_info,
window::app_check_update,
window::app_startup_update_check,
+35 -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.
@@ -310,6 +313,27 @@ pub fn app_log_location() -> IdeResult<LogLocation> {
Ok(LogLocation { dir: dir.into(), file: crate::logging::latest_log(dir).map(Into::into) })
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CliInstallation {
pub path: ide_core::IpcPath,
pub path_directory: Option<ide_core::IpcPath>,
}
/// Installs the `openidea` command for this user.
#[tauri::command]
#[specta::specta]
pub async fn app_cli_install() -> IdeResult<CliInstallation> {
tauri::async_runtime::spawn_blocking(crate::cli::install).await.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Removes the installed `openidea` command; returns the links removed.
#[tauri::command]
#[specta::specta]
pub async fn app_cli_uninstall() -> IdeResult<Vec<ide_core::IpcPath>> {
tauri::async_runtime::spawn_blocking(crate::cli::uninstall).await.map_err(|e| IdeError::internal(e.to_string()))?
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BuildInfo {
-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<()> {
+17 -2
View File
@@ -4,6 +4,7 @@
//! adapter from IPC to a backend crate; no domain logic lives here.
mod application_focus;
mod cli;
mod commands;
#[cfg(debug_assertions)]
mod dev;
@@ -72,6 +73,10 @@ pub fn run() {
let context = tauri::generate_context!();
#[cfg(debug_assertions)]
let context = dev::configure(context);
// The `openidea` command: answer and exit, no UI.
if let Some(code) = cli::run(&context.config().identifier) {
std::process::exit(code);
}
// Before the first window: one taskbar identity for every launch path.
jump_list::set_app_id(&context.config().identifier);
logging::init(&context.config().identifier);
@@ -190,8 +195,13 @@ pub fn run() {
#[cfg(target_os = "macos")]
macos::intercept_system_quit(app.handle());
#[cfg(target_os = "macos")]
if let Some(mtm) = objc2::MainThreadMarker::new() {
macos::prepare_window_tabbing(mtm);
}
#[cfg(target_os = "macos")]
application_focus::observe(app.handle());
app.manage(menu_state);
cli::serve(app.handle(), &app.config().identifier);
if let Some(paths) = setup_opens.lock().take() {
windows::open_paths_at_launch(app.handle(), paths);
}
@@ -201,8 +211,13 @@ pub fn run() {
Ok(())
})
.on_window_event(|window, event| match event {
#[cfg(target_os = "linux")]
tauri::WindowEvent::CloseRequested { .. } => linux_window_state::save_before_close(window.app_handle()),
tauri::WindowEvent::CloseRequested { .. } => {
#[cfg(target_os = "linux")]
linux_window_state::save_before_close(window.app_handle());
if let Some(w) = window.try_state::<WindowsState>() {
w.close_requested(window.label());
}
}
tauri::WindowEvent::Focused(true) => {
application_focus::changed(window.app_handle(), true);
if let Some(w) = window.try_state::<WindowsState>() {
+59 -350
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)
}
+41 -34
View File
@@ -191,6 +191,9 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("app.about", "About OpenIDEA"),
act("app.checkForUpdates", "Check for Updates…"),
SEP,
act("app.installCli", "Install 'openidea' Command"),
act("app.uninstallCli", "Uninstall 'openidea' Command"),
SEP,
act("file.settings", "Settings…"),
SEP,
Entry::Native(Native::Services),
@@ -702,41 +705,45 @@ pub fn spec(os: Os) -> Vec<Entry> {
],
));
menus.push(sub(
"tools",
"Tools",
vec![
act("http.newRequest", "New HTTP Request"),
act("http.showToolWindow", "HTTP Client"),
let mut tools = vec![
act("http.newRequest", "New HTTP Request"),
act("http.showToolWindow", "HTTP Client"),
SEP,
act("db.showToolWindow", "Database"),
act("db.newDataSource", "New Data Source…"),
act("db.openConsole", "Open Query Console"),
SEP,
act("ssh.startSession", "Start SSH Session…"),
act("ssh.configurations", "SSH Configurations…"),
sub(
"deployment",
"Deployment",
vec![
act("deployment.uploadToDefault", "Upload to Default Server"),
act("deployment.uploadTo", "Upload to…"),
act("deployment.downloadFromDefault", "Download from Default Server"),
act("deployment.downloadFrom", "Download from…"),
act("deployment.syncWith", "Sync with Deployed…"),
act("deployment.deleteFromServer", "Delete from Server"),
SEP,
act("deployment.toggleAutoUpload", "Automatic Upload"),
act("deployment.browseRemoteHost", "Browse Remote Host"),
act("deployment.openInBrowser", "Open in Browser"),
act("deployment.configure", "Configuration…"),
],
),
SEP,
act("vim.toggle", "Vim"),
Entry::Dynamic(DYNAMIC_PLUGIN_TOOLS),
];
if !mac {
tools.extend([
SEP,
act("db.showToolWindow", "Database"),
act("db.newDataSource", "New Data Source…"),
act("db.openConsole", "Open Query Console"),
SEP,
act("ssh.startSession", "Start SSH Session…"),
act("ssh.configurations", "SSH Configurations…"),
sub(
"deployment",
"Deployment",
vec![
act("deployment.uploadToDefault", "Upload to Default Server"),
act("deployment.uploadTo", "Upload to…"),
act("deployment.downloadFromDefault", "Download from Default Server"),
act("deployment.downloadFrom", "Download from…"),
act("deployment.syncWith", "Sync with Deployed…"),
act("deployment.deleteFromServer", "Delete from Server"),
SEP,
act("deployment.toggleAutoUpload", "Automatic Upload"),
act("deployment.browseRemoteHost", "Browse Remote Host"),
act("deployment.openInBrowser", "Open in Browser"),
act("deployment.configure", "Configuration…"),
],
),
SEP,
act("vim.toggle", "Vim"),
Entry::Dynamic(DYNAMIC_PLUGIN_TOOLS),
],
));
act("app.installCli", "Install 'openidea' Command"),
act("app.uninstallCli", "Uninstall 'openidea' Command"),
]);
}
menus.push(sub("tools", "Tools", tools));
let mut window = Vec::new();
if mac {
+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);
}
}
+57 -10
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);
@@ -1129,19 +1152,43 @@ pub fn on_second_instance(app: &AppHandle, args: Vec<String>, cwd: String) {
remember_cli_position(&app.state::<WindowsState>(), &args, &path);
open_path(app, &path)
}
None => {
if let Some(w) = target_window(app) {
focus(&w);
} else {
spawn_window(app, None);
}
}
None => focus_any(app),
}
}
/// Focuses the window the user last looked at, else opens a new one.
fn focus_any(app: &AppHandle) {
if let Some(w) = target_window(app) {
focus(&w);
} else {
spawn_window(app, None);
}
}
/// Opens what the `openidea` command asked for, like [`open_paths`]; files open at their
/// 1-based line and column. Without targets, focuses a window.
pub fn open_targets(app: &AppHandle, targets: Vec<ide_cli::Target>) {
if targets.is_empty() {
return focus_any(app);
}
let windows = app.state::<WindowsState>();
let paths = targets
.into_iter()
.map(|t| {
if let Some(line) = t.line
&& !t.path.is_dir()
{
windows.set_position(&t.path, (line.saturating_sub(1), t.column.map_or(0, |c| c.saturating_sub(1))));
}
t.path
})
.collect();
open_paths(app, paths);
}
/// `openidea diff a.txt b.txt` while the app is running: hands the pair to the window the user is
/// looking at (a project window, never an auxiliary one), else opens a new window for it.
fn open_diff_files(app: &AppHandle, diff: DiffFilesLaunch) {
pub(crate) fn open_diff_files(app: &AppHandle, diff: DiffFilesLaunch) {
let launch = Launch { diff: Some(Box::new(diff)), ..Default::default() };
let target = target_window(app)
.filter(|window| auxiliary_parent(window.label()).is_none())
+1 -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",
+5
View File
@@ -1,5 +1,10 @@
{
"bundle": {
"macOS": {
"files": {
"Resources/bin/openidea": "./cli/openidea"
}
},
"fileAssociations": [
{
"name": "Text and Source Code",
+10 -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.
@@ -987,6 +979,10 @@ export const commands = {
*/
menuSetDisabled: (ids: string[]) => __TAURI_INVOKE<void>("menu_set_disabled", { ids }),
appLogLocation: () => typedError<LogLocation, IdeError>(__TAURI_INVOKE("app_log_location")),
/** Installs the `openidea` command for this user. */
appCliInstall: () => typedError<CliInstallation, IdeError>(__TAURI_INVOKE("app_cli_install")),
/** Removes the installed `openidea` command; returns the links removed. */
appCliUninstall: () => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("app_cli_uninstall")),
appBuildInfo: () => __TAURI_INVOKE<BuildInfo>("app_build_info"),
appCheckUpdate: (source: UpdateSource) => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update", { source })),
/**
@@ -2292,6 +2288,11 @@ export type ClearCachesResult = {
rssAfterBytes: number | null,
};
export type CliInstallation = {
path: IpcPath,
pathDirectory: IpcPath | null,
};
export type CloseContext = {
/** Quit is in progress (exit was already confirmed). */
quitting: boolean,
+11 -2
View File
@@ -1,7 +1,7 @@
/** Editor actions (ids from keymaps/intellij-macos.ts). */
import { EditorSelection, type EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { indentSelection, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands";
import { indentSelection, moveLineDown, moveLineUp } from "@codemirror/commands";
import { foldEffect, syntaxTree, toggleFold } from "@codemirror/language";
import { acceptCompletion, completionStatus } from "@codemirror/autocomplete";
import { executeAction, registerActions, type Action } from "@/platform/actions";
@@ -9,6 +9,7 @@ import { getContext } from "@/platform/context";
import { editorController, useGotoLine, useTabs } from "./controller";
import {
addCaretsToLineEnds,
commentBlock,
commentLine,
completeStatement,
deleteLine,
@@ -661,7 +662,7 @@ export function registerEditorActions(): () => void {
"color scheme",
]),
text("code.commentLine", "Comment with Line Comment", (v) => commentLine(v)),
text("code.commentBlock", "Comment with Block Comment", (v) => toggleBlockComment(v)),
text("code.commentBlock", "Comment with Block Comment", (v) => commentBlock(v)),
text("code.duplicateLine", "Duplicate Line or Selection", (v) => duplicateLineOrSelection(v)),
text("code.deleteLine", "Delete Line", (v) => deleteLine(v)),
text("code.moveLineUp", "Move Line Up", (v) => moveLineUp(v)),
@@ -778,6 +779,14 @@ export function registerEditorActions(): () => void {
text("code.autoIndentLines", "Auto-Indent Lines", (v) => !v.state.readOnly && indentSelection(v), {
keywords: ["reindent", "fix indentation"],
}),
{
id: "editor.addOrRemoveCaret",
title: "Add or Remove Caret",
category: "Editor Actions",
when: "textEditorActive",
// The mouse selection style handles the pointer position and dragging.
run: () => {},
},
text("editor.cloneCaretAbove", "Clone Caret Above", (v) => cloneCaretAbove(v), { category: "Editor Actions" }),
text("editor.cloneCaretBelow", "Clone Caret Below", (v) => cloneCaretBelow(v), { category: "Editor Actions" }),
text("editor.matchBrace", "Move Caret to Matching Brace", (v) => cursorMatchBrace(v), {
+162 -3
View File
@@ -1,5 +1,6 @@
/**
* Comment with Line Comment and Enter inside a line comment, as in IDEA.
* Comment with Line Comment, Comment with Block Comment and Enter inside a
* line comment, as in IDEA.
*/
import {
EditorSelection,
@@ -11,7 +12,7 @@ import {
} from "@codemirror/state";
import type { Command, EditorView } from "@codemirror/view";
import { indentString, syntaxTree } from "@codemirror/language";
import { toggleComment } from "@codemirror/commands";
import { toggleBlockComment, toggleComment } from "@codemirror/commands";
import { offsetAtVisibleColumn, visibleColumn } from "./visibleColumn";
/** Code Style › <Language> › Code Generation › Comment Code. */
@@ -76,7 +77,7 @@ export const commentLine: Command = (view) => {
lineToken(state, doc.line(b.startLine).from + leadingWs(doc.line(b.startLine).text)),
);
// Languages without line comments (CSS, HTML…): block comments.
if (tokens.some((t) => !t)) return toggleComment(view);
if (tokens.some((t) => !t)) return blockCommentLines(view);
let allCommented = true;
let commentedNonEmpty = false;
@@ -195,6 +196,164 @@ function minIndent(state: EditorState, startLine: number, endLine: number, token
return min ?? 0;
}
// --- Block comments ---------------------------------------------------------------
interface CommentPair {
open: string;
close: string;
}
/** The block comment token pairs the language offers at `pos`, the more specific first. */
function commentPairs(state: EditorState, pos: number): CommentPair[] {
const pairs: CommentPair[] = [];
for (const data of state.languageDataAt<{ block?: CommentPair }>("commentTokens", pos)) {
const block = data?.block;
if (!block?.open || !block.close) continue;
if (!pairs.some((p) => p.open === block.open && p.close === block.close)) pairs.push(block);
}
return pairs;
}
const BLOCK_SEARCH = 50;
/** A block comment around a range: the tokens' own positions, and how much of their margin to take. */
interface BlockComment {
open: number;
openMargin: number;
close: number;
closeMargin: number;
}
/**
* The comment `pair` around `from..to`: the tokens just outside the range, or
* just inside it when the range takes the tokens in itself.
*/
function findBlockComment(state: EditorState, pair: CommentPair, from: number, to: number): BlockComment | null {
const doc = state.doc;
const before = state.sliceDoc(Math.max(0, from - BLOCK_SEARCH), from);
const after = state.sliceDoc(to, Math.min(doc.length, to + BLOCK_SEARCH));
const spaceBefore = /\s*$/.exec(before)![0].length;
const spaceAfter = /^\s*/.exec(after)![0].length;
const start = before.length - spaceBefore;
if (
before.slice(start - pair.open.length, start) === pair.open &&
after.slice(spaceAfter, spaceAfter + pair.close.length) === pair.close
)
return {
open: from - spaceBefore,
openMargin: spaceBefore ? 1 : 0,
close: to + spaceAfter,
closeMargin: spaceAfter ? 1 : 0,
};
const inside = to - from <= 2 * BLOCK_SEARCH;
const head = state.sliceDoc(from, inside ? to : from + BLOCK_SEARCH);
const tail = inside ? head : state.sliceDoc(Math.max(from, to - BLOCK_SEARCH), to);
const headSpace = /^\s*/.exec(head)![0].length;
const tailSpace = /\s*$/.exec(tail)![0].length;
const end = tail.length - tailSpace - pair.close.length;
if (
head.slice(headSpace, headSpace + pair.open.length) === pair.open &&
tail.slice(end, end + pair.close.length) === pair.close
)
return {
open: from + headSpace + pair.open.length,
openMargin: /\s/.test(head.charAt(headSpace + pair.open.length)) ? 1 : 0,
close: to - tailSpace - pair.close.length,
closeMargin: /\s/.test(tail.charAt(end - 1)) ? 1 : 0,
};
return null;
}
/** The lines the selection touches, from their first non-blank column to their end. */
function selectedLines(state: EditorState): { from: number; to: number }[] {
const ranges: { from: number; to: number }[] = [];
const doc = state.doc;
for (const r of state.selection.ranges) {
const firstLine = doc.lineAt(r.from);
let lastLine = r.to <= firstLine.to ? firstLine : doc.lineAt(r.to);
if (lastLine.from > firstLine.from && lastLine.from === r.to)
lastLine = r.to === firstLine.to + 1 ? firstLine : doc.lineAt(r.to - 1);
const last = ranges[ranges.length - 1];
if (last && last.to > firstLine.from) last.to = lastLine.to;
else ranges.push({ from: firstLine.from + leadingWs(firstLine.text), to: lastLine.to });
}
return ranges;
}
/**
* Removes the block comment wrapping every one of `ranges`; false when one of
* them is not commented (the caller then comments instead).
*
* The pair to look for is read at both ends of a range and past its last line,
* and just outside matching tokens. A comment can span syntax contexts:
* a JSX `{/* … *\/}` is the extreme case — it sits in JSX children,
* while its own text parses as JavaScript, where the tokens are `/* … *\/`. The
* widest pair wins when several fit, so the outer comment is the one removed.
*/
function uncommentBlock(view: EditorView, ranges: { from: number; to: number }[]): boolean {
const { state } = view;
const doc = state.doc;
const comments = ranges.map((r) => {
const pairs = commentPairs(state, r.from);
for (const pos of [r.to, doc.lineAt(r.to).to])
for (const pair of commentPairs(state, pos))
if (!pairs.some((p) => p.open === pair.open && p.close === pair.close)) pairs.push(pair);
let best: { pair: CommentPair; comment: BlockComment } | null = null;
for (const pair of pairs) {
const comment = findBlockComment(state, pair, r.from, r.to);
if (comment)
for (const pos of [
Math.max(0, comment.open - pair.open.length - 1),
Math.min(doc.length, comment.close + pair.close.length + 1),
])
for (const outer of commentPairs(state, pos))
if (!pairs.some((p) => p.open === outer.open && p.close === outer.close)) pairs.push(outer);
if (comment && (!best || comment.open - pair.open.length < best.comment.open - best.pair.open.length))
best = { pair, comment };
}
return best;
});
// End tokens from separate comments must not be removed across live code.
for (const done of comments) {
if (!done) continue;
const { pair, comment } = done;
if (!state.sliceDoc(comment.open, comment.close).includes(pair.close)) continue;
let node = syntaxTree(state).resolveInner(comment.open, 1);
while (node.parent && !/comment/i.test(node.name)) node = node.parent;
// A parser-confirmed nested comment can legitimately contain closing tokens.
if (
!/comment/i.test(node.name) ||
node.from !== comment.open - pair.open.length ||
node.to !== comment.close + pair.close.length
)
return true;
}
if (comments.some((c) => !c)) return false;
const changes: ChangeSpec[] = [];
for (const done of comments) {
const { pair, comment } = done!;
changes.push(
{ from: comment.open - pair.open.length, to: comment.open + comment.openMargin },
{ from: comment.close - comment.closeMargin, to: comment.close + pair.close.length },
);
}
view.dispatch({ changes, scrollIntoView: true, userEvent: "delete.uncomment" });
return true;
}
/** ⌘⇧/: block comments the selection, or takes the block comment back out. */
export const commentBlock: Command = (view) => {
if (view.state.readOnly) return false;
const ranges = view.state.selection.ranges.map((r) => ({ from: r.from, to: r.to }));
return uncommentBlock(view, ranges) || toggleBlockComment(view);
};
/** ⌘/ in a language without line comments (CSS, HTML…): the block comment of the lines. */
function blockCommentLines(view: EditorView): boolean {
return uncommentBlock(view, selectedLines(view.state)) || toggleComment(view);
}
// --- Enter in a line comment ------------------------------------------------------
/** The start of the line comment the caret is in (after its prefix), else -1. */
+129 -1
View File
@@ -15,7 +15,7 @@ import {
} from "./editing";
import { batchLanguage, commentTokens, findLanguageDescription } from "./languages";
import { toggleCaret } from "./multiCaret";
import { enterInLineComment, lineCommentStyle } from "./comments";
import { commentBlock, enterInLineComment, lineCommentStyle } from "./comments";
import { languages } from "@codemirror/language-data";
/** Brace languages in miniature: one unit deeper after a line ending in `{`. */
@@ -386,3 +386,131 @@ describe("Batch language mode and @REM comment toggle", () => {
expect(has("labelName", ":sub")).toBe(true);
});
});
describe("toggle comment where the tokens depend on the syntax context", () => {
// A JSX comment is `{/* … */}` in JSX children, but its own text parses as
// JavaScript, where the tokens are `/* … */`.
const wrap = (body: string) => `const a = (\n <div>\n ${body}\n </div>\n);`;
it("takes a JSX line comment back out, with the caret", async () => {
const tsx = await languages.find((l) => l.name === "TSX")!.load();
const v = make(wrap("|</div>"), tsx);
commentLine(v);
expect(v.state.doc.line(3).text).toBe(" {/* </div> */}");
commentLine(v);
expect(show(v)).toBe(wrap("|</div>"));
});
it("takes a JSX line comment back out, with the line selected", async () => {
const tsx = await languages.find((l) => l.name === "TSX")!.load();
const v = make(wrap("[</div>]"), tsx);
commentLine(v);
expect(v.state.doc.line(3).text).toBe(" {/* </div> */}");
commentLine(v);
expect(v.state.doc.line(3).text).toBe(" </div>");
});
it("takes a JSX block comment back out after ⌘⇧/", async () => {
const tsx = await languages.find((l) => l.name === "TSX")!.load();
const v = make(wrap("[</div>]"), tsx);
commentBlock(v);
expect(v.state.doc.line(3).text).toBe(" {/* </div> */}");
commentBlock(v);
expect(show(v)).toBe(wrap("[</div>]"));
});
it("takes a block comment back out in a language with no line comment", async () => {
const html = await languages.find((l) => l.name === "HTML")!.load();
const v = make("<section>\n |<p>hi</p>\n</section>", html);
commentLine(v);
expect(v.state.doc.line(2).text).toBe(" <!-- <p>hi</p> -->");
commentLine(v);
expect(show(v)).toBe("<section>\n |<p>hi</p>\n</section>");
});
it("wraps several selected JSX lines once and unwraps them again", async () => {
const tsx = await languages.find((l) => l.name === "TSX")!.load();
const v = make("const a = (\n <div>\n [<b>x</b>\n <i>y</i>]\n </div>\n);", tsx);
commentBlock(v);
expect(v.state.doc.toString()).toContain("{/* <b>x</b>\n <i>y</i> */}");
commentBlock(v);
expect(v.state.doc.toString()).toBe("const a = (\n <div>\n <b>x</b>\n <i>y</i>\n </div>\n);");
});
});
describe("comment toggle regressions", () => {
const cases = [
["TSX", "const a = <div>\n [<b>x</b>\n <i>y</i>]\n</div>;"],
["TSX", "const a = <div>{\n [value +\n other]\n}</div>;"],
["TSX", "[const a = 1;\nconst b = 2;]"],
["HTML", "<section>\n [<b>x</b>\n <i>y</i>]\n</section>"],
["CSS", "[a { color: red; }\nb { color: blue; }]"],
["Vue", "<template>\n[<b>x</b>\n<i>y</i>]\n</template>"],
];
for (const [action, command] of [
["line", commentLine],
["block", commentBlock],
] as const) {
it.each(cases)(`${action} comments round trip in %s: %s`, async (language, input) => {
const ext = await languages.find((l) => l.name === language)!.load();
const v = make(input, ext);
const original = v.state.doc.toString();
expect(command(v)).toBe(true);
expect(v.state.doc.toString()).not.toBe(original);
expect(command(v)).toBe(true);
expect(v.state.doc.toString()).toBe(original);
});
it.each(["TSX", "HTML", "CSS", "Vue"])(`${action} comments round trip with multiple ranges in %s`, async (name) => {
const ext = await languages.find((l) => l.name === name)!.load();
let body = name === "CSS" ? "[a { color: red; }]\n[b { color: blue; }]" : "[<b>x</b>]\n[<i>y</i>]";
if (action === "line") body = body.replaceAll("[", "|").replaceAll("]", "");
const input = name === "TSX" ? `const a = <div>\n${body}\n</div>;` : body;
const v = make(input, ext);
const original = v.state.doc.toString();
expect(command(v)).toBe(true);
expect(command(v)).toBe(true);
expect(v.state.doc.toString()).toBe(original);
expect(v.state.selection.ranges).toHaveLength(2);
});
it.each([
["CSS", "/*", "*/"],
["JavaScript", "/*", "*/"],
["HTML", "<!--", "-->"],
["TSX", "{/*", "*/}"],
])(`${action} comments do not join separate comments across live code in %s`, async (name, open, close) => {
const ext = await languages.find((l) => l.name === name)!.load();
for (const body of [`[${open} a ${close} b ${open} c ${close}]`, `${open} [a ${close} b ${open} c] ${close}`]) {
const input = name === "TSX" ? `const a = <div>\n${body}\n</div>;` : body;
const v = make(input, ext);
const original = v.state.doc.toString();
if (name === "JavaScript" && action === "line") {
commentLine(v);
commentLine(v);
} else command(v);
expect(v.state.doc.toString()).toBe(original);
}
});
}
it("keeps a line-only language's block command unavailable", async () => {
const ext = await languages.find((l) => l.name === "Python")!.load();
const v = make("[a = 1\nb = 2]", ext);
const original = v.state.doc.toString();
expect(commentBlock(v)).toBe(false);
expect(v.state.doc.toString()).toBe(original);
expect(commentLine(v)).toBe(true);
expect(v.state.doc.toString()).not.toBe(original);
expect(commentLine(v)).toBe(true);
expect(v.state.doc.toString()).toBe(original);
});
it("uncomments a parser-confirmed nested block comment", async () => {
const ext = await languages.find((l) => l.name === "Rust")!.load();
const v = make("[/* a /* b */ c */]", ext);
expect(commentBlock(v)).toBe(true);
expect(v.state.doc.toString()).toBe("a /* b */ c");
});
});
+1 -1
View File
@@ -659,7 +659,7 @@ export function completeStatement(language: string): Command {
// --- comments ------------------------------------------------------------------------
export { commentLine } from "./comments";
export { commentBlock, commentLine } from "./comments";
// --- smart keys ------------------------------------------------------------------
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { PRESETS, useKeymap } from "@/platform/keymap";
import { usePlatform } from "@/platform/platformInfo";
import { multiCaretMouse } from "./multiCaret";
let view: EditorView;
const originalKeymap = useKeymap.getState();
const originalPlatform = usePlatform.getState();
const event = (init: MouseEventInit = {}) => new MouseEvent("mousedown", { button: 0, altKey: true, ...init });
const styles = (e: MouseEvent) => view.state.facet(EditorView.mouseSelectionStyle).map((style) => style(view, e));
beforeEach(() => {
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
usePlatform.setState({ os: "macos" });
view = new EditorView({
state: EditorState.create({
doc: "first\nsecond\nthird",
extensions: [EditorState.allowMultipleSelections.of(true), multiCaretMouse],
}),
parent: document.body,
});
vi.spyOn(view, "posAtCoords").mockImplementation((coords) => coords.x);
vi.spyOn(view, "coordsAtPos").mockReturnValue(null);
});
afterEach(() => {
view.destroy();
useKeymap.setState(originalKeymap);
usePlatform.setState(originalPlatform);
});
describe("caret mouse bindings", () => {
it.each(Object.keys(PRESETS))("%s keeps the default gesture", (preset) => {
useKeymap.setState({ preset });
expect(PRESETS[preset]!["editor.addOrRemoveCaret"]).toEqual(["Alt+Shift+Button1"]);
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
});
it("matches the complete stroke and only the left button", () => {
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
for (const init of [{}, { shiftKey: true, ctrlKey: true }, { shiftKey: true, metaKey: true }, { button: 1 }]) {
expect(styles(event(init))[0]).toBeNull();
}
});
it("uses rebinding immediately and yields column selection to the caret gesture", () => {
expect(styles(event())[1]).not.toBeNull();
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["Alt+Button1"] } });
expect(styles(event({ shiftKey: true }))[0]).toBeNull();
const [caret, column] = styles(event({ clientX: 2 }));
expect(column).toBeNull();
expect(caret!.get(event({ clientX: 2 }), false, false).ranges.map((r) => r.head)).toEqual([0, 2]);
const drag = caret!.get(event({ clientX: 8 }), false, false);
expect(drag.ranges.map((r) => [r.anchor, r.head])).toEqual([
[0, 0],
[2, 8],
]);
view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(2)]) });
const remove = styles(event({ clientX: 2 }))[0]!;
expect(remove.get(event({ clientX: 2 }), false, false).ranges.map((r) => r.head)).toEqual([0]);
});
it("honours removal and restores Alt-drag column selection", () => {
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": [] } });
expect(styles(event({ shiftKey: true }))[0]).toBeNull();
expect(styles(event())[0]).toBeNull();
expect(styles(event())[1]).not.toBeNull();
useKeymap.setState({ overrides: {} });
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
});
it("matches normalized Ctrl+Alt strokes as reported for right Alt on Windows", () => {
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["alt+ctrl+button1"] } });
expect(styles(event({ ctrlKey: true }))[0]).not.toBeNull();
expect(styles(event())[0]).toBeNull();
});
it("preserves middle-button column selection except on Linux", () => {
expect(styles(event({ button: 1, altKey: false }))[1]).not.toBeNull();
usePlatform.setState({ os: "linux" });
expect(styles(event({ button: 1, altKey: false }))[1]).toBeNull();
});
});
+13 -9
View File
@@ -1,15 +1,11 @@
/**
* IDEA mouse multi-caret gestures (macOS keymap): ⌥⇧click adds a caret (or
* removes the one under the pointer), ⌥⇧drag adds a selection, ⌥drag and
* middle-button drag make a column (rectangular) selection. Middle-button
* drag is left alone on Linux, where a middle click pastes the primary selection.
*/
/** Mouse caret gestures follow the keymap; Alt and middle-button drags select columns. */
import { EditorSelection, type Extension } from "@codemirror/state";
import { EditorView, type MouseSelectionStyle } from "@codemirror/view";
import { bindingsFor, mouseStrokeOf, normalizeBinding } from "@/platform/keymap";
import { usePlatform } from "@/platform/platformInfo";
import { blockMouseSelection } from "./columnMode";
/** The selection after ⌥⇧click / drag from `anchor` to `head` on top of `start`. */
/** The selection after a caret click / drag from `anchor` to `head` on top of `start`. */
export function toggleCaret(start: EditorSelection, anchor: number, head: number): EditorSelection {
if (anchor === head) {
const hit = start.ranges.findIndex((r) => r.empty && r.head === head);
@@ -21,8 +17,16 @@ export function toggleCaret(start: EditorSelection, anchor: number, head: number
return start.addRange(EditorSelection.range(anchor, head));
}
export function isAddCaretGesture(event: MouseEvent): boolean {
if (event.button !== 0) return false;
const stroke = mouseStrokeOf(event);
return (
stroke !== null && bindingsFor("editor.addOrRemoveCaret").some((binding) => normalizeBinding(binding) === stroke)
);
}
const addCaretStyle = EditorView.mouseSelectionStyle.of((view, event): MouseSelectionStyle | null => {
if (event.button !== 0 || !event.altKey || !event.shiftKey || event.metaKey || event.ctrlKey) return null;
if (!isAddCaretGesture(event)) return null;
let start = view.state.selection;
const anchor = view.posAtCoords({ x: event.clientX, y: event.clientY }, false);
return {
@@ -41,7 +45,7 @@ export const multiCaretMouse: Extension = [
// CodeMirror's default (⌘click) would clash with ⌘click navigation.
EditorView.clickAddsSelectionRange.of(() => false),
blockMouseSelection((e) => {
if (e.shiftKey || e.metaKey || e.ctrlKey) return false;
if (isAddCaretGesture(e) || e.shiftKey || e.metaKey || e.ctrlKey) return false;
if (e.button === 0) return e.altKey;
return e.button === 1 && !e.altKey && usePlatform.getState().os !== "linux";
}),
+20
View File
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { registerFileEditorProvider } from "@/workbench/contributions";
import { EditorController, openFilePaths, useTabs } from "./controller";
import { createMemoryBackend } from "./backend";
import { storeSession } from "./model/session";
import { initialTabsState } from "./model/tabs";
import { useProject } from "@/platform/project";
@@ -63,6 +64,25 @@ function tabFor(path: string) {
}
describe("file open order", () => {
it("keeps the caret and activation of a file opened while the project's session restores", async () => {
const before = setup();
before.restoreSession("/p");
await before.openFile("/p/b.ts", { focus: false });
before.stop();
useTabs.setState(initialTabsState(), true);
try {
const controller = setup();
const open = controller.openFile("/p/a.ts", { selection: { line: 1, col: 2 }, focus: false });
controller.restoreSession("/p");
await open;
expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]);
expect(activePath()).toBe("/p/a.ts");
expect(controller.slotFor(tabFor("/p/a.ts")!.id)?.caret()).toMatchObject({ line: 1, col: 2 });
} finally {
storeSession("/p", null);
}
});
it.each([1, 30])("keeps the newer file active after an earlier probe (tab limit: %s)", async (tabLimit) => {
useProject.setState({ settings: { editor: { tabs: { tabLimit } } } as never });
const { started, response } = delayProvider();
+2
View File
@@ -74,6 +74,7 @@ import { requestHover, showPhpConstructDocumentation } from "./hover";
import { declarationSummary, renderCode } from "./markdown";
import { editorPrefs } from "@/platform/editorSettings";
import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs";
import { isAddCaretGesture } from "@/editor/core/cm/multiCaret";
import { dispatchMouseShortcut, shortcutLabel } from "@/platform/keymap";
import { isNavigationGesture, onNavigationModifier, navigationModifierHeld } from "@/platform/navigationModifier";
import { create } from "zustand";
@@ -710,6 +711,7 @@ const linkPlugin = ViewPlugin.fromClass(
},
mousedown(e, view) {
if (e.button !== 0 && e.button !== 1) return false;
if (isAddCaretGesture(e)) return false;
if (
dispatchMouseShortcut(e, () => {
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
@@ -1,5 +1,6 @@
.split {
display: flex;
min-width: 640px;
flex: none;
min-height: 320px;
border: 1px solid var(--border-strong);
+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;
+13
View File
@@ -24,3 +24,16 @@ it("preserves external agent errors", async () => {
const { result } = renderHook(() => useAgentErrorText("Agent says: authentication failed"));
expect(result.current).toBe("Agent says: authentication failed");
});
it("translates only the guidance when the agent adds its own explanation", async () => {
await setLocale("zh");
const details = "Authentication failed: token expired.\nRun `account repair` in a terminal.";
const { result } = renderHook(() =>
useAgentErrorText(
`Google Antigravity needs you to sign in. Run \`'/registry/antigravity' auth login\` in a terminal.\n\n${details}`,
),
);
expect(result.current).toBe(
`Google Antigravity 需要你登录。在终端中运行 \`'/registry/antigravity' auth login\`。\n\n${details}`,
);
});
+2 -2
View File
@@ -4,6 +4,6 @@ import { useTx, useTxTemplate } from "@/platform/i18n";
export function useAgentErrorText(message: string): string {
const tx = useTx();
const translate = useTxTemplate();
const auth = /^(.+) needs you to sign in\. (.+)$/s.exec(message);
return auth ? tx`${auth[1]} needs you to sign in. ${translate(auth[2]!)}` : message;
const auth = /^(.+?) needs you to sign in\. ([^\n]+)([\s\S]*)$/.exec(message);
return auth ? `${tx`${auth[1]} needs you to sign in. ${translate(auth[2]!)}`}${auth[3]}` : message;
}
@@ -0,0 +1,56 @@
import { beforeEach, expect, it, vi } from "vitest";
import { getContext } from "@/platform/context";
import { provideNotificationService } from "@/workbench/contributions";
const { install, uninstall, notify } = vi.hoisted(() => ({ install: vi.fn(), uninstall: vi.fn(), notify: vi.fn() }));
vi.mock("@/platform/ipc", () => ({
call: (result: Promise<unknown>) => result,
commands: { appCliInstall: install, appCliUninstall: uninstall },
isCancelled: (error: unknown) => (error as { kind?: string })?.kind === "cancelled",
}));
const { cliActions } = await import("../cliCommand");
const run = (id: string) =>
cliActions()
.find((action) => action.id === id)!
.run({ ctx: getContext() });
beforeEach(() => {
vi.resetAllMocks();
provideNotificationService({ notify, expire: () => {} });
});
it("explains the Linux PATH step when the user bin is missing from PATH", async () => {
install.mockResolvedValue({ path: "/home/user/.local/bin/openidea", pathDirectory: "/home/user/.local/bin" });
await run("app.installCli");
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({
level: "info",
body: expect.stringContaining("Add /home/user/.local/bin to the beginning of PATH"),
}),
);
});
it("tells users to open a new terminal after installation", async () => {
install.mockResolvedValue({ path: "C:/Users/user/OpenIDEA/bin/openidea.cmd", pathDirectory: null });
await run("app.installCli");
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ body: expect.stringContaining("Open a new terminal") }),
);
});
it("reports an absent command on repeated uninstall", async () => {
uninstall.mockResolvedValue([]);
await run("app.uninstallCli");
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "The openidea command is not installed" }));
});
it("reports an install failure and stays quiet when an administrator prompt is cancelled", async () => {
install.mockRejectedValueOnce(new Error("foreign command"));
await run("app.installCli");
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "foreign command" }));
notify.mockClear();
install.mockRejectedValueOnce({ kind: "cancelled" });
await run("app.installCli");
expect(notify).not.toHaveBeenCalled();
});
+73
View File
@@ -0,0 +1,73 @@
/** Install / Uninstall the `openidea` shell command. */
import type { Action } from "@/platform/actions";
import { tx } from "@/platform/i18n";
import { call, commands, isCancelled } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
const KEY = "app.cli";
function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
}
async function installCli(): Promise<void> {
try {
const result = await call(commands.appCliInstall());
const hint = result.pathDirectory
? tx`Add ${result.pathDirectory} to the beginning of PATH, then open a new terminal.`
: tx("Open a new terminal to use the openidea command.");
notifications().notify({
level: "info",
title: tx("The openidea command is installed"),
body: `${result.path}\n${hint}`,
key: KEY,
});
} catch (e) {
if (!isCancelled(e))
notifications().notify({
level: "error",
title: tx("Cannot install the openidea command"),
body: errorText(e),
key: KEY,
});
}
}
async function uninstallCli(): Promise<void> {
try {
const links = await call(commands.appCliUninstall());
notifications().notify({
level: "info",
title: tx(links.length ? "The openidea command is uninstalled" : "The openidea command is not installed"),
body: links.join("\n") || undefined,
key: KEY,
});
} catch (e) {
if (!isCancelled(e))
notifications().notify({
level: "error",
title: tx("Cannot uninstall the openidea command"),
body: errorText(e),
key: KEY,
});
}
}
export function cliActions(): Action[] {
return [
{
id: "app.installCli",
title: tx("Install 'openidea' Command"),
category: tx("Tools"),
keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"],
run: installCli,
},
{
id: "app.uninstallCli",
title: tx("Uninstall 'openidea' Command"),
category: tx("Tools"),
keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"],
run: uninstallCli,
},
];
}
+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?.();
+2
View File
@@ -6,6 +6,7 @@ import { registerOverlay, registerStatusItem } from "@/workbench/contributions";
import { AboutOverlay } from "./AboutDialog";
import { appActions, updateActions } from "./appActions";
import { checkForUpdatesOnStartup } from "./updates";
import { cliActions } from "./cliCommand";
import { installCloseGuard } from "./closeGuard";
import { TerminationOverlay } from "./termination";
import { InvalidateCachesOverlay, registerInvalidateCachesActions } from "./InvalidateCaches";
@@ -31,6 +32,7 @@ export function activate(): () => void {
const disposers = [
installLocaleSync(),
registerLocalizedActions(() => [...appActions(), ...windowActions()]),
registerLocalizedActions(cliActions),
registerOverlay({ id: "app.about", component: AboutOverlay }),
registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }),
registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }),
+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);
@@ -7,6 +7,7 @@ import { setContext } from "@/platform/context";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { usePlatform } from "@/platform/platformInfo";
import { menuGroupItems } from "@/workbench/contributions";
import { multiCaretMouse } from "@/editor/core/cm/multiCaret";
import { navigationExtension } from "@/editor/lsp/navigation";
import { activate } from "../index";
import { adoptSession, publishContext, useDebug } from "../state";
@@ -45,7 +46,7 @@ function session(adapter = "debugpy", status: "paused" | "running" | "terminated
publishContext();
}
function click() {
const e = new MouseEvent("mousedown", { button: 0, altKey: true, bubbles: true, cancelable: true });
const e = new MouseEvent("mousedown", { button: 0, altKey: true, detail: 1, bubbles: true, cancelable: true });
view.contentDOM.dispatchEvent(e);
return e;
}
@@ -58,7 +59,15 @@ beforeEach(() => {
dispose = activate();
uninstall = installKeyboardDispatcher(window);
view = new EditorView({
state: EditorState.create({ doc, extensions: [navigationExtension(), quickEvaluateMouse()] }),
state: EditorState.create({
doc,
extensions: [
EditorState.allowMultipleSelections.of(true),
navigationExtension(),
quickEvaluateMouse(),
multiCaretMouse,
],
}),
parent: document.body,
});
editor.current = { view, ctx: { path: "/p/app.py" } };
@@ -133,6 +142,32 @@ describe("paused editor evaluation", () => {
expect(ipc.debugEvaluateIn).not.toHaveBeenCalled();
});
it.each([false, true])(
"adds a caret without dispatching an action after removing the evaluation conflict (paused: %s)",
(paused) => {
if (paused) session();
const run = vi.fn();
const remove = registerAction({ id: "editor.addOrRemoveCaret", title: "Add or Remove Caret", run });
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["Alt+Button1"], "debug.quickEvaluate": [] } });
try {
click();
expect(view.state.selection.ranges.map((r) => r.head)).toEqual([0, 16]);
expect(run).not.toHaveBeenCalled();
expect(ipc.debugEvaluateIn).not.toHaveBeenCalled();
} finally {
remove();
}
},
);
it("does not also add a caret when an enabled evaluation handles a shared stroke", async () => {
session();
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["Alt+Button1"] } });
click();
await vi.waitFor(() => expect(ipc.debugEvaluateIn).toHaveBeenCalledOnce());
expect(view.state.selection.ranges.map((r) => r.head)).toEqual([16]);
});
it("honours removal of the mouse binding", () => {
session();
useKeymap.setState({ overrides: { "debug.quickEvaluate": ["Meta+Alt+F8"] } });
+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);
+11
View File
@@ -225,6 +225,17 @@ export function sampleFs(root: string): MemoryFs {
f("build.gradle.kts"),
f("settings.gradle.kts"),
f("README.md"),
f(
"App.tsx",
`export function App() {
return (
<div>
<b>x</b>
</div>
);
}
`,
),
...Array.from({ length: 10_000 }, (_, i) => f(`generated/item${i + 1}.json`)),
]),
root,
+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$",
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more