Files
clide/lib/kernel/src/extensions_manager.dart
T
jpmschweitzerandClaude Opus 4.7 d4f8f89016
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m7s
code-quality batch (T-112)
Seven small consultant findings, one commit:

1. TreeSitterLib stores last dlopen error + path in static fields
   instead of swallowing them. Callers that observe a null instance
   can now read the diagnostic.

2. Drop the Cmsghdr alias in libc.dart — back-compat shim with no
   callers; CLAUDE.md forbids those in a solo repo.

3. Drop EditorController._events field + the unused_field
   suppression. The constructor still subscribes via `events.on<...>`
   for _eventSub; the field itself was speculative retention.

4. Replace inline hex / errno literals in native_pty.dart with
   PosixErrno.{eintr,ebadf,epipe} and new libc.{pollin, pollAnyErr,
   sighup, sigkill, sigwinch}. PosixErrno gains eintr.

5. ExtensionManager records activate/deactivate exceptions in a
   `_failed` map exposed as `failedExtensions` + `didFail(id)`.
   Listeners are notified on entry/exit; cleared on a clean
   activate. UI surfaces the degraded state instead of pretending
   everything is fine.

6. file_tree_view imports FileEntry via the clide.dart barrel
   instead of `package:clide/src/files/listing.dart` directly — the
   leak the consultant flagged (barrel already re-exports it).

7. test_app branch in main.dart wrapped in `if (kDebugMode)` so
   release tree-shaker elides the test harness from shipping
   binaries. Source import stays; tree-shake handles the rest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:11:23 +02:00

304 lines
9.2 KiB
Dart

