Compare commits

...
Author SHA1 Message Date
arookieofc 05eed6d7de Merge remote-tracking branch 'origin/main' into fix/system-hotkey-probe-1876 2026-11-11 20:18:11 +08:00
arookieofcandClaude Code ace118d6fb fix(hotkeys): satisfy clippy::collapsible_if on the F-key parse
Co-Authored-By: Claude Code <[email protected]>
2026-11-11 18:07:50 +08:00
arookieofcandClaude Code 8d4baa4cf4 Merge origin/main into fix/system-hotkey-probe-1876
Co-Authored-By: Claude Code <[email protected]>
2026-11-11 17:05:54 +08:00
arookieofcandClaude Code 448b33446a feat(hotkeys): warn when the OS grabbed an IDE shortcut (#1876)
Probe single-stroke Alt bindings at startup with RegisterHotKey and
notify once per session when another app owns one, with an action that
opens Keymap for reassignment.

Co-Authored-By: Claude Code <[email protected]>
2026-11-11 16:21:49 +08:00
8 changed files with 361 additions and 0 deletions

No files matched your search

+1
View File
@@ -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",
] }
+189
View File
@@ -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);
}
}
}
}
+2
View File
@@ -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,
+5
View File
@@ -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;
}
+3
View File
@@ -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);
+3
View File
@@ -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 应用。",