Forge
csharp4405de34
1#nullable enable
2using System.Text.Json;
3using System.Windows.Input;
4using Avalonia.Controls;
5using Avalonia.Input;
6using CascadeIDE.ViewModels;
7
8namespace CascadeIDE.Services;
9
10/// <summary>
11/// Жесты главного окна из merged <c>hotkeys.toml</c>: подсказки в меню,
12/// tunnel <see cref="InputElement.KeyDownEvent"/> на главном окне (см. <see cref="TryHandleTunnelShortcuts"/>).
13/// Глобальные команды не дублируем через <see cref="Window.KeyBindings"/>: в Avalonia обработка KeyBinding идёт до
14/// <c>RaiseEvent</c> и при успехе ставит <see cref="RoutedEventArgs.Handled"/> — туннельный обработчик на окне
15/// с <c>handledEventsToo: false</c> тогда не вызывается, а строгий <see cref="KeyGesture.Matches"/> не совпадает
16/// с <see cref="KeyGestureChordMatching"/> (модификаторы, раскладка).
17/// Источник привязок id → VM — <see cref="IdeCommandRegistry"/>.
18/// </summary>
19public static class MainWindowHotkeyService
20{
21 /// <summary>Алиас для ключа TOML/UI-only старта отладки (см. <see cref="IdeCommandRegistry.DebugStartOrContinueHotkeyId"/>).</summary>
22 public const string DebugStartOrContinueId = IdeCommandRegistry.DebugStartOrContinueHotkeyId;
23
24 private static IReadOnlyDictionary<string, string>? _mergedMap;
25 private static readonly object HotkeyLogLock = new();
26
27 /// <summary>Сброс кэша merged TOML (только тесты: изоляция и подмена файлов в <see cref="AppContext.BaseDirectory"/>).</summary>
28 internal static void ClearMergedHotkeyMapCacheForTests() => _mergedMap = null;
29
30 /// <summary>
31 /// Только тесты: подменить merged map без диска (изоляция от <c>%LocalAppData%\CascadeIDE\hotkeys.toml</c>).
32 /// <see langword="null"/> — сброс, следующий <see cref="GetMergedMap"/> загрузит с диска.
33 /// </summary>
34 internal static void ReplaceMergedMapForTests(IReadOnlyDictionary<string, string>? map) => _mergedMap = map;
35
36 public static IReadOnlyDictionary<string, string> GetMergedMap() =>
37 _mergedMap ??= HotkeyTomlLoader.LoadMergedDictionary();
38
39 internal static void LogTunnelEvent(string source, KeyEventArgs e, MainWindowViewModel vm, string stage)
40 {
41 try
42 {
43 var dir = Path.Combine(AppContext.BaseDirectory, ".cascade-ide");
44 Directory.CreateDirectory(dir);
45 var path = Path.Combine(dir, "hotkey-log.txt");
46 var line =
47 $"[{DateTimeOffset.Now:O}] source={source} stage={stage} key={e.Key} physical={e.PhysicalKey} mods={e.KeyModifiers} handled={e.Handled} paletteOpen={vm.IsCommandPaletteOpen}{Environment.NewLine}";
48 lock (HotkeyLogLock)
49 File.AppendAllText(path, line);
50 }
51 catch
52 {
53 // Never break input processing because of debug logging.
54 }
55 }
56
57 private static void LogTunnelExecutionError(string commandId, Exception ex)
58 {
59 try
60 {
61 var dir = Path.Combine(AppContext.BaseDirectory, ".cascade-ide");
62 Directory.CreateDirectory(dir);
63 var path = Path.Combine(dir, "hotkey-log.txt");
64 var line = $"[{DateTimeOffset.Now:O}] source=HotkeyService stage=command-fault command={commandId} error={ex.Message}{Environment.NewLine}";
65 lock (HotkeyLogLock)
66 File.AppendAllText(path, line);
67 }
68 catch
69 {
70 // Never break input processing because of debug logging.
71 }
72 }
73
74 public static void ApplyAll(Window window, MainWindowViewModel vm)
75 {
76 var map = HotkeyTomlLoader.LoadMergedDictionary();
77 _mergedMap = map;
78 ApplyWindowKeyBindings(window, vm, map);
79 ApplyMenuHotKeys(window, map);
80 }
81
82 private static void ApplyWindowKeyBindings(Window window, MainWindowViewModel vm, IReadOnlyDictionary<string, string> map)
83 {
84 window.KeyBindings.Clear();
85
86 var seenTomlKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
87 foreach (var entry in IdeCommandRegistry.AllEntries)
88 {
89 var tomlKey = entry.EffectiveHotkeysTomlKey;
90 if (!seenTomlKeys.Add(tomlKey))
91 continue;
92 if (!TryBuildKeyBinding(vm, map, entry, out var binding))
93 continue;
94 window.KeyBindings.Add(binding);
95 }
96 }
97
98 private static (ICommand Command, object? Parameter) ResolveWindowCommand(MainWindowViewModel vm, IdeCommandRegistry.MainWindowHotkeyVmBinding b) =>
99 b.Kind switch
100 {
101 IdeCommandRegistry.MainWindowHotkeyVmBindingKind.ToggleCommandPalette => (vm.ToggleCommandPaletteCommand, null),
102 IdeCommandRegistry.MainWindowHotkeyVmBindingKind.CycleUiMode => (vm.CycleUiModeCommand, null),
103 IdeCommandRegistry.MainWindowHotkeyVmBindingKind.SetUiModeByIndex => (vm.SetUiModeByIndexCommand, b.SetUiModeIndex),
104 IdeCommandRegistry.MainWindowHotkeyVmBindingKind.DebugStartOrContinue => (vm.DebugStartOrContinueCommand, null),
105 IdeCommandRegistry.MainWindowHotkeyVmBindingKind.DebugStop => (vm.DebugStopCommand, null),
106 IdeCommandRegistry.MainWindowHotkeyVmBindingKind.DebugStepOver => (vm.DebugStepOverCommand, null),
107 IdeCommandRegistry.MainWindowHotkeyVmBindingKind.DebugStepInto => (vm.DebugStepIntoCommand, null),
108 IdeCommandRegistry.MainWindowHotkeyVmBindingKind.DebugStepOut => (vm.DebugStepOutCommand, null),
109 IdeCommandRegistry.MainWindowHotkeyVmBindingKind.OpenGoToFilePalette => (vm.OpenGoToFilePaletteCommand, null),
110 IdeCommandRegistry.MainWindowHotkeyVmBindingKind.FocusSolutionExplorerFilter => (vm.FocusSolutionExplorerFilterCommand, null),
111 _ => throw new ArgumentOutOfRangeException(nameof(b)),
112 };
113
114 private static void ApplyMenuHotKeys(Window window, IReadOnlyDictionary<string, string> map)
115 {
116 SetMenuHotKey(window, "MenuDebugStartOrContinue", map, DebugStartOrContinueId);
117 SetMenuHotKey(window, "MenuDebugStop", map, IdeCommands.DebugStop);
118 SetMenuHotKey(window, "MenuDebugStepOver", map, IdeCommands.DebugStepOver);
119 SetMenuHotKey(window, "MenuDebugStepInto", map, IdeCommands.DebugStepInto);
120 SetMenuHotKey(window, "MenuDebugStepOut", map, IdeCommands.DebugStepOut);
121 SetMenuHotKey(window, "MenuCommandPalette", map, "toggle_command_palette");
122 }
123
124 /// <summary>
125 /// Вход туннеля с окна (Main / PFD / MFD / PmSplit): при <see cref="RoutedEventArgs.Handled"/> всё равно пробуем
126 /// <see cref="MainWindowViewModel.TryConsumeCascadeChordKeyDown"/> — иначе корень Ctrl+K из hotkeys.toml может
127 /// не дойти до VM, если KeyBinding или дочерний контрол отметили KeyDown раньше.
128 /// </summary>
129 public static void TryHandleTunnelKeyDownFromWindow(string windowName, KeyEventArgs e, MainWindowViewModel vm)
130 {
131 if (e.Handled)
132 {
133 if (vm.TryConsumeCascadeChordKeyDown(e))
134 {
135 LogTunnelEvent(windowName, e, vm, "cascade-consumed-after-handled");
136 return;
137 }
138
139 LogTunnelEvent(windowName, e, vm, "window-entry-already-handled");
140 return;
141 }
142
143 LogTunnelEvent(windowName, e, vm, "window-entry");
144 _ = TryHandleTunnelKeyDownForMainVm(e, vm);
145 }
146
147 /// <summary>
148 /// Выполняет tunnel-hotkey, пришедший из Monaco/WebView2 (Ctrl+P не доходит до Avalonia KeyDown).
149 /// </summary>
150 public static bool TryExecuteEditorHostShortcut(string tomlKey, MainWindowViewModel vm)
151 {
152 if (string.IsNullOrWhiteSpace(tomlKey))
153 return false;
154
155 if (IdeCommandRegistry.WindowHotkeyByTomlKey.TryGetValue(tomlKey, out var windowEntry)
156 && windowEntry.WindowHotkey is { } windowHotkey)
157 {
158 var fake = new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.None };
159 return ExecuteWindowHotkeyBinding(fake, vm, tomlKey, windowHotkey);
160 }
161
162 foreach (var entry in IdeCommandRegistry.AllEntries)
163 {
164 if (!string.Equals(entry.EffectiveHotkeysTomlKey, tomlKey, StringComparison.OrdinalIgnoreCase))
165 continue;
166 if (string.IsNullOrWhiteSpace(entry.CommandId))
167 return false;
168
169 ExecuteRegistryCommandAsync(vm, entry.CommandId, IdeCommandRegistry.ParseArgs(entry.ArgsJson));
170 return true;
171 }
172
173 return false;
174 }
175
176 /// <summary>
177 /// Tunnel KeyDown для любого <see cref="Window"/> с тем же <see cref="MainWindowViewModel"/> (главное окно, PFD/MFD-хосты):
178 /// CascadeChord → Esc закрывает палитру (путь туннеля до фокуса в редакторе не проходит через <c>CommandPaletteView</c>) → жесты из TOML.
179 /// </summary>
180 public static bool TryHandleTunnelKeyDownForMainVm(KeyEventArgs e, MainWindowViewModel vm)
181 {
182 if (vm.TryConsumeCascadeChordKeyDown(e))
183 {
184 LogTunnelEvent("HotkeyService", e, vm, "cascade-consumed");
185 return true;
186 }
187
188 if (vm.IsCommandPaletteOpen
189 && e.Key == Key.Escape
190 && KeyGestureChordMatching.NormalizeChordModifiers(e.KeyModifiers) == KeyModifiers.None)
191 {
192 if (!vm.CloseCommandPaletteCommand.CanExecute(null))
193 {
194 LogTunnelEvent("HotkeyService", e, vm, "palette-esc-cannot-execute");
195 return false;
196 }
197 vm.CloseCommandPaletteCommand.Execute(null);
198 e.Handled = true;
199 LogTunnelEvent("HotkeyService", e, vm, "palette-esc-closed");
200 return true;
201 }
202
203 var handled = TryHandleTunnelShortcuts(e, vm);
204 if (!handled && (e.KeyModifiers != KeyModifiers.None || e.Key is Key.Escape or Key.F5 or Key.F10 or Key.F11))
205 LogTunnelEvent("HotkeyService", e, vm, "no-match");
206 return handled;
207 }
208
209 private static void SetMenuHotKey(Window window, string controlName, IReadOnlyDictionary<string, string> map, string id)
210 {
211 if (window.FindControl<MenuItem>(controlName) is not { } item)
212 return;
213 if (!map.TryGetValue(id, out var s) || string.IsNullOrWhiteSpace(s))
214 {
215 item.HotKey = null;
216 return;
217 }
218
219 try
220 {
221 item.HotKey = KeyGesture.Parse(s.Trim());
222 }
223 catch
224 {
225 item.HotKey = null;
226 }
227 }
228
229 /// <summary>
230 /// Единственный путь выполнения глобальных хоткеев окна (TOML + <see cref="KeyGestureChordMatching"/>).
231 /// </summary>
232 /// <returns><see langword="true"/>, если событие обработано (выполнена команда).</returns>
233 public static bool TryHandleTunnelShortcuts(KeyEventArgs e, MainWindowViewModel vm)
234 {
235 var map = GetMergedMap();
236 var explicitTomlKeys = new HashSet<string>(IdeCommandRegistry.TunnelShortcutKeyOrder, StringComparer.OrdinalIgnoreCase);
237
238 foreach (var tomlKey in IdeCommandRegistry.TunnelShortcutKeyOrder)
239 {
240 if (!IdeCommandRegistry.WindowHotkeyByTomlKey.TryGetValue(tomlKey, out var entry))
241 continue;
242 if (TryExecuteTunnelShortcutEntry(e, vm, map, tomlKey, entry))
243 return true;
244 }
245
246 var seenTomlKeys = new HashSet<string>(explicitTomlKeys, StringComparer.OrdinalIgnoreCase);
247 foreach (var entry in IdeCommandRegistry.AllEntries)
248 {
249 var tomlKey = entry.EffectiveHotkeysTomlKey;
250 if (!seenTomlKeys.Add(tomlKey))
251 continue;
252 if (TryExecuteTunnelShortcutEntry(e, vm, map, tomlKey, entry))
253 return true;
254 }
255
256 return false;
257 }
258
259 private static bool TryExecuteTunnelShortcutEntry(
260 KeyEventArgs e,
261 MainWindowViewModel vm,
262 IReadOnlyDictionary<string, string> map,
263 string tomlKey,
264 IdeCommandRegistry.IdeCommandRegistryEntry entry)
265 {
266 if (!map.TryGetValue(tomlKey, out var gestureText) || string.IsNullOrWhiteSpace(gestureText))
267 return false;
268
269 KeyGesture gesture;
270 try
271 {
272 gesture = KeyGesture.Parse(gestureText.Trim());
273 }
274 catch
275 {
276 return false;
277 }
278
279 if (!KeyGestureChordMatching.Matches(gesture, e))
280 return false;
281
282 if (entry.WindowHotkey is { } windowHotkey)
283 return ExecuteWindowHotkeyBinding(e, vm, tomlKey, windowHotkey);
284
285 if (!string.IsNullOrWhiteSpace(entry.CommandId))
286 {
287 ExecuteRegistryCommandAsync(vm, entry.CommandId, IdeCommandRegistry.ParseArgs(entry.ArgsJson));
288 e.Handled = true;
289 LogTunnelEvent("HotkeyService", e, vm, $"matched-{tomlKey}");
290 return true;
291 }
292
293 return false;
294 }
295
296 private static bool ExecuteWindowHotkeyBinding(
297 KeyEventArgs e,
298 MainWindowViewModel vm,
299 string tomlKey,
300 IdeCommandRegistry.MainWindowHotkeyVmBinding windowHotkey)
301 {
302 if (tomlKey.Equals("toggle_command_palette", StringComparison.OrdinalIgnoreCase))
303 {
304 if (!vm.ToggleCommandPaletteCommand.CanExecute(null))
305 {
306 LogTunnelEvent("HotkeyService", e, vm, $"matched-{tomlKey}-cannot-execute");
307 return false;
308 }
309
310 vm.ToggleCommandPaletteCommand.Execute(null);
311 e.Handled = true;
312 LogTunnelEvent("HotkeyService", e, vm, $"matched-{tomlKey}");
313 return true;
314 }
315
316 if (tomlKey.Equals("workspace_go_to_file", StringComparison.OrdinalIgnoreCase))
317 {
318 if (!vm.OpenGoToFilePaletteCommand.CanExecute(null))
319 {
320 LogTunnelEvent("HotkeyService", e, vm, $"matched-{tomlKey}-cannot-execute");
321 return false;
322 }
323
324 vm.OpenGoToFilePaletteCommand.Execute(null);
325 e.Handled = true;
326 LogTunnelEvent("HotkeyService", e, vm, $"matched-{tomlKey}");
327 return true;
328 }
329
330 var (cmd, param) = ResolveWindowCommand(vm, windowHotkey);
331 if (!cmd.CanExecute(param))
332 {
333 LogTunnelEvent("HotkeyService", e, vm, $"matched-{tomlKey}-cannot-execute");
334 return false;
335 }
336
337 cmd.Execute(param);
338 e.Handled = true;
339 LogTunnelEvent("HotkeyService", e, vm, $"matched-{tomlKey}");
340 return true;
341 }
342
343 private static void ExecuteRegistryCommandAsync(
344 MainWindowViewModel vm,
345 string commandId,
346 IReadOnlyDictionary<string, JsonElement>? args)
347 {
348 _ = RunAsync();
349
350 async Task RunAsync()
351 {
352 try
353 {
354 await vm.IdeMcp.ExecuteCommandAsync(commandId, args).ConfigureAwait(false);
355 }
356 catch (Exception ex)
357 {
358 LogTunnelExecutionError(commandId, ex);
359 }
360 }
361 }
362
363 private static bool TryBuildKeyBinding(
364 MainWindowViewModel vm,
365 IReadOnlyDictionary<string, string> map,
366 IdeCommandRegistry.IdeCommandRegistryEntry entry,
367 out KeyBinding binding)
368 {
369 binding = null!;
370 var tomlKey = entry.EffectiveHotkeysTomlKey;
371 if (!map.TryGetValue(tomlKey, out var gestureText) || string.IsNullOrWhiteSpace(gestureText))
372 return false;
373
374 KeyGesture gesture;
375 try
376 {
377 gesture = KeyGesture.Parse(gestureText.Trim());
378 }
379 catch
380 {
381 return false;
382 }
383
384 ICommand? command = null;
385 object? parameter = null;
386
387 if (entry.WindowHotkey is { } windowHotkey)
388 {
389 (command, parameter) = ResolveWindowCommand(vm, windowHotkey);
390 }
391 else if (!string.IsNullOrWhiteSpace(entry.CommandId))
392 {
393 command = new DelegateCommand(
394 execute: _ => ExecuteRegistryCommandAsync(vm, entry.CommandId, IdeCommandRegistry.ParseArgs(entry.ArgsJson)),
395 canExecute: _ => true);
396 }
397
398 if (command is null)
399 return false;
400
401 binding = new KeyBinding
402 {
403 Gesture = gesture,
404 Command = command
405 };
406 if (parameter is not null)
407 binding.CommandParameter = parameter;
408 return true;
409 }
410
411 private sealed class DelegateCommand(Action<object?> execute, Predicate<object?> canExecute) : ICommand
412 {
413 public event EventHandler? CanExecuteChanged
414 {
415 add { }
416 remove { }
417 }
418
419 public bool CanExecute(object? parameter) => canExecute(parameter);
420
421 public void Execute(object? parameter) => execute(parameter);
422 }
423}
424
View only · write via MCP/CIDE