import 'dart:async';
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/extension/src/extension.dart';
import 'package:clide/kernel/src/clipboard.dart';
import 'package:clide/kernel/src/commands/keybindings.dart';
import 'package:clide/kernel/src/commands/palette.dart';
import 'package:clide/kernel/src/commands/registry.dart';
import 'package:clide/kernel/src/dialog.dart';
import 'package:clide/kernel/src/events/bus.dart';
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:clide/kernel/src/events/types.dart';
import 'package:clide/kernel/src/files.dart';
import 'package:clide/kernel/src/focus.dart';
import 'package:clide/kernel/src/i18n/i18n.dart';
import 'package:clide/kernel/src/ipc/client.dart';
import 'package:clide/kernel/src/keymap/keymap_service.dart';
import 'package:clide/kernel/src/log.dart';
import 'package:clide/kernel/src/net.dart';
import 'package:clide/kernel/src/notify.dart';
import 'package:clide/kernel/src/os.dart';
import 'package:clide/kernel/src/panels/arrangement.dart';
import 'package:clide/kernel/src/panels/registry.dart';
import 'package:clide/kernel/src/project.dart';
import 'package:clide/kernel/src/secrets.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/tray.dart';
import 'package:flutter/foundation.dart';
class ExtensionManager extends ChangeNotifier {
ExtensionManager({
required this.log,
required this.events,
required this.messages,
required this.settings,
required this.theme,
required this.i18n,
required this.panels,
required this.arrangement,
required this.commands,
required this.palette,
required this.keybindings,
required this.keymap,
required this.clipboard,
required this.files,
required this.notify,
required this.dialog,
required this.tray,
required this.secrets,
required this.os,
required this.net,
required this.focus,
required this.project,
required this.ipc,
});
final Logger log;
final DaemonBus events;
final MessageBus messages;
final SettingsStore settings;
final ThemeController theme;
final I18n i18n;
final PanelRegistry panels;
final LayoutArrangement arrangement;
final CommandRegistry commands;
final PaletteController palette;
final KeybindingResolver keybindings;
final KeymapService keymap;
final ClideClipboard clipboard;
final FileServices files;
final Notifications notify;
final DialogRouter dialog;
final TrayRegistry tray;
final SecretsVault secrets;
final OsBridge os;
final NetworkStatus net;
final FocusTracker focus;
final ProjectManager project;
final DaemonClient ipc;
final Map<String, ClideExtension> _known = {};
final Set<String> _activated = {};
// Extensions whose activate() / deactivate() threw — exposed so the
// UI can show a "degraded" status next to them.
final Map<String, Object> _failed = {};
void register(ClideExtension ext) {
if (_known.containsKey(ext.id)) {
log.warn('extensions', 'duplicate registration: ${ext.id}');
return;
}
_known[ext.id] = ext;
notifyListeners();
}
Iterable<ClideExtension> get all => _known.values;
bool isActivated(String id) => _activated.contains(id);
/// Map of extension id → most recent activate/deactivate error.
/// Cleared for an extension when it activates cleanly. UI surfaces
/// read this for the "degraded" indicator.
Map<String, Object> get failedExtensions => Map.unmodifiable(_failed);
bool didFail(String id) => _failed.containsKey(id);
bool isEnabled(String id) {
final v = settings.get<bool>('app.extensions.$id.enabled');
return v ?? true;
}
Future<void> setEnabled(String id, bool enabled) async {
await settings.set<bool>('app.extensions.$id.enabled', enabled);
if (enabled && !isActivated(id)) {
await activate(id);
} else if (!enabled && isActivated(id)) {
await deactivate(id);
}
}
/// Activate every enabled extension in dependency order. Missing
/// deps warn and skip.
Future<void> activateAll() async {
final order = _topoSort();
for (final id in order) {
if (!isEnabled(id)) continue;
await activate(id);
}
}
Future<void> activate(String id) async {
if (_activated.contains(id)) return;
final ext = _known[id];
if (ext == null) {
log.warn('extensions', 'unknown extension: $id');
return;
}
for (final dep in ext.dependsOn) {
if (!_activated.contains(dep)) {
log.warn('extensions', 'skipping ${ext.id}: dependency not activated: $dep');
return;
}
}
final ctx = _ExtensionContext(manager: this, id: ext.id);
try {
await ext.activate(ctx);
for (final c in ext.contributions) {
_applyContribution(c);
}
_activated.add(id);
_failed.remove(id);
events.emit(ExtensionActivated(id: id));
notifyListeners();
log.info('extensions', 'activated $id');
} catch (e, st) {
_failed[id] = e;
log.error('extensions', 'activate failed for $id', error: e, stackTrace: st);
notifyListeners();
}
}
Future<void> deactivate(String id) async {
if (!_activated.contains(id)) return;
final ext = _known[id];
if (ext == null) return;
try {
await ext.deactivate();
for (final c in ext.contributions) {
_removeContribution(c);
}
_activated.remove(id);
events.emit(ExtensionDeactivated(id: id));
notifyListeners();
log.info('extensions', 'deactivated $id');
} catch (e, st) {
_failed[id] = e;
log.error('extensions', 'deactivate failed for $id', error: e, stackTrace: st);
notifyListeners();
}
}
void _applyContribution(ContributionPoint c) {
switch (c) {
case TabContribution _:
case StatusItemContribution _:
case ToolbarButtonContribution _:
panels.contribute(c);
case CommandContribution cmd:
commands.register(cmd);
final binding = cmd.defaultBinding;
if (binding != null) {
// Legacy KeybindingResolver still wired for back-compat
// until all callers migrate; the keymap layer is the
// canonical home for chord → command bindings (T-117).
keybindings.bind(Keybinding.parse(binding), cmd.command);
keymap.registerCommandBinding(binding, cmd.command);
}
case TrayItemContribution t:
tray.add(t);
case LayoutPresetContribution _:
// Presets are consumed by the default-layout extension in its
// own activate(); nothing for the kernel to do here.
break;
}
}
void _removeContribution(ContributionPoint c) {
switch (c) {
case TabContribution _:
case StatusItemContribution _:
case ToolbarButtonContribution _:
panels.uncontribute(c.id);
case CommandContribution cmd:
commands.unregister(cmd.command);
final binding = cmd.defaultBinding;
if (binding != null) {
keybindings.unbind(Keybinding.parse(binding));
}
keymap.unregisterCommandBindings(cmd.command);
case TrayItemContribution t:
tray.remove(t.id);
case LayoutPresetContribution _:
break;
}
}
List<String> _topoSort() {
final order = <String>[];
final seen = <String>{};
final visiting = <String>{};
void visit(String id) {
if (seen.contains(id)) return;
if (visiting.contains(id)) {
log.warn('extensions', 'dependency cycle touching $id');
return;
}
final ext = _known[id];
if (ext == null) return;
visiting.add(id);
for (final dep in ext.dependsOn) {
visit(dep);
}
visiting.remove(id);
seen.add(id);
order.add(id);
}
for (final id in _known.keys) {
visit(id);
}
return order;
}
}
class _ExtensionContext implements ClideExtensionContext {
_ExtensionContext({required this.manager, required this.id});
final ExtensionManager manager;
@override
final String id;
@override
Logger get log => manager.log;
@override
DaemonBus get events => manager.events;
@override
MessageBus get messages => manager.messages;
@override
SettingsStore get settings => manager.settings;
@override
ThemeController get theme => manager.theme;
@override
I18n get i18n => manager.i18n;
@override
PanelRegistry get panels => manager.panels;
@override
LayoutArrangement get arrangement => manager.arrangement;
@override
CommandRegistry get commands => manager.commands;
@override
PaletteController get palette => manager.palette;
@override
ClideClipboard get clipboard => manager.clipboard;
@override
FileServices get files => manager.files;
@override
Notifications get notify => manager.notify;
@override
DialogRouter get dialog => manager.dialog;
@override
TrayRegistry get tray => manager.tray;
@override
SecretsVault get secrets => manager.secrets;
@override
OsBridge get os => manager.os;
@override
NetworkStatus get net => manager.net;
@override
FocusTracker get focus => manager.focus;
@override
ProjectManager get project => manager.project;
@override
DaemonClient get ipc => manager.ipc;
}