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>
214 lines
7.4 KiB
Dart
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 [];
|
|
}
|