Files
clide/lib/kernel/src/keymap/keymap_service.dart
T
jpmschweitzerandClaude Fable 5 99dc52052a dispose-safe teardown for KeymapService + ClaudeConfig; cover the
claude extension lifecycle

make test-integration failed at widget-tree finalization: the
palette's dispose() clears its scope flag, which during app teardown
runs AFTER KernelServices.dispose() has disposed the KeymapService —
notifyListeners asserted. Scope-flag mutations now use the same
fire-and-forget guard SettingsStore established. Same family in
ClaudeConfig: activation's unawaited load() could notify (and start
watchers on) a disposed notifier when a teardown raced it.

The claude extension's activation lifecycle and command success paths
are now exercised end-to-end through the kernel fixture — the file
entered the coverage denominator with the T-391 failure-path tests,
so per the ratchet discipline the rest of it gets covered too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:55:56 +02:00

214 lines
7.4 KiB
Dart

/// Kernel service that owns the active [Keymap], scope context, and
/// resolution surface.
///
/// Layering (lowest precedence → highest):
/// 1. The active preset (asset under `assets/keymaps/<preset>.yaml`).
/// Selected by the `app.keymap.preset` setting; defaults to
/// `default`.
/// 2. A user keymap file at `<appDir>/keybindings.yaml` (per-user
/// power-user overrides).
/// 3. A settings-stored JSON overlay at `app.keymap.overrides` —
/// list of `{intent, keys, when?}` maps in the same shape as
/// preset YAML.
///
/// Scope context is a `Map<String, bool>` keyed by named flags (e.g.
/// `palette.open`, `editor.focused`). Producing services call
/// [setScopeFlag] when their state changes; consumers reference the
/// flag name in when-clauses.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show AssetBundle, KeyEvent, HardwareKeyboard, rootBundle;
import 'package:flutter/widgets.dart' show Intent;
import '../settings.dart';
import 'intents.dart';
import 'key_chord.dart';
import 'keymap.dart';
import 'when_clause.dart';
/// Setting key for the active preset name.
const String kKeymapPresetSetting = 'app.keymap.preset';
/// Setting key for the JSON overlay list.
const String kKeymapOverridesSetting = 'app.keymap.overrides';
/// Filename for the user keymap file under the app dir.
const String kKeymapUserFile = 'keybindings.yaml';
class KeymapService extends ChangeNotifier {
KeymapService({required SettingsStore settings, required Directory appDir, AssetBundle? bundle})
: _settings = settings,
_appDir = appDir,
_bundle = bundle ?? rootBundle;
final SettingsStore _settings;
final Directory _appDir;
final AssetBundle _bundle;
Keymap? _active;
final Map<String, bool> _scope = {};
// The four layer slots, lowest to highest precedence. Held
// separately so [registerCommandBinding] can refresh the
// contributions layer without re-reading the preset / file /
// settings.
KeymapLayer? _preset;
final List<KeymapBinding> _contributions = [];
KeymapLayer? _userFile;
KeymapLayer? _settingsOverlay;
/// The currently effective layered keymap. Null before [load] runs.
Keymap? get keymap => _active;
/// Live read-only view of the scope context.
Map<String, bool> get scope => Map.unmodifiable(_scope);
/// Read the preset from settings (default `default`), load all
/// non-contribution layers, and rebuild the active keymap. Safe to
/// call repeatedly. Contributions registered via
/// [registerCommandBinding] are preserved across reloads.
Future<void> load() async {
final presetName = _settings.get<String>(kKeymapPresetSetting) ?? 'default';
// Preset (asset).
try {
final src = await _bundle.loadString('assets/keymaps/$presetName.yaml');
_preset = KeymapLayer.fromYaml(src, nameOverride: presetName);
} catch (_) {
// A missing preset means we ship without a default. Tests can
// inject a custom bundle. We don't surface this beyond an empty
// active map.
_preset = null;
}
// User file overlay.
final userFile = File('${_appDir.path}/$kKeymapUserFile');
if (await userFile.exists()) {
try {
_userFile = KeymapLayer.fromYaml(await userFile.readAsString(), nameOverride: 'user-file');
} on FormatException {
_userFile = null;
}
} else {
_userFile = null;
}
// Settings overlay.
final overlay = _settings.get<List<Object?>>(kKeymapOverridesSetting);
if (overlay != null && overlay.isNotEmpty) {
final asYaml = StringBuffer('name: settings-overlay\nbindings:\n');
for (final entry in overlay) {
if (entry is! Map) continue;
asYaml.writeln(' - ${jsonEncode(entry)}');
}
try {
_settingsOverlay = KeymapLayer.fromYaml(asYaml.toString(), nameOverride: 'settings-overlay');
} on FormatException {
_settingsOverlay = null;
}
} else {
_settingsOverlay = null;
}
_rebuildActive();
}
/// Register a programmatic chord → command-id binding (typically
/// from an extension's `defaultBinding`). Contributions form a
/// layer between preset and user-file: extensions establish their
/// defaults, the user can override either via the user file or
/// settings overlay.
void registerCommandBinding(String chordSpec, String commandId, {String? when}) {
_contributions.add(KeymapBinding(sequence: KeyChord.parseSequence(chordSpec), intent: InvokeCommandIntent(commandId), when: WhenExpr.tryParse(when)));
_rebuildActive();
}
/// Remove all extension-contributed bindings for [commandId]. Used
/// when an extension is disabled or unregistered.
void unregisterCommandBindings(String commandId) {
final before = _contributions.length;
_contributions.removeWhere((b) {
final i = b.intent;
return i is InvokeCommandIntent && i.commandId == commandId;
});
if (_contributions.length != before) {
_rebuildActive();
}
}
void _rebuildActive() {
final layers = <KeymapLayer>[?_preset, KeymapLayer(name: 'contributions', bindings: List.unmodifiable(_contributions)), ?_userFile, ?_settingsOverlay];
_active = Keymap(layers);
notifyListeners();
}
/// Resolve a [KeyEvent] against the active keymap and current scope.
/// Returns null when nothing matches.
Intent? resolveEvent(KeyEvent event, HardwareKeyboard kb) {
final km = _active;
if (km == null) return null;
final chord = KeyChord.fromKeyEvent(event, kb);
if (chord == null) return null;
return km.resolve(chord, _scope);
}
/// Resolve a complete chord [sequence] (e.g. a double-tapped modifier,
/// `[shift, shift]`) against the active keymap and current scope. Returns
/// the bound intent only on an exact full-sequence match, else null.
/// Used by the global handler's double-tap detector (T-341).
Intent? resolveSequence(List<KeyChord> sequence) {
final km = _active;
if (km == null) return null;
return km.match(sequence, _scope).exact;
}
/// Scope-flag producers clear their flags from widget dispose() — which
/// during app teardown runs AFTER KernelServices.dispose() has disposed
/// this notifier. Tolerate that ordering instead of asserting (the same
/// fire-and-forget pattern SettingsStore uses).
bool _disposed = false;
@override
void dispose() {
_disposed = true;
super.dispose();
}
void _safeNotify() {
if (_disposed) return;
notifyListeners();
}
/// Set a named scope flag. Producers should call this when their
/// state changes so when-clauses re-evaluate correctly. Notifies
/// listeners when the value actually changes.
void setScopeFlag(String name, bool value) {
if (_scope[name] == value) return;
_scope[name] = value;
_safeNotify();
}
/// Clear a named scope flag.
void clearScopeFlag(String name) {
if (!_scope.containsKey(name)) return;
_scope.remove(name);
_safeNotify();
}
/// Switch presets. Persists the new preset name to settings and
/// re-loads the layered keymap.
Future<void> setPreset(String name) async {
await _settings.set<String>(kKeymapPresetSetting, name);
await load();
}
/// All effective bindings, highest-precedence first. Useful for
/// debug surfaces and keybinding hints in the UI.
List<KeymapBinding> get effectiveBindings => _active?.effectiveBindings ?? const [];
}