mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05eed6d7de | ||
|
|
ace118d6fb | ||
|
|
8d4baa4cf4 | ||
|
|
448b33446a |
No files matched your search
@@ -108,6 +108,7 @@ windows = { version = "0.62", features = [
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Detects shortcuts the OS already gave to another app (Windows only).
|
||||
//!
|
||||
//! Some vendors register system-wide hotkeys (e.g. NVIDIA's overlay grabs
|
||||
//! `Alt+R` / `Alt+Shift+R`), so those keystrokes never reach the IDE. Probing
|
||||
//! asks Windows to register each combo for an instant: success means it is
|
||||
//! free (released right away), `ERROR_HOTKEY_ALREADY_REGISTERED` means another
|
||||
//! app grabbed it. Nothing is left registered.
|
||||
|
||||
use ide_core::IdeResult;
|
||||
|
||||
/// Reports which of `combos` (single-stroke `Alt+Shift+R`-style bindings) are
|
||||
/// already registered to another app. Empty everywhere but Windows.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn system_probe_hotkeys(combos: Vec<String>) -> IdeResult<Vec<String>> {
|
||||
Ok(combos.into_iter().filter(|combo| imp::grabbed(combo)).collect())
|
||||
}
|
||||
|
||||
/// A parsed binding: `RegisterHotKey` modifiers plus a virtual-key code.
|
||||
#[cfg(windows)]
|
||||
struct Hotkey {
|
||||
modifiers: u32,
|
||||
vk: u32,
|
||||
}
|
||||
|
||||
/// Splits a canonical single-stroke binding into modifiers and key; `None`
|
||||
/// for chords, double-modifier gestures, mouse buttons and unknown keys.
|
||||
#[cfg(windows)]
|
||||
fn parse_combo(combo: &str) -> Option<Hotkey> {
|
||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MOD_ALT, MOD_CONTROL, MOD_SHIFT, MOD_WIN};
|
||||
|
||||
if combo.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
let mut parts = combo.split('+');
|
||||
let key = parts.next_back()?;
|
||||
let mut modifiers = 0;
|
||||
for part in parts {
|
||||
modifiers |= match part.to_ascii_uppercase().as_str() {
|
||||
"CTRL" => MOD_CONTROL,
|
||||
"ALT" => MOD_ALT,
|
||||
"SHIFT" => MOD_SHIFT,
|
||||
"META" => MOD_WIN,
|
||||
_ => return None,
|
||||
};
|
||||
}
|
||||
if modifiers == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Hotkey { modifiers, vk: vk_of(key)? })
|
||||
}
|
||||
|
||||
/// The virtual-key code of a binding key, or `None` when it has no single
|
||||
/// `RegisterHotKey` code (mouse buttons, gestures, layout-dependent keys).
|
||||
#[cfg(windows)]
|
||||
fn vk_of(key: &str) -> Option<u32> {
|
||||
if key.len() == 1 {
|
||||
let c = key.as_bytes()[0].to_ascii_uppercase();
|
||||
if c.is_ascii_alphanumeric() {
|
||||
return Some(u32::from(c));
|
||||
}
|
||||
return match c {
|
||||
b'-' => Some(0xBD),
|
||||
b'=' => Some(0xBB),
|
||||
b'[' => Some(0xDB),
|
||||
b']' => Some(0xDD),
|
||||
b'\\' => Some(0xDC),
|
||||
b';' => Some(0xBA),
|
||||
b'\'' => Some(0xDE),
|
||||
b',' => Some(0xBC),
|
||||
b'.' => Some(0xBE),
|
||||
b'/' => Some(0xBF),
|
||||
b'`' => Some(0xC0),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// "F13" → 0x7C … "F24" → 0x87; anything else falls through to the names below.
|
||||
let fkey =
|
||||
key.strip_prefix('F').and_then(|n| n.parse::<u32>().ok()).filter(|n| (1..=24).contains(n)).map(|n| 0x6F + n);
|
||||
if fkey.is_some() {
|
||||
return fkey;
|
||||
}
|
||||
match key.to_ascii_uppercase().as_str() {
|
||||
"SPACE" => Some(0x20),
|
||||
"ENTER" | "RETURN" => Some(0x0D),
|
||||
"ESCAPE" | "ESC" => Some(0x1B),
|
||||
"TAB" => Some(0x09),
|
||||
"BACKSPACE" | "BACK" => Some(0x08),
|
||||
"DELETE" | "DEL" => Some(0x2E),
|
||||
"INSERT" | "INS" => Some(0x2D),
|
||||
"HOME" => Some(0x24),
|
||||
"END" => Some(0x23),
|
||||
"PAGEUP" | "PRIOR" => Some(0x21),
|
||||
"PAGEDOWN" | "NEXT" => Some(0x22),
|
||||
"UP" => Some(0x26),
|
||||
"DOWN" => Some(0x28),
|
||||
"LEFT" => Some(0x25),
|
||||
"RIGHT" => Some(0x27),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::{Hotkey, parse_combo};
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use windows_sys::Win32::Foundation::{ERROR_HOTKEY_ALREADY_REGISTERED, GetLastError};
|
||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{RegisterHotKey, UnregisterHotKey};
|
||||
|
||||
/// Probe ids (never shown, never left registered).
|
||||
static NEXT_ID: AtomicI32 = AtomicI32::new(0x1DEA00);
|
||||
|
||||
/// Registers `combo` for an instant: grabbed iff Windows refuses with
|
||||
/// `ERROR_HOTKEY_ALREADY_REGISTERED`. A granted registration is released
|
||||
/// before returning, so probing never steals a shortcut.
|
||||
pub(super) fn grabbed(combo: &str) -> bool {
|
||||
let Some(Hotkey { modifiers, vk }) = parse_combo(combo) else {
|
||||
return false;
|
||||
};
|
||||
// SAFETY: registering a hotkey to a null window only tests availability;
|
||||
// the id is unique per probe and unregistered on success.
|
||||
unsafe {
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
if RegisterHotKey(std::ptr::null_mut(), id, modifiers, vk) != 0 {
|
||||
UnregisterHotKey(std::ptr::null_mut(), id);
|
||||
false
|
||||
} else {
|
||||
GetLastError() == ERROR_HOTKEY_ALREADY_REGISTERED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod imp {
|
||||
/// No other platform lets background apps swallow our keystrokes this way.
|
||||
pub(super) fn grabbed(_combo: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod tests {
|
||||
use super::imp::grabbed;
|
||||
use super::parse_combo;
|
||||
use windows_sys::Win32::Foundation::{ERROR_HOTKEY_ALREADY_REGISTERED, GetLastError};
|
||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MOD_ALT, MOD_SHIFT, RegisterHotKey, UnregisterHotKey};
|
||||
|
||||
#[test]
|
||||
fn parses_modifier_order_and_key() {
|
||||
let hotkey = parse_combo("Alt+Shift+R").expect("Alt+Shift+R parses");
|
||||
assert_eq!(hotkey.modifiers, MOD_ALT | MOD_SHIFT);
|
||||
assert_eq!(hotkey.vk, u32::from(b'R'));
|
||||
let f7 = parse_combo("Alt+F7").expect("Alt+F7 parses");
|
||||
assert_eq!(f7.modifiers, MOD_ALT);
|
||||
assert_eq!(f7.vk, 0x76);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_what_cannot_be_probed() {
|
||||
for combo in ["R", "Alt+R Alt+S", "Shift Shift", "Alt+Button4", "Ctrl+Foo", ""] {
|
||||
assert!(!grabbed(combo), "{combo} must not probe");
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds a real hotkey, then the probe must report it as grabbed — whether
|
||||
/// our hold succeeded or another app already held it (both read 1409).
|
||||
#[test]
|
||||
fn sees_a_hotkey_held_by_another_registration() {
|
||||
const COMBO: &str = "Alt+Shift+F24";
|
||||
const VK_F24: u32 = 0x87;
|
||||
// SAFETY: a test-only registration, released below.
|
||||
let held = unsafe { RegisterHotKey(std::ptr::null_mut(), 0x1DEAFE, MOD_ALT | MOD_SHIFT, VK_F24) != 0 };
|
||||
if !held {
|
||||
assert_eq!(
|
||||
unsafe { GetLastError() },
|
||||
ERROR_HOTKEY_ALREADY_REGISTERED,
|
||||
"the test hotkey must be holdable or already held"
|
||||
);
|
||||
}
|
||||
assert!(grabbed(COMBO), "a held hotkey reads as grabbed");
|
||||
if held {
|
||||
// SAFETY: releases the registration above.
|
||||
unsafe {
|
||||
UnregisterHotKey(std::ptr::null_mut(), 0x1DEAFE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ pub mod fs;
|
||||
pub mod github;
|
||||
pub mod heap;
|
||||
pub mod history;
|
||||
pub mod hotkeys;
|
||||
pub mod http;
|
||||
pub mod java;
|
||||
pub mod lsp;
|
||||
@@ -213,6 +214,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
history::history_list_dir,
|
||||
history::history_put_label,
|
||||
history::history_restore,
|
||||
hotkeys::system_probe_hotkeys,
|
||||
refactor::refactor_file_move_edits,
|
||||
refactor::refactor_file_move_text_edits,
|
||||
refactor::refactor_move_files,
|
||||
|
||||
@@ -366,6 +366,11 @@ export const commands = {
|
||||
* file and its folders); an open editor updates in place.
|
||||
*/
|
||||
historyRestore: (path: string, id: string) => typedError<null, IdeError>(__TAURI_INVOKE("history_restore", { path, id })),
|
||||
/**
|
||||
* Reports which of `combos` (single-stroke `Alt+Shift+R`-style bindings) are
|
||||
* already registered to another app. Empty everywhere but Windows.
|
||||
*/
|
||||
systemProbeHotkeys: (combos: string[]) => typedError<string[], IdeError>(__TAURI_INVOKE("system_probe_hotkeys", { combos })),
|
||||
/** `workspace/willRenameFiles` on every interested server (before the move). Cancellable. */
|
||||
refactorFileMoveEdits: (request: RequestId, moves: FileMove[]) => typedError<FileRenameEdits, IdeError>(__TAURI_INVOKE("refactor_file_move_edits", { request, moves })),
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NotificationService } from "@/workbench/contributions";
|
||||
|
||||
const ok = (data: string[]) => ({ status: "ok", data }) as const;
|
||||
const systemProbeHotkeys = vi.fn(async (_combos: string[]) => ok([]));
|
||||
const openSettings = vi.fn();
|
||||
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const actual = await orig<typeof import("@/platform/ipc")>();
|
||||
return { ...actual, isTauri: () => true, commands: { ...actual.commands, systemProbeHotkeys } };
|
||||
});
|
||||
|
||||
vi.mock("@/workbench/contributions", () => ({
|
||||
notifications: (): Pick<NotificationService, "notify"> => ({ notify: (n) => void notes.push(n) }),
|
||||
}));
|
||||
|
||||
vi.mock("../model/staging", () => ({ openSettings }));
|
||||
|
||||
type Note = Parameters<NotificationService["notify"]>[0];
|
||||
let notes: Note[];
|
||||
|
||||
const { SYSTEM_HOTKEY_KEY, _resetSystemHotkeyProbeForTests, maybeWarnSystemHotkeys, systemHotkeyCandidates } =
|
||||
await import("../keymap/systemHotkeys");
|
||||
const { useKeymap } = await import("@/platform/keymap");
|
||||
const { usePlatform } = await import("@/platform/platformInfo");
|
||||
|
||||
beforeEach(() => {
|
||||
notes = [];
|
||||
systemProbeHotkeys.mockReset().mockResolvedValue(ok([]));
|
||||
openSettings.mockClear();
|
||||
_resetSystemHotkeyProbeForTests();
|
||||
usePlatform.setState({ os: "windows" });
|
||||
useKeymap.setState({ preset: "intellij-windows", overrides: {}, extensions: {} });
|
||||
});
|
||||
|
||||
describe("systemHotkeyCandidates", () => {
|
||||
it("keeps single-stroke Alt bindings and drops the rest", () => {
|
||||
expect(
|
||||
systemHotkeyCandidates([
|
||||
["Alt+Shift+R", "rename"],
|
||||
["Shift+Alt+R", "other"],
|
||||
["Alt+R Alt+S", "chord"],
|
||||
["Shift Shift", "double"],
|
||||
["Alt+Button4", "mouse"],
|
||||
["Meta+R", "win"],
|
||||
["Ctrl+S", "save"],
|
||||
["Alt+F7", "find"],
|
||||
]),
|
||||
).toEqual(["Alt+Shift+R", "Alt+F7"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeWarnSystemHotkeys", () => {
|
||||
it("warns once per session with an action opening Keymap", async () => {
|
||||
useKeymap.setState({ overrides: { "t.probe": ["Alt+Shift+R"] } });
|
||||
systemProbeHotkeys.mockResolvedValue(ok(["Alt+Shift+R"]));
|
||||
await maybeWarnSystemHotkeys();
|
||||
expect(systemProbeHotkeys).toHaveBeenCalledTimes(1);
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toMatchObject({
|
||||
level: "warning",
|
||||
key: SYSTEM_HOTKEY_KEY,
|
||||
title: "Some shortcuts are already used by another app",
|
||||
});
|
||||
expect(String(notes[0]!.body)).toContain("Alt+Shift+R");
|
||||
expect(notes[0]!.actions).toHaveLength(1);
|
||||
expect(notes[0]!.actions![0]!.label).toBe("Open Keymap");
|
||||
notes[0]!.actions![0]!.run();
|
||||
expect(openSettings).toHaveBeenCalledWith("keymap");
|
||||
await maybeWarnSystemHotkeys();
|
||||
expect(systemProbeHotkeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays quiet when nothing is grabbed or not on Windows", async () => {
|
||||
useKeymap.setState({ overrides: { "t.probe": ["Alt+Shift+R"] } });
|
||||
await maybeWarnSystemHotkeys();
|
||||
expect(notes).toEqual([]);
|
||||
_resetSystemHotkeyProbeForTests();
|
||||
systemProbeHotkeys.mockClear();
|
||||
usePlatform.setState({ os: "linux" });
|
||||
systemProbeHotkeys.mockResolvedValue(ok(["Alt+Shift+R"]));
|
||||
await maybeWarnSystemHotkeys();
|
||||
expect(systemProbeHotkeys).not.toHaveBeenCalled();
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Warns when another Windows app grabbed one of our Alt shortcuts (e.g.
|
||||
* NVIDIA's overlay swallowing `Alt+Shift+R`, so Rename never reaches the IDE).
|
||||
*
|
||||
* At startup the backend probes each single-stroke Alt binding with
|
||||
* `RegisterHotKey`: success means it is free (released immediately), refusal
|
||||
* means another app owns it. One aggregated notification opens Keymap, where
|
||||
* the conflicting shortcuts can be reassigned.
|
||||
*/
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import {
|
||||
effectiveBindings,
|
||||
formatBinding,
|
||||
isMouseBinding,
|
||||
normalizeBinding,
|
||||
parseDoubleModifier,
|
||||
} from "@/platform/keymap";
|
||||
import { isPageSwipe } from "@/platform/pageSwipe";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { openSettings } from "../model/staging";
|
||||
|
||||
export const SYSTEM_HOTKEY_KEY = "keymap.systemHotkeys";
|
||||
|
||||
/**
|
||||
* The effective bindings worth probing: single-stroke Alt shortcuts (chords,
|
||||
* double-modifier gestures, mouse buttons and swipe gestures have no
|
||||
* `RegisterHotKey` equivalent; Meta combos are not stolen this way).
|
||||
*/
|
||||
export function systemHotkeyCandidates(bindings: Array<[string, string]>): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const [binding] of bindings) {
|
||||
if (binding.includes(" ")) continue;
|
||||
if (parseDoubleModifier(binding) || isMouseBinding(binding) || isPageSwipe(binding)) continue;
|
||||
const parts = normalizeBinding(binding).split("+");
|
||||
const mods = parts.slice(0, -1);
|
||||
if (!mods.includes("Alt") || mods.includes("Meta")) continue;
|
||||
out.add(parts.join("+"));
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
let probed = false;
|
||||
|
||||
/** Probes once per session (Windows desktop only) and warns about grabs. */
|
||||
export async function maybeWarnSystemHotkeys(): Promise<void> {
|
||||
if (probed) return;
|
||||
probed = true;
|
||||
try {
|
||||
if (!isTauri() || usePlatform.getState().os !== "windows") return;
|
||||
const candidates = systemHotkeyCandidates(effectiveBindings());
|
||||
if (!candidates.length) return;
|
||||
const grabbed = await call(commands.systemProbeHotkeys(candidates));
|
||||
if (!grabbed.length) return;
|
||||
const shortcuts = grabbed.map((b) => formatBinding(b)).join(", ");
|
||||
notifications().notify({
|
||||
level: "warning",
|
||||
key: SYSTEM_HOTKEY_KEY,
|
||||
title: "Some shortcuts are already used by another app",
|
||||
body: tx`These shortcuts never reach the IDE: ${shortcuts}. Open Keymap to reassign them.`,
|
||||
actions: [{ label: "Open Keymap", run: () => openSettings("keymap") }],
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("cannot probe system hotkeys", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests only: resets the once-per-session guard. */
|
||||
export function _resetSystemHotkeyProbeForTests(): void {
|
||||
probed = false;
|
||||
}
|
||||
@@ -13,11 +13,14 @@ import { isTauri, onIdeEvent } from "@/platform/ipc";
|
||||
import { applyKeymapSettings } from "@/platform/keymap";
|
||||
import { loadSettings, useProject } from "@/platform/project";
|
||||
import { applyAppearanceOptions, installAltDragGuard, installIdleSave } from "./appearanceRuntime";
|
||||
import { maybeWarnSystemHotkeys } from "./keymap/systemHotkeys";
|
||||
import { validateSettings } from "./model/schema";
|
||||
|
||||
export function applyRuntimeSettings(raw: unknown, root: HTMLElement = document.documentElement): void {
|
||||
const s = validateSettings(raw);
|
||||
applyKeymapSettings(s.keymap, s.keymapOverrides, s.customKeymaps);
|
||||
// The keymap just took effect: probe it once for OS-grabbed shortcuts.
|
||||
void maybeWarnSystemHotkeys();
|
||||
|
||||
applyAppearanceOptions(s, root);
|
||||
|
||||
|
||||
@@ -7419,6 +7419,9 @@
|
||||
"Show branch and incoming/outgoing commits in the Project view": "在项目视图中显示分支及传入/传出提交",
|
||||
"Up to date with {upstream}": "已与 {upstream} 同步",
|
||||
"Highlight occurrences of selected text": "高亮所选文本的匹配项",
|
||||
"Some shortcuts are already used by another app": "某些快捷键已被其他应用占用",
|
||||
"These shortcuts never reach the IDE: {shortcuts}. Open Keymap to reassign them.": "以下快捷键无法到达 IDE:{shortcuts}。请打开按键映射为其重新指定快捷键。",
|
||||
"Open Keymap": "打开按键映射",
|
||||
"Show What's New after an update": "更新后显示“新功能”页面",
|
||||
"Opens a page with the release notes when a new version starts. Help › What's New opens it any time.": "新版本首次启动时显示包含发行说明的页面。可随时通过“帮助 › 新功能”打开。",
|
||||
"An Angular app from the Angular CLI.": "用 Angular CLI 创建的 Angular 应用。",
|
||||
|
||||
Reference in new issue
Block a user