dissolve app/ into repo root (D-056)

Single Flutter package at the repo root. All code, tests, assets,
and platform directories moved from app/ to root. Package renamed
from clide_app to clide — all imports rewritten. Merged pubspec
combines core (ffi) and app (flutter, yaml, xterm) dependencies.
Makefile simplified: no APP_PRESENT conditionals, no cd, no daemon
lifecycle. 317 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 00:37:20 +02:00
co-authored by Claude Opus 4.6
parent a526c5b9b7
commit 46329700d5
394 changed files with 978 additions and 1090 deletions
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' as flutter_services;
/// Typed, per-content-kind clipboard with a plaintext fallback.
///
/// Extensions write typed values (`write<GitHunk>(hunk)`) and read in
/// the same type (`readAs<GitHunk>()`). Anything with a `toPlain`
/// callback also syncs to the OS clipboard so external apps see
/// reasonable text. The history ring keeps the last [historyLimit]
/// entries per type for quick recall.
class ClideClipboard {
ClideClipboard({this.historyLimit = 16});
final int historyLimit;
final Map<Type, List<Object>> _history = {};
Future<void> write<T extends Object>(
T value, {
String Function(T)? toPlain,
}) async {
final bucket = _history.putIfAbsent(T, () => <Object>[]);
bucket.insert(0, value);
if (bucket.length > historyLimit) bucket.removeLast();
if (toPlain != null) {
await flutter_services.Clipboard.setData(
flutter_services.ClipboardData(text: toPlain(value)));
}
}
T? readAs<T extends Object>() {
final bucket = _history[T];
if (bucket == null || bucket.isEmpty) return null;
return bucket.first as T;
}
List<T> historyOf<T extends Object>() {
final bucket = _history[T];
if (bucket == null) return const [];
return bucket.cast<T>().toList(growable: false);
}
Future<String?> readPlain() async {
final d = await flutter_services.Clipboard.getData('text/plain');
return d?.text;
}
Future<void> writePlain(String text) async {
await flutter_services.Clipboard.setData(
flutter_services.ClipboardData(text: text));
final bucket = _history.putIfAbsent(String, () => <Object>[]);
bucket.insert(0, text);
if (bucket.length > historyLimit) bucket.removeLast();
}
@visibleForTesting
void clear() => _history.clear();
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Key combo: modifiers + primary key. Canonicalized on construction
/// (modifiers sorted, lowercased) so equality works for lookup keys.
@immutable
class Keybinding {
Keybinding({required Set<String> modifiers, required String key})
: modifiers = _canonModifiers(modifiers),
key = key.toLowerCase();
final List<String> modifiers;
final String key;
static List<String> _canonModifiers(Set<String> m) {
final normalized = m.map((s) => s.toLowerCase()).toSet().toList()..sort();
return List.unmodifiable(normalized);
}
/// Parse "ctrl+shift+g", "cmd+k", "alt+f4".
static Keybinding parse(String spec) {
if (spec.trim().isEmpty) {
throw ArgumentError('empty keybinding');
}
final parts = spec.split('+').map((s) => s.trim()).toList();
final key = parts.removeLast();
if (key.isEmpty) {
throw ArgumentError('keybinding is missing a key: "$spec"');
}
return Keybinding(modifiers: parts.toSet(), key: key);
}
String get canonical {
if (modifiers.isEmpty) return key;
return '${modifiers.join('+')}+$key';
}
@override
bool operator ==(Object other) =>
other is Keybinding &&
other.key == key &&
listEquals(other.modifiers, modifiers);
@override
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
@override
String toString() => 'Keybinding($canonical)';
}
class KeybindingResolver {
final Map<Keybinding, String> _bindings = {};
void bind(Keybinding b, String commandId) {
_bindings[b] = commandId;
}
void unbind(Keybinding b) {
_bindings.remove(b);
}
String? commandFor(Keybinding b) => _bindings[b];
Iterable<MapEntry<Keybinding, String>> get entries => _bindings.entries;
/// Map a Flutter [KeyEvent] to a [Keybinding] suitable for lookup.
static Keybinding? fromKeyEvent(KeyEvent event, HardwareKeyboard keyboard) {
if (event is! KeyDownEvent) return null;
final label = event.logicalKey.keyLabel;
if (label.isEmpty) return null;
final mods = <String>{};
if (keyboard.isControlPressed) mods.add('ctrl');
if (keyboard.isShiftPressed) mods.add('shift');
if (keyboard.isAltPressed) mods.add('alt');
if (keyboard.isMetaPressed) mods.add('cmd');
return Keybinding(modifiers: mods, key: label);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/src/commands/registry.dart';
import 'package:flutter/foundation.dart';
class PaletteController extends ChangeNotifier {
PaletteController(this._registry);
final CommandRegistry _registry;
bool _open = false;
String _filter = '';
bool get isOpen => _open;
String get filter => _filter;
void open() {
if (_open) return;
_open = true;
notifyListeners();
}
void close() {
if (!_open) return;
_open = false;
_filter = '';
notifyListeners();
}
void toggle() => _open ? close() : open();
void setFilter(String f) {
if (_filter == f) return;
_filter = f;
notifyListeners();
}
List<CommandContribution> filtered() {
if (_filter.isEmpty) return _registry.all.toList();
final q = _filter.toLowerCase();
return _registry.all.where((c) {
final haystack = (c.title ?? c.command).toLowerCase();
return haystack.contains(q);
}).toList();
}
Future<void> invoke(String command) async {
close();
await _registry.execute(command);
}
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:clide/clide.dart';
import 'package:clide/extension/src/contribution.dart';
import 'package:flutter/foundation.dart';
class CommandRegistry extends ChangeNotifier {
final Map<String, CommandContribution> _byCommand = {};
void register(CommandContribution cmd) {
_byCommand[cmd.command] = cmd;
notifyListeners();
}
void unregister(String command) {
if (_byCommand.remove(command) != null) notifyListeners();
}
Iterable<CommandContribution> get all => _byCommand.values;
CommandContribution? get(String command) => _byCommand[command];
Future<IpcResponse> execute(
String command, {
List<String> args = const [],
}) async {
final c = _byCommand[command];
if (c == null) {
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'no such command: $command',
),
);
}
return c.run(args);
}
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
typedef DialogBuilder<T> = Widget Function(
BuildContext context,
void Function([T? result]) dismiss,
);
/// Single-at-a-time modal router.
///
/// Extensions call [show] with a builder; the root widget (installed by
/// [DialogHost]) listens and renders the current dialog over a dimmed
/// backdrop. Only one dialog is active at a time — a second [show] call
/// while one is open awaits until the first dismisses.
class DialogRouter extends ChangeNotifier {
DialogBuilder<Object?>? _current;
Completer<Object?>? _completer;
final List<_Queued> _queue = [];
DialogBuilder<Object?>? get current => _current;
bool get isOpen => _current != null;
Future<T?> show<T extends Object>(DialogBuilder<T> builder) {
final completer = Completer<T?>();
final wrapped = _wrap<T>(builder);
if (_current == null) {
_current = wrapped;
_completer = Completer<Object?>();
// forward our generic completer to the typed one
_completer!.future.then((v) {
if (!completer.isCompleted) completer.complete(v as T?);
});
notifyListeners();
} else {
_queue.add(_Queued(wrapped, completer));
}
return completer.future;
}
void dismiss([Object? result]) {
if (_current == null) return;
final c = _completer;
_current = null;
_completer = null;
if (c != null && !c.isCompleted) c.complete(result);
if (_queue.isNotEmpty) {
final next = _queue.removeAt(0);
_current = next.builder;
_completer = Completer<Object?>();
_completer!.future.then((v) {
if (!next.completer.isCompleted) next.completer.complete(v);
});
}
notifyListeners();
}
DialogBuilder<Object?> _wrap<T>(DialogBuilder<T> builder) {
return (ctx, dismiss) => builder(ctx, ([T? v]) => dismiss(v));
}
}
class _Queued {
_Queued(this.builder, this.completer);
final DialogBuilder<Object?> builder;
// ignore: strict_raw_type
final Completer completer;
}
/// Hosts the current dialog from [DialogRouter]. Place high in the tree
/// (inside the WidgetsApp) so dialogs overlay every other surface.
class DialogHost extends StatelessWidget {
const DialogHost({
super.key,
required this.router,
required this.child,
this.backdropColor = const Color(0xC0000000),
});
final DialogRouter router;
final Widget child;
final Color backdropColor;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
child,
ListenableBuilder(
listenable: router,
builder: (ctx, _) {
final b = router.current;
if (b == null) return const SizedBox.shrink();
return Positioned.fill(
child: ColoredBox(
color: backdropColor,
child: Center(
child: b(ctx, router.dismiss),
),
),
);
},
),
],
);
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'dart:async';
import 'package:clide/kernel/src/events/types.dart';
class EventBus {
EventBus();
final StreamController<ClideEventEnvelope> _controller =
StreamController<ClideEventEnvelope>.broadcast();
Stream<ClideEventEnvelope> get stream => _controller.stream;
Stream<T> on<T extends ClideEvent>() =>
_controller.stream.where((e) => e.event is T).map((e) => e.event as T);
void emit(ClideEvent event) {
if (_controller.isClosed) return;
_controller.add(ClideEventEnvelope(event, DateTime.now().toUtc()));
}
Future<void> dispose() => _controller.close();
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/foundation.dart';
@immutable
abstract class ClideEvent {
const ClideEvent();
String get subsystem;
String get kind;
Map<String, Object?> payload() => const {};
}
@immutable
class ClideEventEnvelope {
const ClideEventEnvelope(this.event, this.timestamp);
final ClideEvent event;
final DateTime timestamp;
Map<String, Object?> toJson() => {
'v': 1,
'subsystem': event.subsystem,
'kind': event.kind,
'ts': timestamp.toIso8601String(),
'data': event.payload(),
};
}
class DaemonConnectionChanged extends ClideEvent {
const DaemonConnectionChanged({required this.connected});
final bool connected;
@override
String get subsystem => 'ipc';
@override
String get kind => 'connection-changed';
@override
Map<String, Object?> payload() => {'connected': connected};
}
class ThemeChanged extends ClideEvent {
const ThemeChanged({required this.themeName});
final String themeName;
@override
String get subsystem => 'theme';
@override
String get kind => 'changed';
@override
Map<String, Object?> payload() => {'theme': themeName};
}
class ProjectOpened extends ClideEvent {
const ProjectOpened({required this.path});
final String path;
@override
String get subsystem => 'project';
@override
String get kind => 'opened';
@override
Map<String, Object?> payload() => {'path': path};
}
class ProjectClosed extends ClideEvent {
const ProjectClosed();
@override
String get subsystem => 'project';
@override
String get kind => 'closed';
}
class ExtensionActivated extends ClideEvent {
const ExtensionActivated({required this.id});
final String id;
@override
String get subsystem => 'extensions';
@override
String get kind => 'activated';
@override
Map<String, Object?> payload() => {'id': id};
}
class ExtensionDeactivated extends ClideEvent {
const ExtensionDeactivated({required this.id});
final String id;
@override
String get subsystem => 'extensions';
@override
String get kind => 'deactivated';
@override
Map<String, Object?> payload() => {'id': id};
}
/// Forwarded from the daemon. Feature extensions subscribe to this and
/// narrow by subsystem+kind, or register a converter that emits a typed
/// `ClideEvent` subclass into the bus.
class DaemonEvent extends ClideEvent {
const DaemonEvent({
required this.subsystem,
required this.kind,
required this.data,
required this.ts,
});
@override
final String subsystem;
@override
final String kind;
final Map<String, Object?> data;
final DateTime ts;
@override
Map<String, Object?> payload() => {'ts': ts.toIso8601String(), ...data};
}
+279
View File
@@ -0,0 +1,279 @@
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/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/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.settings,
required this.theme,
required this.i18n,
required this.panels,
required this.arrangement,
required this.commands,
required this.palette,
required this.keybindings,
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 EventBus events;
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 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 = {};
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);
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);
events.emit(ExtensionActivated(id: id));
notifyListeners();
log.info('extensions', 'activated $id');
} catch (e, st) {
log.error('extensions', 'activate failed for $id',
error: e, stackTrace: st);
}
}
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) {
log.error('extensions', 'deactivate failed for $id',
error: e, stackTrace: st);
}
}
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) {
keybindings.bind(Keybinding.parse(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));
}
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
EventBus get events => manager.events;
@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;
}
+236
View File
@@ -0,0 +1,236 @@
import 'dart:async';
import 'dart:io';
import 'package:clide/clide.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/extensions_manager.dart';
import 'package:clide/kernel/src/files.dart';
import 'package:clide/kernel/src/focus.dart';
import 'package:clide/kernel/src/i18n/catalog_loader.dart';
import 'package:clide/kernel/src/i18n/i18n.dart';
import 'package:clide/kernel/src/ipc/client.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/theme/loader.dart';
import 'package:clide/kernel/src/tray.dart';
import 'package:flutter/widgets.dart';
/// Aggregated kernel services. Feature code that runs outside a
/// BuildContext (extensions, background tasks) holds a [KernelServices]
/// ref directly; widget code reaches them via [ClideKernel.of].
class KernelServices {
KernelServices({
required this.log,
required this.settings,
required this.events,
required this.ipc,
required this.theme,
required this.i18n,
required this.panels,
required this.arrangement,
required this.commands,
required this.palette,
required this.keybindings,
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.extensions,
});
final Logger log;
final SettingsStore settings;
final EventBus events;
final DaemonClient ipc;
final ThemeController theme;
final I18n i18n;
final PanelRegistry panels;
final LayoutArrangement arrangement;
final CommandRegistry commands;
final PaletteController palette;
final KeybindingResolver keybindings;
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 ExtensionManager extensions;
static Future<KernelServices> boot({
required Directory appDir,
required List<ThemeDefinition> bundledThemes,
required CatalogLoader i18nLoader,
List<String> preloadNamespaces = const [],
Locale defaultLocale = const Locale('en', 'US'),
Locale? initialLocale,
List<Locale> availableLocales = const [Locale('en', 'US')],
String? socketPath,
DaemonClient Function(Logger, EventBus)? daemonClientFactory,
bool autoStartDaemonClient = true,
}) async {
final log = Logger();
final events = EventBus();
final settings = SettingsStore(appDir: appDir);
await settings.load();
final i18n = I18n(
loader: i18nLoader,
log: log,
defaultLocale: defaultLocale,
initialLocale: initialLocale,
availableLocales: availableLocales,
);
for (final ns in preloadNamespaces) {
await i18n.ensureNamespaceLoaded(ns);
}
final theme = ThemeController(bundled: bundledThemes);
final panels = PanelRegistry();
final arrangement = LayoutArrangement();
final commands = CommandRegistry();
final keybindings = KeybindingResolver();
final palette = PaletteController(commands);
final clipboard = ClideClipboard();
final files = FileServices(events);
final notify = Notifications();
final dialog = DialogRouter();
final tray = TrayRegistry();
final secrets = SecretsVault();
final os = OsBridge(log: log, events: events);
final net = NetworkStatus();
final focus = FocusTracker();
final project = ProjectManager(
log: log,
events: events,
settings: settings,
);
final ipc = daemonClientFactory != null
? daemonClientFactory(log, events)
: DaemonClient(
socketPath: socketPath ?? defaultSocketPath(),
log: log,
events: events,
);
final extensions = ExtensionManager(
log: log,
events: events,
settings: settings,
theme: theme,
i18n: i18n,
panels: panels,
arrangement: arrangement,
commands: commands,
palette: palette,
keybindings: keybindings,
clipboard: clipboard,
files: files,
notify: notify,
dialog: dialog,
tray: tray,
secrets: secrets,
os: os,
net: net,
focus: focus,
project: project,
ipc: ipc,
);
if (autoStartDaemonClient) {
unawaited(ipc.start());
}
return KernelServices(
log: log,
settings: settings,
events: events,
ipc: ipc,
theme: theme,
i18n: i18n,
panels: panels,
arrangement: arrangement,
commands: commands,
palette: palette,
keybindings: keybindings,
clipboard: clipboard,
files: files,
notify: notify,
dialog: dialog,
tray: tray,
secrets: secrets,
os: os,
net: net,
focus: focus,
project: project,
extensions: extensions,
);
}
Future<void> dispose() async {
await ipc.stop();
ipc.dispose();
settings.dispose();
theme.dispose();
panels.dispose();
arrangement.dispose();
commands.dispose();
palette.dispose();
i18n.dispose();
notify.dispose();
dialog.dispose();
tray.dispose();
net.dispose();
focus.dispose();
project.dispose();
extensions.dispose();
await log.dispose();
await events.dispose();
}
}
class ClideKernel extends InheritedWidget {
const ClideKernel({
super.key,
required this.services,
required super.child,
});
final KernelServices services;
static KernelServices of(BuildContext context) {
final w = context.dependOnInheritedWidgetOfExactType<ClideKernel>();
if (w == null) {
throw FlutterError(
'ClideKernel.of() called with a context that is not a descendant of a ClideKernel.');
}
return w.services;
}
@override
bool updateShouldNotify(ClideKernel oldWidget) =>
services != oldWidget.services;
}
+61
View File
@@ -0,0 +1,61 @@
import 'dart:async';
import 'package:clide/kernel/src/events/bus.dart';
import 'package:clide/kernel/src/events/types.dart';
import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:flutter/foundation.dart';
class FilesDropped extends ClideEvent {
const FilesDropped({required this.paths, required this.slot});
final List<String> paths;
final SlotId slot;
@override
String get subsystem => 'files';
@override
String get kind => 'dropped';
@override
Map<String, Object?> payload() => {
'paths': paths,
'slot': slot.value,
};
}
/// Tier-0 stub for file pickers and drop targets.
///
/// Flutter desktop has no native picker API without a dep; rather than
/// add one now, pickOpen/pickSave/pickDirectory throw UnimplementedError
/// and the drop target is a no-op until we wire it through the
/// platform channel. This lets the rest of the kernel compile and makes
/// the service surface real.
class FileServices {
FileServices(this._events);
final EventBus _events;
Future<List<String>> pickOpen({
List<String> extensions = const [],
bool multiple = false,
}) async {
throw UnimplementedError('pickOpen — wired in a later tier');
}
Future<String?> pickSave({
String? defaultName,
List<String> extensions = const [],
}) async {
throw UnimplementedError('pickSave — wired in a later tier');
}
Future<String?> pickDirectory() async {
throw UnimplementedError('pickDirectory — wired in a later tier');
}
/// Invoked by the platform drop-target wiring when files land on a
/// slot. Emits a [FilesDropped] event; the slot-owning extension
/// subscribes.
@visibleForTesting
void notifyDropped({required List<String> paths, required SlotId slot}) {
_events.emit(FilesDropped(paths: paths, slot: slot));
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:flutter/foundation.dart';
/// Tracks the currently focused contribution (tab id + slot). Backs
/// `clide active`; extensions that need "which tab does the user care
/// about right now?" read from here instead of poking Flutter's
/// FocusScope directly.
class FocusTracker extends ChangeNotifier {
SlotId? _slot;
String? _contributionId;
SlotId? get activeSlot => _slot;
String? get activeContributionId => _contributionId;
void setActive({required SlotId slot, required String contributionId}) {
if (_slot == slot && _contributionId == contributionId) return;
_slot = slot;
_contributionId = contributionId;
notifyListeners();
}
void clear() {
if (_slot == null && _contributionId == null) return;
_slot = null;
_contributionId = null;
notifyListeners();
}
}
@@ -0,0 +1,7 @@
{
"tab.title": { "translation": "Claude" },
"status.attaching": { "translation": "attaching…" },
"status.no-tmux": { "translation": "no-tmux · fresh every launch" },
"status.exited": { "translation": "session exited" },
"status.primary-exited": { "translation": "session exited — restart clide to retry" }
}
@@ -0,0 +1,4 @@
{
"command.reset": { "translation": "Layout: Reset to Classic" },
"preset.classic": { "translation": "Classic" }
}
@@ -0,0 +1,5 @@
{
"tab.title": { "translation": "Editor" },
"empty": { "translation": "Open a file to begin editing." },
"subtitle.no-buffer": { "translation": "no buffer · use `clide open <path>` or pick a file in the tree" }
}
@@ -0,0 +1,5 @@
{
"tab.title": { "translation": "Files" },
"loading": { "translation": "Loading…" },
"empty": { "translation": "No visible files" }
}
@@ -0,0 +1,6 @@
{
"connected": { "translation": "connected" },
"connected.hint": { "translation": "clide daemon is reachable over the local socket" },
"disconnected": { "translation": "disconnected" },
"disconnected.hint": { "translation": "clide daemon is not running — start it with `clide --daemon`" }
}
@@ -0,0 +1,7 @@
{
"tab.title": { "translation": "Terminal" },
"subtitle.spawning": { "translation": "spawning shell…" },
"subtitle.exited": { "translation": "Shell exited." },
"error.unavailable": { "translation": "Terminal unavailable" },
"error.daemon": { "translation": "Daemon not connected. Start `clide --daemon`." }
}
@@ -0,0 +1,7 @@
{
"command.pick": { "translation": "Theme: Pick…" },
"modal.title": { "translation": "Select theme" },
"modal.cancel": { "translation": "Cancel" },
"modal.cancel.hint": { "translation": "Close the theme picker without changing the current theme" },
"row.select.hint": { "translation": "Activate this theme" }
}
@@ -0,0 +1,7 @@
{
"title": { "translation": "clide" },
"subtitle": { "translation": "Flutter desktop IDE for Claude Code" },
"open-project": { "translation": "Open project" },
"open-project.hint": { "translation": "Pick a git repository to open as the workspace" },
"tab.title": { "translation": "Welcome" }
}
+93
View File
@@ -0,0 +1,93 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:clide/kernel/src/i18n/fallback_chain.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Loads catalog JSON for a given `(namespace, locale)` pair.
///
/// The file format (mirrors fframe verbatim):
/// `{namespace}_{lang}_{country}.json` — or `{namespace}_{lang}.json`
/// Content: `{ "key": { "translation": "...", ...extras }, ... }`.
///
/// Two reader shapes:
/// * Asset bundle (built-in catalogs shipped under `lib/kernel/src/i18n/catalog/`).
/// * Filesystem (third-party extensions under `~/.clide/extensions/<id>/`).
///
/// Missing files return an empty map — not an error. The fallback chain
/// walker handles "nothing for this locale" by trying the next one.
abstract class CatalogLoader {
Future<Map<String, Object?>> load(String namespace, Locale locale);
}
class AssetCatalogLoader implements CatalogLoader {
AssetCatalogLoader({required this.bundle, this.rootDir = _defaultRoot});
final AssetBundle bundle;
final String rootDir;
static const String _defaultRoot = 'lib/kernel/src/i18n/catalog';
@override
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
final suffix = FallbackChain.filenameSuffix(locale);
final path = '$rootDir/${namespace}_$suffix.json';
try {
final text = await bundle.loadString(path);
if (text.trim().isEmpty) return const {};
final obj = jsonDecode(text);
if (obj is Map) return obj.cast<String, Object?>();
return const {};
} on FlutterError {
// Asset missing. Return empty map; fallback chain handles the miss.
return const {};
} on FormatException {
return const {};
}
}
}
class FileCatalogLoader implements CatalogLoader {
const FileCatalogLoader({required this.rootDir});
final Directory rootDir;
@override
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
final suffix = FallbackChain.filenameSuffix(locale);
final f = File('${rootDir.path}/${namespace}_$suffix.json');
if (!await f.exists()) return const {};
try {
final text = await f.readAsString();
if (text.trim().isEmpty) return const {};
final obj = jsonDecode(text);
if (obj is Map) return obj.cast<String, Object?>();
} on FormatException {
// malformed — return empty; caller will fall back.
}
return const {};
}
}
/// Preloaded-in-memory loader for tests and synthesized catalogs.
class InMemoryCatalogLoader implements CatalogLoader {
InMemoryCatalogLoader(this._map);
final Map<String, Map<Locale, Map<String, Object?>>> _map;
@override
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
final byNs = _map[namespace];
if (byNs == null) return const {};
// match by canonical comparison so Locale("en") == registered Locale("en")
for (final entry in byNs.entries) {
if (_eq(entry.key, locale)) return entry.value;
}
return const {};
}
static bool _eq(Locale a, Locale b) =>
a.languageCode == b.languageCode && a.countryCode == b.countryCode;
}
+55
View File
@@ -0,0 +1,55 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
/// Resolves the ordered list of locales to try when looking up a key.
///
/// Order, starting from the current locale:
/// 1. exact (language + country) — e.g. nl_NL
/// 2. language-only — e.g. nl
/// 3. default language + country — e.g. en_US
/// 4. default language-only — e.g. en
///
/// Duplicates are removed while preserving order. `null` country code is
/// canonicalized by omitting it (not empty string) so equality works.
@immutable
class FallbackChain {
const FallbackChain({
required this.current,
required this.defaultLocale,
});
final Locale current;
final Locale defaultLocale;
List<Locale> resolve() {
final out = <Locale>[];
for (final l in [
current,
Locale(current.languageCode),
defaultLocale,
Locale(defaultLocale.languageCode),
]) {
final canon = _canon(l);
if (!out.any((e) => _canon(e) == canon)) {
out.add(l);
}
}
return out;
}
static String _canon(Locale l) {
final country = l.countryCode;
if (country == null || country.isEmpty) return l.languageCode;
return '${l.languageCode}_$country';
}
/// Canonical filename suffix for a locale, matching fframe: `en_us`
/// (lowercase, country only when present).
static String filenameSuffix(Locale l) {
final lang = l.languageCode.toLowerCase();
final country = l.countryCode?.toLowerCase();
if (country == null || country.isEmpty) return lang;
return '${lang}_$country';
}
}
+171
View File
@@ -0,0 +1,171 @@
import 'dart:ui';
import 'package:clide/kernel/src/i18n/catalog_loader.dart';
import 'package:clide/kernel/src/i18n/fallback_chain.dart';
import 'package:clide/kernel/src/log.dart';
import 'package:flutter/foundation.dart';
@immutable
class I18nReplacer {
const I18nReplacer({required this.from, required this.replace});
final String from;
final String replace;
}
/// Text-driven i18n — fframe-style. Keys are strings, lookups are by
/// `(namespace, key)`, missing keys fall back through a locale chain
/// and finally to the caller-supplied placeholder.
///
/// Singleton-per-kernel: `kernel.i18n`. Extensions write:
/// final t = ctx.i18n;
/// t.string('key', placeholder: '...', namespace: ext.id);
class I18n extends ChangeNotifier {
I18n({
required this.loader,
required this.log,
required Locale defaultLocale,
Locale? initialLocale,
List<Locale> availableLocales = const [Locale('en', 'US')],
}) : _defaultLocale = defaultLocale,
_current = initialLocale ?? defaultLocale,
_available = List<Locale>.unmodifiable(availableLocales);
final CatalogLoader loader;
final Logger log;
final Locale _defaultLocale;
Locale _current;
final List<Locale> _available;
/// namespace -> locale -> flat key map
final Map<String, Map<Locale, Map<String, Object?>>> _cache = {};
/// Keys we've already warned about for a given (namespace, key, locale).
/// Keeps the log quiet across repeated lookups.
final Set<String> _warnedMisses = {};
Locale get currentLocale => _current;
Locale get defaultLocale => _defaultLocale;
List<Locale> get availableLocales => _available;
/// Register a catalog that was loaded outside of [loader] — e.g. by the
/// ExtensionManager when a third-party extension activates.
void registerCatalog(
String namespace,
Locale locale,
Map<String, Object?> catalog,
) {
_cache.putIfAbsent(
namespace, () => <Locale, Map<String, Object?>>{})[locale] = catalog;
notifyListeners();
}
/// Remove every entry for a namespace (extension deactivated).
void unregisterCatalog(String namespace) {
if (_cache.remove(namespace) != null) {
notifyListeners();
}
}
/// Set the current locale and reload every already-cached namespace
/// for the new chain. Listeners fire once at the end.
Future<void> setLocale(Locale locale) async {
if (locale == _current) return;
_current = locale;
_warnedMisses.clear();
for (final ns in _cache.keys.toList()) {
await _ensureLoaded(ns);
}
notifyListeners();
}
/// Eagerly load a namespace across the whole fallback chain. Safe to
/// call more than once (subsequent calls only fill missing locales).
Future<void> ensureNamespaceLoaded(String namespace) async {
await _ensureLoaded(namespace);
}
Future<void> _ensureLoaded(String namespace) async {
final byLocale = _cache.putIfAbsent(
namespace,
() => <Locale, Map<String, Object?>>{},
);
final chain = FallbackChain(
current: _current,
defaultLocale: _defaultLocale,
).resolve();
for (final l in chain) {
if (byLocale.containsKey(l)) continue;
byLocale[l] = await loader.load(namespace, l);
}
}
/// Look up a key, walking the locale fallback chain. Returns the
/// placeholder if nothing hits; returns the key itself when placeholder
/// is null (developer fallback — keys are more useful than blanks).
String string(
String key, {
required String namespace,
String? placeholder,
}) {
final byLocale = _cache[namespace];
if (byLocale == null) {
_warnOnce(
'$namespace::MISSING_NAMESPACE::$key',
'i18n: namespace not registered: $namespace (key: $key)',
);
return placeholder ?? key;
}
final chain = FallbackChain(
current: _current,
defaultLocale: _defaultLocale,
).resolve();
for (final locale in chain) {
final catalog = byLocale[locale];
if (catalog == null) continue;
final hit = _extract(catalog, key);
if (hit != null) return hit;
}
_warnOnce(
'$namespace::${_current.languageCode}::$key',
'i18n: missing key "$key" in namespace "$namespace" (locale ${_current.toString()})',
);
return placeholder ?? key;
}
/// [string] + naive `replaceAll` interpolation per replacer.
/// Matches fframe: replacers whose [from] isn't present are silent no-ops.
String interpolated(
String key, {
required String namespace,
String? placeholder,
List<I18nReplacer> replacers = const [],
}) {
var out = string(key, namespace: namespace, placeholder: placeholder);
for (final r in replacers) {
out = out.replaceAll(r.from, r.replace);
}
return out;
}
/// Walks fframe's nested shape: `{ "translation": "..." }`. If the
/// value is a plain string we accept that too (forward-compat).
String? _extract(Map<String, Object?> catalog, String key) {
final v = catalog[key];
if (v == null) return null;
if (v is String) return v;
if (v is Map && v['translation'] is String) {
return v['translation'] as String;
}
return null;
}
void _warnOnce(String dedupeKey, String message) {
if (_warnedMisses.add(dedupeKey)) {
log.warn('i18n', message);
}
}
}
+171
View File
@@ -0,0 +1,171 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:clide/clide.dart';
import 'package:clide/kernel/src/events/bus.dart';
import 'package:clide/kernel/src/events/types.dart';
import 'package:clide/kernel/src/log.dart';
import 'package:flutter/foundation.dart';
class DaemonClient extends ChangeNotifier {
DaemonClient({
required this.socketPath,
required Logger log,
required EventBus events,
}) : _log = log,
_events = events;
final String socketPath;
final Logger _log;
final EventBus _events;
Socket? _socket;
bool _connected = false;
bool _disposed = false;
Timer? _reconnectTimer;
Duration _backoff = const Duration(milliseconds: 200);
int _nextId = 0;
final Map<String, Completer<IpcResponse>> _pending = {};
bool get isConnected => _connected;
Future<void> start() async {
_disposed = false;
await _connect();
}
Future<void> stop() async {
_disposed = true;
_reconnectTimer?.cancel();
_reconnectTimer = null;
final s = _socket;
_socket = null;
await s?.close();
_failPending('client stopped');
_setConnected(false);
}
Future<IpcResponse> request(
String cmd, {
Map<String, Object?> args = const {},
}) {
if (!_connected || _socket == null) {
return Future.value(IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'daemon not connected',
hint: 'is `clide --daemon` running?',
),
));
}
final id = '${_nextId++}';
final completer = Completer<IpcResponse>();
_pending[id] = completer;
final req = IpcRequest(id: id, cmd: cmd, args: args);
_socket!.writeln(req.encode());
return completer.future;
}
Future<void> _connect() async {
if (_disposed) return;
try {
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
final socket = await Socket.connect(addr, 0);
_socket = socket;
_backoff = const Duration(milliseconds: 200);
_setConnected(true);
_log.info('ipc', 'connected to $socketPath');
socket
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
_handleLine,
onDone: _handleDisconnect,
onError: (Object e) {
_log.warn('ipc', 'socket error', error: e);
_handleDisconnect();
},
cancelOnError: true,
);
} catch (e) {
_log.debug(
'ipc', 'connect failed ($e); retry in ${_backoff.inMilliseconds}ms');
_scheduleReconnect();
}
}
void _handleLine(String line) {
if (line.isEmpty) return;
try {
final msg = IpcMessage.decode(line);
switch (msg) {
case IpcResponse r:
final c = _pending.remove(r.id);
if (c != null && !c.isCompleted) c.complete(r);
case IpcEvent e:
_events.emit(DaemonEvent(
subsystem: e.subsystem,
kind: e.kind,
data: e.data,
ts: e.timestamp,
));
case IpcRequest _:
_log.warn('ipc', 'daemon sent a request — unexpected');
}
} on FormatException catch (e) {
_log.warn('ipc', 'bad line from daemon: $e');
}
}
void _handleDisconnect() {
_socket = null;
_failPending('daemon disconnected');
_setConnected(false);
_scheduleReconnect();
}
void _failPending(String reason) {
final err = IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: reason,
);
for (final entry in _pending.entries) {
if (!entry.value.isCompleted) {
entry.value.complete(IpcResponse.err(id: entry.key, error: err));
}
}
_pending.clear();
}
void _scheduleReconnect() {
if (_disposed) return;
_reconnectTimer?.cancel();
_reconnectTimer = Timer(_backoff, _connect);
_backoff = Duration(
milliseconds: math.min(_backoff.inMilliseconds * 2, 5000),
);
}
void _setConnected(bool v) {
if (_connected == v) return;
_connected = v;
_events.emit(DaemonConnectionChanged(connected: v));
notifyListeners();
}
@override
void dispose() {
_disposed = true;
_reconnectTimer?.cancel();
unawaited(_socket?.close());
_socket = null;
_failPending('client disposed');
super.dispose();
}
}
+88
View File
@@ -0,0 +1,88 @@
import 'dart:async';
import 'dart:io';
enum LogLevel { trace, debug, info, warn, error }
class LogRecord {
LogRecord({
required this.level,
required this.source,
required this.message,
required this.timestamp,
this.error,
this.stackTrace,
});
final LogLevel level;
final String source;
final String message;
final DateTime timestamp;
final Object? error;
final StackTrace? stackTrace;
@override
String toString() {
final lv = level.name.toUpperCase().padRight(5);
final buf =
StringBuffer('${timestamp.toIso8601String()} $lv [$source] $message');
if (error != null) buf.write(' | error=$error');
return buf.toString();
}
}
typedef LogSink = void Function(LogRecord);
class Logger {
Logger({this.minLevel = LogLevel.info, List<LogSink>? sinks})
: _sinks = List<LogSink>.from(sinks ?? <LogSink>[stderrSink]);
LogLevel minLevel;
final List<LogSink> _sinks;
final StreamController<LogRecord> _stream =
StreamController<LogRecord>.broadcast();
Stream<LogRecord> get records => _stream.stream;
void addSink(LogSink sink) => _sinks.add(sink);
void trace(String source, String message) =>
_emit(LogLevel.trace, source, message);
void debug(String source, String message) =>
_emit(LogLevel.debug, source, message);
void info(String source, String message) =>
_emit(LogLevel.info, source, message);
void warn(String source, String message, {Object? error}) =>
_emit(LogLevel.warn, source, message, error: error);
void error(String source, String message,
{Object? error, StackTrace? stackTrace}) =>
_emit(LogLevel.error, source, message,
error: error, stackTrace: stackTrace);
void _emit(LogLevel level, String source, String message,
{Object? error, StackTrace? stackTrace}) {
if (level.index < minLevel.index) return;
final rec = LogRecord(
level: level,
source: source,
message: message,
timestamp: DateTime.now().toUtc(),
error: error,
stackTrace: stackTrace,
);
for (final sink in _sinks) {
try {
sink(rec);
} catch (_) {
// a broken sink must not kill logging
}
}
if (!_stream.isClosed) _stream.add(rec);
}
Future<void> dispose() => _stream.close();
}
void stderrSink(LogRecord r) {
stderr.writeln(r);
if (r.stackTrace != null) stderr.writeln(r.stackTrace);
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
enum Reachability { online, offline, metered }
/// Tier-0 stub reachability observable. Hardcoded to `online`; real
/// detection via a platform channel lands in a later tier.
class NetworkStatus extends ChangeNotifier {
Reachability _state = Reachability.online;
Reachability get state => _state;
bool get isOnline => _state != Reachability.offline;
@visibleForTesting
void setState(Reachability r) {
if (_state == r) return;
_state = r;
notifyListeners();
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
enum NotificationLevel { info, warning, error, success }
@immutable
class ClideNotification {
ClideNotification({
required this.id,
required this.level,
required this.message,
this.title,
this.duration = const Duration(seconds: 4),
}) : createdAt = DateTime.now().toUtc();
final String id;
final NotificationLevel level;
final String? title;
final String message;
final DateTime createdAt;
final Duration duration;
}
class Notifications extends ChangeNotifier {
final List<ClideNotification> _active = [];
final Map<String, Timer> _timers = {};
int _seq = 0;
List<ClideNotification> get active => List.unmodifiable(_active);
void info(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.info, message, title: title, duration: duration);
void warn(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.warning, message,
title: title, duration: duration);
void error(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.error, message, title: title, duration: duration);
void success(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.success, message,
title: title, duration: duration);
void dismiss(String id) {
_timers.remove(id)?.cancel();
final before = _active.length;
_active.removeWhere((n) => n.id == id);
if (_active.length != before) notifyListeners();
}
void _push(
NotificationLevel level,
String message, {
String? title,
Duration? duration,
}) {
final id = 'n${_seq++}';
final n = ClideNotification(
id: id,
level: level,
message: message,
title: title,
duration: duration ?? const Duration(seconds: 4),
);
_active.add(n);
_timers[id] = Timer(n.duration, () => dismiss(id));
notifyListeners();
}
@override
void dispose() {
for (final t in _timers.values) {
t.cancel();
}
_timers.clear();
super.dispose();
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'dart:async';
import 'dart:io';
import 'package:clide/kernel/src/events/bus.dart';
import 'package:clide/kernel/src/events/types.dart';
import 'package:clide/kernel/src/log.dart';
import 'package:flutter/foundation.dart';
class OsLifecycleEvent extends ClideEvent {
const OsLifecycleEvent(this._kind);
final String _kind;
@override
String get subsystem => 'os';
@override
String get kind => _kind;
}
class OsBridge {
OsBridge({required Logger log, required EventBus events})
: _log = log,
_events = events;
final Logger _log;
final EventBus _events;
Future<bool> openURL(String url) async {
final cmd = _openCommand();
if (cmd == null) {
_log.warn('os', 'openURL unsupported on ${Platform.operatingSystem}');
return false;
}
try {
final r = await Process.run(cmd[0], [...cmd.skip(1), url]);
return r.exitCode == 0;
} catch (e) {
_log.warn('os', 'openURL failed', error: e);
return false;
}
}
Future<bool> reveal(String path) async {
final cmd = _revealCommand(path);
if (cmd == null) return false;
try {
final r = await Process.run(cmd[0], cmd.skip(1).toList());
return r.exitCode == 0;
} catch (e) {
_log.warn('os', 'reveal failed', error: e);
return false;
}
}
/// Fire an OS lifecycle event (called by the platform wiring).
@visibleForTesting
void fire(String kind) {
_events.emit(OsLifecycleEvent(kind));
}
static List<String>? _openCommand() {
if (Platform.isLinux) return ['xdg-open'];
if (Platform.isMacOS) return ['open'];
if (Platform.isWindows) return ['cmd', '/c', 'start', ''];
return null;
}
static List<String>? _revealCommand(String path) {
if (Platform.isLinux) return ['xdg-open', File(path).parent.path];
if (Platform.isMacOS) return ['open', '-R', path];
if (Platform.isWindows) return ['explorer', '/select,', path];
return null;
}
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/src/panels/registry.dart';
import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:flutter/foundation.dart';
class LayoutArrangement extends ChangeNotifier {
LayoutArrangement();
final Map<SlotId, _SlotState> _state = {};
Map<SlotId, _SlotState>? _focusModeSnapshot;
SlotId? _focusModeSlot;
bool _editorOpen = false;
double _editorRatio = 0.35;
void applyPreset(LayoutPresetContribution preset) {
_state.clear();
_focusModeSnapshot = null;
_focusModeSlot = null;
for (final slot in preset.slots) {
_state[slot.slot] = _SlotState(
position: slot.position,
size: slot.defaultSize,
minSize: slot.minSize,
maxSize: slot.maxSize,
visible: slot.visible,
);
}
notifyListeners();
}
Iterable<SlotId> get slotsInOrder => _state.keys;
SlotPosition? positionOf(SlotId id) => _state[id]?.position;
double? sizeOf(SlotId id) => _state[id]?.size;
double? minSizeOf(SlotId id) => _state[id]?.minSize;
double? maxSizeOf(SlotId id) => _state[id]?.maxSize;
bool isVisible(SlotId id) => _state[id]?.visible ?? false;
bool isCollapsed(SlotId id) => _state[id]?.collapsed ?? false;
bool get isInFocusMode => _focusModeSlot != null;
SlotId? get focusModeSlot => _focusModeSlot;
bool get editorOpen => _editorOpen;
double get editorRatio => _editorRatio;
void setSize(SlotId id, double size) {
final s = _state[id];
if (s == null) return;
final clamped = size.clamp(s.minSize ?? 0, s.maxSize ?? double.infinity).toDouble();
if (s.size == clamped) return;
_state[id] = s.copyWith(size: clamped);
notifyListeners();
}
void setVisible(SlotId id, bool visible) {
final s = _state[id];
if (s == null || s.visible == visible) return;
_state[id] = s.copyWith(visible: visible);
notifyListeners();
}
void setCollapsed(SlotId id, bool collapsed) {
final s = _state[id];
if (s == null || s.collapsed == collapsed) return;
_state[id] = s.copyWith(collapsed: collapsed);
notifyListeners();
}
void toggleCollapsed(SlotId id) {
final s = _state[id];
if (s == null) return;
_state[id] = s.copyWith(collapsed: !s.collapsed);
notifyListeners();
}
void enterFocusMode(SlotId slot) {
if (_focusModeSlot != null) return;
_focusModeSnapshot = {for (final e in _state.entries) e.key: e.value};
_focusModeSlot = slot;
for (final id in _state.keys) {
if (id == slot) {
_state[id] = _state[id]!.copyWith(visible: true, collapsed: false);
} else {
_state[id] = _state[id]!.copyWith(visible: false);
}
}
notifyListeners();
}
void exitFocusMode() {
final snap = _focusModeSnapshot;
if (snap == null) return;
_state.clear();
_state.addAll(snap);
_focusModeSnapshot = null;
_focusModeSlot = null;
notifyListeners();
}
void toggleFocusMode(SlotId slot) {
if (_focusModeSlot != null) {
exitFocusMode();
} else {
enterFocusMode(slot);
}
}
void openEditor() {
if (_editorOpen) return;
_editorOpen = true;
notifyListeners();
}
void closeEditor() {
if (!_editorOpen) return;
_editorOpen = false;
notifyListeners();
}
void toggleEditor() {
_editorOpen = !_editorOpen;
notifyListeners();
}
void setEditorRatio(double ratio) {
final clamped = ratio.clamp(0.15, 0.70);
if (_editorRatio == clamped) return;
_editorRatio = clamped;
notifyListeners();
}
void registerSlotsInto(PanelRegistry registry, LayoutPresetContribution preset) {
for (final slot in preset.slots) {
registry.registerSlot(SlotDefinition(
id: slot.slot,
position: slot.position,
defaultSize: slot.defaultSize,
minSize: slot.minSize,
maxSize: slot.maxSize,
));
}
}
}
class _SlotState {
const _SlotState({
required this.position,
this.size,
this.minSize,
this.maxSize,
this.visible = true,
this.collapsed = false,
});
final SlotPosition position;
final double? size;
final double? minSize;
final double? maxSize;
final bool visible;
final bool collapsed;
_SlotState copyWith({
SlotPosition? position,
double? size,
double? minSize,
double? maxSize,
bool? visible,
bool? collapsed,
}) {
return _SlotState(
position: position ?? this.position,
size: size ?? this.size,
minSize: minSize ?? this.minSize,
maxSize: maxSize ?? this.maxSize,
visible: visible ?? this.visible,
collapsed: collapsed ?? this.collapsed,
);
}
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:clide/kernel/src/panels/arrangement.dart';
import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
/// A 4-px draggable splitter that adjusts the size of [slot] in the
/// given [arrangement]. Slot hosts wrap this around their edges to make
/// the three-column layout resizable.
class DragResizeHandle extends StatefulWidget {
const DragResizeHandle({
super.key,
required this.arrangement,
required this.slot,
required this.axis,
this.thickness = 4.0,
});
final LayoutArrangement arrangement;
final SlotId slot;
final Axis axis;
final double thickness;
@override
State<DragResizeHandle> createState() => _DragResizeHandleState();
}
class _DragResizeHandleState extends State<DragResizeHandle> {
bool _hovered = false;
double? _dragStartSize;
Offset? _dragStartPointer;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final color = _hovered ? tokens.panelActiveBorder : tokens.panelBorder;
return MouseRegion(
cursor: widget.axis == Axis.horizontal
? SystemMouseCursors.resizeColumn
: SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Listener(
onPointerDown: _onDown,
onPointerMove: _onMove,
onPointerUp: _onUp,
child: Container(
width: widget.axis == Axis.horizontal ? widget.thickness : null,
height: widget.axis == Axis.vertical ? widget.thickness : null,
color: color,
),
),
);
}
void _onDown(PointerDownEvent e) {
_dragStartSize = widget.arrangement.sizeOf(widget.slot);
_dragStartPointer = e.position;
}
void _onMove(PointerMoveEvent e) {
final start = _dragStartSize;
final startPt = _dragStartPointer;
if (start == null || startPt == null) return;
final delta = widget.axis == Axis.horizontal
? e.position.dx - startPt.dx
: e.position.dy - startPt.dy;
widget.arrangement.setSize(widget.slot, start + delta);
}
void _onUp(PointerUpEvent _) {
_dragStartSize = null;
_dragStartPointer = null;
}
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/src/panels/slot_id.dart';
/// Canonical "three-column + statusbar" preset — the default-layout
/// extension contributes this at Tier 0. Split out so tests and the
/// default-layout extension share one source of truth.
///
/// Columns (px):
/// sidebar 240 (drag 180400)
/// center flex (workspace on top, statusbar below)
/// context 280 (drag 220420)
/// statusbar 26 (fixed height strip)
LayoutPresetContribution classicPreset() => const LayoutPresetContribution(
id: 'builtin.default-layout.classic',
displayName: 'Classic',
slots: [
LayoutSlot(
slot: Slots.sidebar,
position: SlotPosition.left,
defaultSize: 240,
minSize: 180,
maxSize: 400,
),
LayoutSlot(
slot: Slots.workspace,
position: SlotPosition.center,
),
LayoutSlot(
slot: Slots.contextPanel,
position: SlotPosition.right,
defaultSize: 280,
minSize: 220,
maxSize: 420,
),
LayoutSlot(
slot: Slots.statusbar,
position: SlotPosition.bottom,
defaultSize: 26,
minSize: 26,
maxSize: 26,
),
],
);
+87
View File
@@ -0,0 +1,87 @@
import 'package:clide/extension/src/contribution.dart';
import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:flutter/foundation.dart';
@immutable
class SlotDefinition {
const SlotDefinition({
required this.id,
required this.position,
this.defaultSize,
this.minSize,
this.maxSize,
});
final SlotId id;
final SlotPosition position;
final double? defaultSize;
final double? minSize;
final double? maxSize;
}
class PanelRegistry extends ChangeNotifier {
final Map<SlotId, SlotDefinition> _defs = {};
final Map<SlotId, List<ContributionPoint>> _mounts = {};
final Map<SlotId, String?> _activeTab = {};
void registerSlot(SlotDefinition def) {
_defs[def.id] = def;
_mounts.putIfAbsent(def.id, () => <ContributionPoint>[]);
notifyListeners();
}
void contribute(ContributionPoint point) {
final slot = point.slot;
if (slot == null) return; // non-slot contributions go elsewhere
final list = _mounts.putIfAbsent(slot, () => <ContributionPoint>[]);
list.add(point);
list.sort((a, b) => _priority(a).compareTo(_priority(b)));
// first tab-contribution in the sidebar/workspace/context becomes the
// default active tab until the user picks another
if (_activeTab[slot] == null && point is TabContribution) {
_activeTab[slot] = point.id;
}
notifyListeners();
}
void uncontribute(String contributionId) {
for (final entry in _mounts.entries) {
final before = entry.value.length;
entry.value.removeWhere((c) => c.id == contributionId);
if (entry.value.length != before) {
if (_activeTab[entry.key] == contributionId) {
_activeTab[entry.key] =
entry.value.whereType<TabContribution>().isEmpty
? null
: entry.value.whereType<TabContribution>().first.id;
}
}
}
notifyListeners();
}
Iterable<SlotDefinition> get slots => _defs.values;
SlotDefinition? definitionFor(SlotId id) => _defs[id];
List<ContributionPoint> contributionsFor(SlotId id) =>
List.unmodifiable(_mounts[id] ?? const []);
List<TabContribution> tabsFor(SlotId id) =>
contributionsFor(id).whereType<TabContribution>().toList();
String? activeTabIn(SlotId id) => _activeTab[id];
void activateTab(SlotId id, String tabId) {
if (_activeTab[id] == tabId) return;
_activeTab[id] = tabId;
notifyListeners();
}
int _priority(ContributionPoint p) {
if (p is TabContribution) return p.priority;
if (p is StatusItemContribution) return p.priority;
if (p is ToolbarButtonContribution) return p.priority;
if (p is TrayItemContribution) return p.priority;
return 0;
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/foundation.dart';
@immutable
class SlotId {
const SlotId(this.value);
final String value;
@override
bool operator ==(Object other) => other is SlotId && other.value == value;
@override
int get hashCode => value.hashCode;
@override
String toString() => 'SlotId($value)';
}
/// Kernel-reserved slot ids. Extensions can declare new slots; these are
/// the ones the default layout presets and the kernel services target.
abstract class Slots {
static const sidebar = SlotId('sidebar');
static const workspace = SlotId('workspace');
static const contextPanel = SlotId('context');
static const statusbar = SlotId('statusbar');
static const toolbar = SlotId('toolbar.main');
static const commandPalette = SlotId('commandPalette');
static const tray = SlotId('tray');
static const fullscreen = SlotId('fullscreen');
}
enum SlotPosition { left, right, top, bottom, center, float, popout }
+139
View File
@@ -0,0 +1,139 @@
import 'dart:convert';
import 'dart:io';
import 'package:clide/kernel/src/events/bus.dart';
import 'package:clide/kernel/src/events/types.dart';
import 'package:clide/kernel/src/log.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:flutter/foundation.dart';
class RecentProject {
const RecentProject({required this.path, required this.name, this.branch, required this.lastOpened});
final String path;
final String name;
final String? branch;
final DateTime lastOpened;
Map<String, dynamic> toJson() => {'path': path, 'name': name, 'branch': branch, 'lastOpened': lastOpened.toIso8601String()};
factory RecentProject.fromJson(Map<String, dynamic> json) => RecentProject(
path: json['path'] as String? ?? '',
name: json['name'] as String? ?? '',
branch: json['branch'] as String?,
lastOpened: DateTime.tryParse(json['lastOpened'] as String? ?? '') ?? DateTime.now(),
);
String get relativePath {
final home = Platform.environment['HOME'] ?? '';
if (home.isNotEmpty && path.startsWith(home)) return '~${path.substring(home.length)}';
return path;
}
String get timeAgo {
final diff = DateTime.now().difference(lastOpened);
if (diff.inMinutes < 1) return 'just now';
if (diff.inMinutes < 60) return '${diff.inMinutes} min ago';
if (diff.inHours < 24) return '${diff.inHours} hours ago';
if (diff.inDays == 1) return 'yesterday';
if (diff.inDays < 7) return '${diff.inDays} days ago';
if (diff.inDays < 30) return '${(diff.inDays / 7).floor()} weeks ago';
return '${(diff.inDays / 30).floor()} months ago';
}
}
class ProjectManager extends ChangeNotifier {
ProjectManager({
required Logger log,
required EventBus events,
required SettingsStore settings,
}) : _log = log,
_events = events,
_settings = settings;
final Logger _log;
final EventBus _events;
final SettingsStore _settings;
Directory? _current;
Directory? get current => _current;
bool get isOpen => _current != null;
List<RecentProject> _recents = [];
List<RecentProject> get recents => List.unmodifiable(_recents);
Future<void> loadRecents() async {
final raw = _settings.get<String>('app.recentProjects');
if (raw == null || raw.isEmpty) {
_recents = [];
return;
}
try {
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
_recents = list.map(RecentProject.fromJson).toList();
} catch (_) {
_recents = [];
}
}
Future<bool> open(String path) async {
final root = await resolveWorkspace(path);
if (root == null) {
_log.warn('project', 'not a git repo: $path');
return false;
}
_current = Directory(root);
await _settings.setProjectDir(_current);
await _settings.set<String>('app.lastProject', root);
final branch = await _currentBranch(root);
final name = root.split('/').last;
_recents.removeWhere((r) => r.path == root);
_recents.insert(0, RecentProject(path: root, name: name, branch: branch, lastOpened: DateTime.now()));
if (_recents.length > 10) _recents = _recents.sublist(0, 10);
await _settings.set<String>('app.recentProjects', jsonEncode(_recents.map((r) => r.toJson()).toList()));
_events.emit(ProjectOpened(path: root));
notifyListeners();
return true;
}
Future<bool> openLast() async {
final last = _settings.get<String>('app.lastProject');
if (last == null || last.isEmpty) return false;
final dir = Directory(last);
if (!await dir.exists()) return false;
return open(last);
}
Future<void> close() async {
if (_current == null) return;
_current = null;
await _settings.setProjectDir(null);
_events.emit(const ProjectClosed());
notifyListeners();
}
Future<String?> resolveWorkspace(String path) async {
try {
final r = await Process.run('git', ['rev-parse', '--show-toplevel'], workingDirectory: path, runInShell: false);
if (r.exitCode != 0) return null;
final out = (r.stdout as String).trim();
return out.isEmpty ? null : out;
} catch (e) {
_log.debug('project', 'git rev-parse failed: $e');
return null;
}
}
Future<String?> _currentBranch(String root) async {
try {
final r = await Process.run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], workingDirectory: root, runInShell: false);
if (r.exitCode != 0) return null;
final out = (r.stdout as String).trim();
return out.isEmpty ? null : out;
} catch (_) {
return null;
}
}
}
+34
View File
@@ -0,0 +1,34 @@
/// Tier-0 in-memory stub for the OS-keychain-backed vault.
///
/// Lands on `libsecret` (Linux) and macOS Keychain in a later tier.
/// The async API already matches the eventual platform-channel shape,
/// so consumers don't need to change when the real backend arrives.
class SecretsVault {
final Map<String, String> _memory = {};
Future<void> write({
required String extensionId,
required String key,
required String value,
}) async {
_memory['$extensionId/$key'] = value;
}
Future<String?> read({
required String extensionId,
required String key,
}) async {
return _memory['$extensionId/$key'];
}
Future<void> delete({
required String extensionId,
required String key,
}) async {
_memory.remove('$extensionId/$key');
}
Future<void> deleteAll({required String extensionId}) async {
_memory.removeWhere((k, _) => k.startsWith('$extensionId/'));
}
}
+218
View File
@@ -0,0 +1,218 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:yaml/yaml.dart';
enum SettingsScope { app, project, ext }
class SettingsStore extends ChangeNotifier {
SettingsStore({required this.appDir, this.projectDir});
final Directory appDir;
Directory? projectDir;
final Map<String, Object?> _appValues = <String, Object?>{};
final Map<String, Object?> _projectValues = <String, Object?>{};
Future<void> load() async {
_appValues
..clear()
..addAll(await _readFile(_appFile));
_projectValues.clear();
if (projectDir != null) {
_projectValues.addAll(await _readFile(_projectFile));
}
notifyListeners();
}
Future<void> setProjectDir(Directory? dir) async {
projectDir = dir;
_projectValues.clear();
if (dir != null) {
_projectValues.addAll(await _readFile(_projectFile));
}
notifyListeners();
}
File get _appFile => File('${appDir.path}/settings.yaml');
File get _projectFile => File('${projectDir!.path}/.clide/settings.yaml');
T? get<T>(String key) {
final v = _lookup(key);
if (v is T) return v;
if (T == int && v is num) return v.toInt() as T;
if (T == double && v is num) return v.toDouble() as T;
return null;
}
Object? _lookup(String key) {
switch (_scopeOf(key)) {
case SettingsScope.app:
return _appValues[key];
case SettingsScope.project:
return _projectValues[key];
case SettingsScope.ext:
// project overrides app for the same ext.* key
return _projectValues.containsKey(key)
? _projectValues[key]
: _appValues[key];
}
}
Future<void> set<T>(String key, T value) async {
switch (_scopeOf(key)) {
case SettingsScope.app:
_appValues[key] = value;
await _writeFile(_appFile, _appValues);
case SettingsScope.project:
if (projectDir == null) {
throw StateError(
'Cannot set project-scoped key with no project open: $key');
}
_projectValues[key] = value;
await _writeFile(_projectFile, _projectValues);
case SettingsScope.ext:
// default: store under app until an ext manifest requests project scope
_appValues[key] = value;
await _writeFile(_appFile, _appValues);
}
notifyListeners();
}
Future<Map<String, Object?>> _readFile(File f) async {
try {
if (!await f.exists()) return <String, Object?>{};
final txt = await f.readAsString();
if (txt.trim().isEmpty) return <String, Object?>{};
final yaml = loadYaml(txt);
final out = <String, Object?>{};
if (yaml is Map) _flatten(yaml, '', out);
return out;
} catch (_) {
// On web (or in sandboxes where the path isn't writable) silently
// degrade to an empty in-memory catalog. `set` will no-op too.
return <String, Object?>{};
}
}
Future<void> _writeFile(File f, Map<String, Object?> flat) async {
try {
await f.parent.create(recursive: true);
await f.writeAsString(_emitYaml(_unflatten(flat)));
} catch (_) {
// Web / read-only sandbox: in-memory update remains valid, we
// just can't persist. Callers already called notifyListeners.
}
}
static SettingsScope _scopeOf(String key) {
if (key.startsWith('app.')) return SettingsScope.app;
if (key.startsWith('project.')) return SettingsScope.project;
if (key.startsWith('ext.')) return SettingsScope.ext;
throw ArgumentError(
'Settings key must start with app.|project.|ext.: "$key"');
}
}
void _flatten(Map src, String prefix, Map<String, Object?> into) {
src.forEach((k, v) {
final key = prefix.isEmpty ? '$k' : '$prefix.$k';
if (v is Map) {
_flatten(v, key, into);
} else if (v is YamlList) {
into[key] = v.toList();
} else {
into[key] = v;
}
});
}
Map<String, Object?> _unflatten(Map<String, Object?> flat) {
final root = <String, Object?>{};
flat.forEach((k, v) {
final parts = k.split('.');
var cursor = root;
for (var i = 0; i < parts.length - 1; i++) {
final next = cursor[parts[i]];
if (next is Map<String, Object?>) {
cursor = next;
} else {
final fresh = <String, Object?>{};
cursor[parts[i]] = fresh;
cursor = fresh;
}
}
cursor[parts.last] = v;
});
return root;
}
String _emitYaml(Object? value, {int indent = 0}) {
final buf = StringBuffer();
_emit(buf, value, indent);
return buf.toString();
}
void _emit(StringBuffer buf, Object? v, int indent) {
final pad = ' ' * indent;
if (v is Map) {
if (v.isEmpty) {
buf.writeln('{}');
return;
}
v.forEach((k, vv) {
buf.write('$pad$k:');
if (vv is Map && vv.isNotEmpty) {
buf.writeln();
_emit(buf, vv, indent + 1);
} else if (vv is List && vv.isNotEmpty) {
buf.writeln();
for (final item in vv) {
buf.write('$pad- ');
_emitScalar(buf, item);
buf.writeln();
}
} else {
buf.write(' ');
_emitScalar(buf, vv);
buf.writeln();
}
});
return;
}
_emitScalar(buf, v);
buf.writeln();
}
void _emitScalar(StringBuffer buf, Object? v) {
if (v == null) {
buf.write('null');
} else if (v is bool || v is num) {
buf.write(v);
} else if (v is String) {
if (_needsQuoting(v)) {
buf.write('"${v.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"');
} else {
buf.write(v);
}
} else if (v is List) {
buf.write('[');
for (var i = 0; i < v.length; i++) {
if (i > 0) buf.write(', ');
_emitScalar(buf, v[i]);
}
buf.write(']');
} else {
buf.write('"${v.toString()}"');
}
}
bool _needsQuoting(String s) {
if (s.isEmpty) return true;
if (RegExp(r'[:\#\n\r\t]').hasMatch(s)) return true;
if (s != s.trim()) return true;
const reserved = {'true', 'false', 'null', 'yes', 'no', 'on', 'off', '~'};
if (reserved.contains(s.toLowerCase())) return true;
if (num.tryParse(s) != null) return true;
return false;
}
+118
View File
@@ -0,0 +1,118 @@
/// Maps file extensions and special filenames to grammar asset names.
library;
String? grammarForPath(String path) {
final name = path.split('/').last;
final special = _filenameMap[name];
if (special != null) return special;
final dot = name.lastIndexOf('.');
if (dot < 0) return null;
final ext = name.substring(dot).toLowerCase();
return _extMap[ext];
}
const _filenameMap = <String, String>{
'Makefile': 'make',
'makefile': 'make',
'GNUmakefile': 'make',
'Dockerfile': 'dockerfile',
'dockerfile': 'dockerfile',
'.gitignore': 'gitignore',
'.gitconfig': 'git-config',
'.gitmodules': 'git-config',
'justfile': 'just',
'Justfile': 'just',
};
const _extMap = <String, String>{
// Systems
'.c': 'c',
'.h': 'c',
'.cpp': 'cpp',
'.cxx': 'cpp',
'.cc': 'cpp',
'.hpp': 'cpp',
'.hxx': 'cpp',
'.cs': 'c-sharp',
// Application
'.dart': 'dart',
'.go': 'go',
'.rs': 'rust',
'.java': 'java',
'.kt': 'kotlin',
'.kts': 'kotlin',
'.swift': 'swift',
'.rb': 'ruby',
'.py': 'python',
'.pyw': 'python',
'.ex': 'elixir',
'.exs': 'elixir',
'.erl': 'erlang',
'.hrl': 'erlang',
'.hs': 'haskell',
'.lhs': 'haskell',
'.jl': 'julia',
'.r': 'r',
'.R': 'r',
'.zig': 'zig',
'.nix': 'nix',
'.lua': 'lua',
'.php': 'php',
'.nkl': 'nickel',
// Web
'.js': 'javascript',
'.mjs': 'javascript',
'.cjs': 'javascript',
'.jsx': 'javascript',
'.ts': 'typescript',
'.tsx': 'typescript',
'.html': 'html',
'.htm': 'html',
'.css': 'css',
'.svelte': 'svelte',
'.vue': 'vue',
// Data / Config
'.json': 'json',
'.yaml': 'yaml',
'.yml': 'yaml',
'.toml': 'toml',
'.xml': 'xml',
'.svg': 'xml',
'.plist': 'xml',
'.hcl': 'hcl',
'.tf': 'hcl',
'.tfvars': 'hcl',
'.proto': 'proto',
'.regex': 'regex',
// Markdown
'.md': 'markdown',
'.mdx': 'markdown',
'.markdown': 'markdown',
// Shell
'.sh': 'bash',
'.bash': 'bash',
'.zsh': 'bash',
'.fish': 'bash',
// Game dev
'.gd': 'gdscript',
'.glsl': 'glsl',
'.vert': 'glsl',
'.frag': 'glsl',
'.hlsl': 'hlsl',
'.wgsl': 'wgsl',
// Data / Query
'.sql': 'sqlite',
'.sqlite': 'sqlite',
// Other
'.diff': 'diff',
'.patch': 'diff',
};
+256
View File
@@ -0,0 +1,256 @@
library;
import 'dart:ffi';
import 'dart:io' show File, Platform;
import 'package:ffi/ffi.dart';
// -- Opaque handles ----------------------------------------------------------
final class TSParser extends Opaque {}
final class TSTree extends Opaque {}
final class TSQuery extends Opaque {}
final class TSQueryCursor extends Opaque {}
final class TSWasmStore extends Opaque {}
final class TSWasmEngine extends Opaque {}
// -- Structs -----------------------------------------------------------------
final class TSNode extends Struct {
@Array(4)
external Array<Uint32> context;
external Pointer<Void> id;
external Pointer<Void> tree;
}
final class TSQueryCapture extends Struct {
external TSNode node;
@Uint32()
external int index;
}
final class TSQueryMatch extends Struct {
@Uint32()
external int id;
@Uint16()
external int patternIndex;
@Uint16()
external int captureCount;
external Pointer<TSQueryCapture> captures;
}
final class TSWasmError extends Struct {
@Int32()
external int kind;
external Pointer<Utf8> message;
}
// -- Native function typedefs ------------------------------------------------
// Parser
typedef _TsParserNew = Pointer<TSParser> Function();
typedef _TsParserDelete = Void Function(Pointer<TSParser>);
typedef _TsParserSetLanguage = Bool Function(Pointer<TSParser>, Pointer<Void>);
typedef _TsParserSetWasmStore = Void Function(
Pointer<TSParser>, Pointer<TSWasmStore>);
typedef _TsParserParseString = Pointer<TSTree> Function(
Pointer<TSParser>, Pointer<TSTree>, Pointer<Utf8>, Uint32);
// Tree
typedef _TsTreeDelete = Void Function(Pointer<TSTree>);
typedef _TsTreeRootNode = TSNode Function(Pointer<TSTree>);
// Node
typedef _TsNodeStartByte = Uint32 Function(TSNode);
typedef _TsNodeEndByte = Uint32 Function(TSNode);
// Query
typedef _TsQueryNew = Pointer<TSQuery> Function(
Pointer<Void>, Pointer<Utf8>, Uint32, Pointer<Uint32>, Pointer<Int32>);
typedef _TsQueryDelete = Void Function(Pointer<TSQuery>);
typedef _TsQueryCaptureCount = Uint32 Function(Pointer<TSQuery>);
typedef _TsQueryCaptureNameForId = Pointer<Utf8> Function(
Pointer<TSQuery>, Uint32, Pointer<Uint32>);
// Query cursor
typedef _TsQueryCursorNew = Pointer<TSQueryCursor> Function();
typedef _TsQueryCursorDelete = Void Function(Pointer<TSQueryCursor>);
typedef _TsQueryCursorExec = Void Function(
Pointer<TSQueryCursor>, Pointer<TSQuery>, TSNode);
typedef _TsQueryCursorNextMatch = Bool Function(
Pointer<TSQueryCursor>, Pointer<TSQueryMatch>);
// WASM store
typedef _TsWasmStoreNew = Pointer<TSWasmStore> Function(
Pointer<TSWasmEngine>, Pointer<TSWasmError>);
typedef _TsWasmStoreDelete = Void Function(Pointer<TSWasmStore>);
typedef _TsWasmStoreLoadLanguage = Pointer<Void> Function(
Pointer<TSWasmStore>, Pointer<Utf8>, Pointer<Uint8>, Uint32,
Pointer<TSWasmError>);
// WASM engine (from wasmtime C API, re-exported by tree-sitter)
typedef _WasmEngineNew = Pointer<TSWasmEngine> Function();
typedef _WasmEngineDelete = Void Function(Pointer<TSWasmEngine>);
// -- Dart function typedefs --------------------------------------------------
typedef DTsParserNew = Pointer<TSParser> Function();
typedef DTsParserDelete = void Function(Pointer<TSParser>);
typedef DTsParserSetLanguage = bool Function(Pointer<TSParser>, Pointer<Void>);
typedef DTsParserSetWasmStore = void Function(
Pointer<TSParser>, Pointer<TSWasmStore>);
typedef DTsParserParseString = Pointer<TSTree> Function(
Pointer<TSParser>, Pointer<TSTree>, Pointer<Utf8>, int);
typedef DTsTreeDelete = void Function(Pointer<TSTree>);
typedef DTsTreeRootNode = TSNode Function(Pointer<TSTree>);
typedef DTsNodeStartByte = int Function(TSNode);
typedef DTsNodeEndByte = int Function(TSNode);
typedef DTsQueryNew = Pointer<TSQuery> Function(
Pointer<Void>, Pointer<Utf8>, int, Pointer<Uint32>, Pointer<Int32>);
typedef DTsQueryDelete = void Function(Pointer<TSQuery>);
typedef DTsQueryCaptureCount = int Function(Pointer<TSQuery>);
typedef DTsQueryCaptureNameForId = Pointer<Utf8> Function(
Pointer<TSQuery>, int, Pointer<Uint32>);
typedef DTsQueryCursorNew = Pointer<TSQueryCursor> Function();
typedef DTsQueryCursorDelete = void Function(Pointer<TSQueryCursor>);
typedef DTsQueryCursorExec = void Function(
Pointer<TSQueryCursor>, Pointer<TSQuery>, TSNode);
typedef DTsQueryCursorNextMatch = bool Function(
Pointer<TSQueryCursor>, Pointer<TSQueryMatch>);
typedef DTsWasmStoreNew = Pointer<TSWasmStore> Function(
Pointer<TSWasmEngine>, Pointer<TSWasmError>);
typedef DTsWasmStoreDelete = void Function(Pointer<TSWasmStore>);
typedef DTsWasmStoreLoadLanguage = Pointer<Void> Function(
Pointer<TSWasmStore>, Pointer<Utf8>, Pointer<Uint8>, int,
Pointer<TSWasmError>);
typedef DWasmEngineNew = Pointer<TSWasmEngine> Function();
typedef DWasmEngineDelete = void Function(Pointer<TSWasmEngine>);
// -- Bindings ----------------------------------------------------------------
class TreeSitterLib {
TreeSitterLib._(DynamicLibrary lib)
: parserNew = lib.lookupFunction<_TsParserNew, DTsParserNew>(
'ts_parser_new'),
parserDelete = lib.lookupFunction<_TsParserDelete, DTsParserDelete>(
'ts_parser_delete'),
parserSetLanguage =
lib.lookupFunction<_TsParserSetLanguage, DTsParserSetLanguage>(
'ts_parser_set_language'),
parserSetWasmStore =
lib.lookupFunction<_TsParserSetWasmStore, DTsParserSetWasmStore>(
'ts_parser_set_wasm_store'),
parserParseString =
lib.lookupFunction<_TsParserParseString, DTsParserParseString>(
'ts_parser_parse_string'),
treeDelete = lib.lookupFunction<_TsTreeDelete, DTsTreeDelete>(
'ts_tree_delete'),
treeRootNode = lib.lookupFunction<_TsTreeRootNode, DTsTreeRootNode>(
'ts_tree_root_node'),
nodeStartByte = lib.lookupFunction<_TsNodeStartByte, DTsNodeStartByte>(
'ts_node_start_byte'),
nodeEndByte = lib.lookupFunction<_TsNodeEndByte, DTsNodeEndByte>(
'ts_node_end_byte'),
queryNew =
lib.lookupFunction<_TsQueryNew, DTsQueryNew>('ts_query_new'),
queryDelete = lib.lookupFunction<_TsQueryDelete, DTsQueryDelete>(
'ts_query_delete'),
queryCaptureCount =
lib.lookupFunction<_TsQueryCaptureCount, DTsQueryCaptureCount>(
'ts_query_capture_count'),
queryCaptureNameForId = lib.lookupFunction<_TsQueryCaptureNameForId,
DTsQueryCaptureNameForId>('ts_query_capture_name_for_id'),
queryCursorNew =
lib.lookupFunction<_TsQueryCursorNew, DTsQueryCursorNew>(
'ts_query_cursor_new'),
queryCursorDelete =
lib.lookupFunction<_TsQueryCursorDelete, DTsQueryCursorDelete>(
'ts_query_cursor_delete'),
queryCursorExec =
lib.lookupFunction<_TsQueryCursorExec, DTsQueryCursorExec>(
'ts_query_cursor_exec'),
queryCursorNextMatch =
lib.lookupFunction<_TsQueryCursorNextMatch, DTsQueryCursorNextMatch>(
'ts_query_cursor_next_match'),
wasmStoreNew = lib.lookupFunction<_TsWasmStoreNew, DTsWasmStoreNew>(
'ts_wasm_store_new'),
wasmStoreDelete =
lib.lookupFunction<_TsWasmStoreDelete, DTsWasmStoreDelete>(
'ts_wasm_store_delete'),
wasmStoreLoadLanguage = lib.lookupFunction<_TsWasmStoreLoadLanguage,
DTsWasmStoreLoadLanguage>('ts_wasm_store_load_language'),
wasmEngineNew = lib.lookupFunction<_WasmEngineNew, DWasmEngineNew>(
'wasm_engine_new'),
wasmEngineDelete =
lib.lookupFunction<_WasmEngineDelete, DWasmEngineDelete>(
'wasm_engine_delete');
final DTsParserNew parserNew;
final DTsParserDelete parserDelete;
final DTsParserSetLanguage parserSetLanguage;
final DTsParserSetWasmStore parserSetWasmStore;
final DTsParserParseString parserParseString;
final DTsTreeDelete treeDelete;
final DTsTreeRootNode treeRootNode;
final DTsNodeStartByte nodeStartByte;
final DTsNodeEndByte nodeEndByte;
final DTsQueryNew queryNew;
final DTsQueryDelete queryDelete;
final DTsQueryCaptureCount queryCaptureCount;
final DTsQueryCaptureNameForId queryCaptureNameForId;
final DTsQueryCursorNew queryCursorNew;
final DTsQueryCursorDelete queryCursorDelete;
final DTsQueryCursorExec queryCursorExec;
final DTsQueryCursorNextMatch queryCursorNextMatch;
final DTsWasmStoreNew wasmStoreNew;
final DTsWasmStoreDelete wasmStoreDelete;
final DTsWasmStoreLoadLanguage wasmStoreLoadLanguage;
final DWasmEngineNew wasmEngineNew;
final DWasmEngineDelete wasmEngineDelete;
static TreeSitterLib? _instance;
static TreeSitterLib? get instance => _instance;
static bool init() {
if (_instance != null) return true;
final lib = _openLibrary();
if (lib == null) return false;
_instance = TreeSitterLib._(lib);
return true;
}
static DynamicLibrary? _openLibrary() {
final libName = Platform.isLinux
? 'libtree-sitter.so'
: Platform.isMacOS
? 'libtree-sitter.dylib'
: Platform.isWindows
? 'tree-sitter.dll'
: null;
if (libName == null) return null;
// Try standard dlopen path first (works when lib is in bundle/lib/).
try {
return DynamicLibrary.open(libName);
} catch (_) {}
// Try next to executable.
final exe = File(Platform.resolvedExecutable).parent.path;
for (final dir in ['$exe/lib', exe]) {
final path = '$dir/$libName';
if (File(path).existsSync()) {
try {
return DynamicLibrary.open(path);
} catch (_) {}
}
}
return null;
}
}
@@ -0,0 +1,291 @@
library;
import 'dart:convert' show utf8;
import 'dart:ffi';
import 'dart:ui' show Color;
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/services.dart' show rootBundle;
class SyntaxSpan {
const SyntaxSpan({
required this.start,
required this.end,
required this.role,
});
final int start;
final int end;
final String role;
}
class SyntaxResult {
const SyntaxResult(this.spans);
final List<SyntaxSpan> spans;
static const empty = SyntaxResult([]);
}
class _LoadedGrammar {
_LoadedGrammar({
required this.language,
required this.query,
required this.captureNames,
});
final Pointer<Void> language;
final Pointer<TSQuery> query;
final List<String> captureNames;
}
class TreeSitterService {
final Map<String, _LoadedGrammar> _grammars = {};
final Set<String> _unavailable = {};
Pointer<TSWasmStore>? _store;
Pointer<TSParser>? _parser;
Pointer<TSQueryCursor>? _cursor;
bool _initDone = false;
bool _init() {
if (_initDone) return _parser != null;
_initDone = true;
final lib = TreeSitterLib.instance;
if (lib == null) return false;
final engine = lib.wasmEngineNew();
if (engine == nullptr) return false;
final error = calloc<TSWasmError>();
_store = lib.wasmStoreNew(engine, error);
lib.wasmEngineDelete(engine);
if (_store == null || _store == nullptr) {
calloc.free(error);
return false;
}
calloc.free(error);
_parser = lib.parserNew();
if (_parser == null || _parser == nullptr) return false;
lib.parserSetWasmStore(_parser!, _store!);
_cursor = lib.queryCursorNew();
return true;
}
Future<_LoadedGrammar?> _loadGrammar(String language) async {
if (_unavailable.contains(language)) return null;
final cached = _grammars[language];
if (cached != null) return cached;
if (!_init()) {
_unavailable.add(language);
return null;
}
final lib = TreeSitterLib.instance!;
try {
// Load grammar WASM bytes.
final wasmData =
await rootBundle.load('assets/grammars/$language.wasm');
final wasmBytes = wasmData.buffer.asUint8List();
// Load into WASM store.
final nameNative = language.toNativeUtf8();
final wasmNative = calloc<Uint8>(wasmBytes.length);
wasmNative.asTypedList(wasmBytes.length).setAll(0, wasmBytes);
final error = calloc<TSWasmError>();
final lang = lib.wasmStoreLoadLanguage(
_store!, nameNative.cast(), wasmNative, wasmBytes.length, error,
);
calloc.free(wasmNative);
calloc.free(nameNative);
if (lang == nullptr) {
final msg = error.ref.message;
if (msg != nullptr) calloc.free(msg);
calloc.free(error);
_unavailable.add(language);
return null;
}
calloc.free(error);
// Load highlight query.
String? querySource;
try {
querySource =
await rootBundle.loadString('assets/queries/$language.scm');
} catch (_) {}
Pointer<TSQuery> query = nullptr;
List<String> captureNames = [];
if (querySource != null) {
final queryNative = querySource.toNativeUtf8();
final queryLen = utf8.encode(querySource).length;
final errorOffset = calloc<Uint32>();
final errorType = calloc<Int32>();
query = lib.queryNew(
lang, queryNative.cast(), queryLen, errorOffset, errorType,
);
calloc.free(queryNative);
calloc.free(errorOffset);
calloc.free(errorType);
if (query != nullptr) {
final count = lib.queryCaptureCount(query);
final lenOut = calloc<Uint32>();
for (var i = 0; i < count; i++) {
final namePtr = lib.queryCaptureNameForId(query, i, lenOut);
final len = lenOut.value;
captureNames.add(namePtr.cast<Utf8>().toDartString(length: len));
}
calloc.free(lenOut);
}
}
final grammar = _LoadedGrammar(
language: lang,
query: query,
captureNames: captureNames,
);
_grammars[language] = grammar;
return grammar;
} catch (_) {
_unavailable.add(language);
return null;
}
}
Future<bool> hasGrammar(String path) async {
final lang = grammarForPath(path);
if (lang == null) return false;
return (await _loadGrammar(lang)) != null;
}
Future<String?> languageFor(String path) async {
final lang = grammarForPath(path);
if (lang == null) return null;
return (await _loadGrammar(lang)) != null ? lang : null;
}
List<String> get loadedLanguages => _grammars.keys.toList();
Future<SyntaxResult> highlight(String path, String source) async {
final lang = grammarForPath(path);
if (lang == null) return SyntaxResult.empty;
final grammar = await _loadGrammar(lang);
if (grammar == null || grammar.query == nullptr) {
return SyntaxResult.empty;
}
final lib = TreeSitterLib.instance!;
final parser = _parser!;
final cursor = _cursor!;
// Set language on parser for this parse.
lib.parserSetLanguage(parser, grammar.language);
// Parse source.
final sourceNative = source.toNativeUtf8();
final sourceLen = utf8.encode(source).length;
final tree = lib.parserParseString(
parser, nullptr, sourceNative.cast(), sourceLen,
);
if (tree == nullptr) {
calloc.free(sourceNative);
return SyntaxResult.empty;
}
final root = lib.treeRootNode(tree);
// Run highlight query.
lib.queryCursorExec(cursor, grammar.query, root);
final match = calloc<TSQueryMatch>();
final spans = <SyntaxSpan>[];
while (lib.queryCursorNextMatch(cursor, match)) {
final m = match.ref;
for (var i = 0; i < m.captureCount; i++) {
final cap = m.captures[i];
final captureIndex = cap.index;
if (captureIndex < grammar.captureNames.length) {
spans.add(SyntaxSpan(
start: lib.nodeStartByte(cap.node),
end: lib.nodeEndByte(cap.node),
role: grammar.captureNames[captureIndex],
));
}
}
}
calloc.free(match);
lib.treeDelete(tree);
calloc.free(sourceNative);
return SyntaxResult(spans);
}
void dispose() {
final lib = TreeSitterLib.instance;
if (lib == null) return;
for (final grammar in _grammars.values) {
if (grammar.query != nullptr) lib.queryDelete(grammar.query);
}
_grammars.clear();
if (_cursor != null && _cursor != nullptr) lib.queryCursorDelete(_cursor!);
// Parser and WASM store are cleaned up together — deleting the parser
// does not delete the store, but the store owns the languages.
if (_parser != null && _parser != nullptr) lib.parserDelete(_parser!);
if (_store != null && _store != nullptr) lib.wasmStoreDelete(_store!);
_parser = null;
_store = null;
_cursor = null;
_unavailable.clear();
}
static Color colorForRole(String role, SurfaceTokens tokens) {
return switch (role) {
'keyword' || 'repeat' || 'conditional' || 'include' ||
'exception' || 'operator' =>
tokens.syntaxKeyword,
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
'string' || 'string.special' => tokens.syntaxString,
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
'comment' => tokens.syntaxComment,
'function' || 'function.builtin' || 'function.method' ||
'method' =>
tokens.syntaxMethod,
'punctuation.bracket' || 'punctuation.delimiter' ||
'punctuation.special' =>
tokens.syntaxPunct,
'variable' || 'variable.builtin' || 'variable.parameter' =>
tokens.globalForeground,
'property' || 'field' => tokens.syntaxMethod,
'constant' || 'constant.builtin' => tokens.syntaxNumber,
'tag' || 'attribute' => tokens.syntaxKeyword,
'namespace' || 'module' => tokens.syntaxType,
'text.title' => tokens.syntaxKeyword,
'text.literal' || 'text.reference' || 'text.uri' => tokens.syntaxString,
'text.emphasis' || 'text.strong' => tokens.syntaxType,
_ => tokens.globalForeground,
};
}
}
+151
View File
@@ -0,0 +1,151 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:flutter/foundation.dart';
/// A foreground/background token pair the a11y contrast suite walks.
@immutable
class ContrastPair {
const ContrastPair({
required this.name,
required this.foreground,
required this.background,
this.largeText = false,
});
final String name;
final Color foreground;
final Color background;
/// WCAG AA threshold for "large text" (18pt, or 14pt bold) is 3:1;
/// normal text is 4.5:1. Mark a pair as [largeText] when the rendered
/// typography qualifies.
final bool largeText;
}
/// Compute the WCAG 2.x relative-luminance ratio between two colors.
///
/// Alpha is pre-composited against a neutral grey so semi-transparent
/// tokens don't spuriously pass. Returns a value in `[1, 21]`.
double contrastRatio(Color a, Color b, {Color onto = const Color(0xFF808080)}) {
final la = _relativeLuminance(_composite(a, onto));
final lb = _relativeLuminance(_composite(b, onto));
final brighter = math.max(la, lb);
final darker = math.min(la, lb);
return (brighter + 0.05) / (darker + 0.05);
}
/// Minimum ratio required for this pair per WCAG AA.
double minimumRatio(ContrastPair pair) => pair.largeText ? 3.0 : 4.5;
/// Canonical set of token pairs each bundled theme must honour.
///
/// The a11y contrast test walks this list per-theme.
List<ContrastPair> canonicalPairs(SurfaceTokens s) => [
ContrastPair(
name: 'global.text_on_background',
foreground: s.globalForeground,
background: s.globalBackground,
),
ContrastPair(
name: 'panel.header_foreground_on_panel',
foreground: s.panelHeaderForeground,
background: s.panelHeader,
),
ContrastPair(
name: 'sidebar.foreground_on_sidebar',
foreground: s.sidebarForeground,
background: s.sidebarBackground,
),
ContrastPair(
name: 'statusbar.foreground_on_statusbar',
foreground: s.statusBarForeground,
background: s.statusBarBackground,
),
ContrastPair(
name: 'tab.active_text_on_active_bg',
foreground: s.tabActiveForeground,
background: s.tabActive,
),
ContrastPair(
name: 'tab.inactive_text_on_inactive_bg',
foreground: s.tabInactiveForeground,
background: s.tabInactive,
),
ContrastPair(
name: 'button.text_on_button',
foreground: s.buttonForeground,
background: s.buttonBackground,
),
ContrastPair(
name: 'listItem.selected_text_on_selected_bg',
foreground: s.listItemSelectedForeground,
background: s.listItemSelectedBackground,
),
ContrastPair(
name: 'listItem.text_on_list',
foreground: s.listItemForeground,
background: s.listItemBackground,
),
ContrastPair(
name: 'tooltip.text_on_tooltip',
foreground: s.tooltipForeground,
background: s.tooltipBackground,
),
ContrastPair(
name: 'dropdown.text_on_dropdown',
foreground: s.dropdownForeground,
background: s.dropdownBackground,
),
];
/// Convenience for tests: returns the list of pairs that fail WCAG AA.
List<ContrastFailure> failingPairs(SurfaceTokens tokens) {
final out = <ContrastFailure>[];
for (final p in canonicalPairs(tokens)) {
final ratio = contrastRatio(p.foreground, p.background);
final need = minimumRatio(p);
if (ratio < need) {
out.add(ContrastFailure(pair: p, ratio: ratio, minimum: need));
}
}
return out;
}
@immutable
class ContrastFailure {
const ContrastFailure({
required this.pair,
required this.ratio,
required this.minimum,
});
final ContrastPair pair;
final double ratio;
final double minimum;
@override
String toString() => 'contrast ${pair.name}: ${ratio.toStringAsFixed(2)} < '
'${minimum.toStringAsFixed(1)}';
}
// -- internals ---------------------------------------------------------------
Color _composite(Color src, Color dst) {
final a = src.a;
if (a >= 0.999) return src;
double mix(double s, double d) => s * a + d * (1 - a);
return Color.from(
alpha: 1.0,
red: mix(src.r, dst.r),
green: mix(src.g, dst.g),
blue: mix(src.b, dst.b),
);
}
double _relativeLuminance(Color c) {
double chan(double v) =>
v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
return 0.2126 * chan(c.r) + 0.7152 * chan(c.g) + 0.0722 * chan(c.b);
}
+106
View File
@@ -0,0 +1,106 @@
import 'package:clide/kernel/src/theme/loader.dart';
import 'package:clide/kernel/src/theme/resolver.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:flutter/widgets.dart';
@immutable
class ClideThemeData {
const ClideThemeData({
required this.name,
required this.displayName,
required this.dark,
required this.surface,
});
final String name;
final String displayName;
final bool dark;
final SurfaceTokens surface;
}
class ThemeController extends ChangeNotifier {
ThemeController({
required List<ThemeDefinition> bundled,
ThemeResolver resolver = const ThemeResolver(),
String? initialName,
}) : _resolver = resolver,
_defs = Map.fromEntries(bundled.map((d) => MapEntry(d.name, d))) {
final first = initialName != null && _defs.containsKey(initialName)
? initialName
: bundled.first.name;
_currentName = first;
_current = _build(first);
}
final ThemeResolver _resolver;
final Map<String, ThemeDefinition> _defs;
late String _currentName;
late ClideThemeData _current;
ClideThemeData get current => _current;
String get currentName => _currentName;
List<ThemeDefinition> get available => _defs.values.toList(growable: false);
void select(String name) {
if (!_defs.containsKey(name)) {
throw ArgumentError('Unknown theme: $name');
}
if (name == _currentName) return;
_currentName = name;
_current = _build(name);
notifyListeners();
}
void registerTheme(ThemeDefinition def) {
_defs[def.name] = def;
// If the user re-imported the current theme, rebuild so overrides
// take effect without a select().
if (def.name == _currentName) {
_current = _build(def.name);
notifyListeners();
}
}
ClideThemeData _build(String name) {
final def = _defs[name]!;
final tokens = _resolver.resolve(
palette: def.palette,
semanticOverride: def.semanticOverride,
surfaceOverride: def.surfaceOverride,
extensionOverride: def.extensionOverride,
);
return ClideThemeData(
name: def.name,
displayName: def.displayName,
dark: def.dark,
surface: tokens,
);
}
}
class ClideTheme extends InheritedNotifier<ThemeController> {
const ClideTheme({
super.key,
required ThemeController controller,
required super.child,
}) : super(notifier: controller);
static ClideThemeData of(BuildContext context) {
final w = context.dependOnInheritedWidgetOfExactType<ClideTheme>();
if (w == null) {
throw FlutterError(
'ClideTheme.of() called with a context that is not a descendant of a ClideTheme.');
}
return w.notifier!.current;
}
static ThemeController controllerOf(BuildContext context) {
final w = context.dependOnInheritedWidgetOfExactType<ClideTheme>();
if (w == null) {
throw FlutterError(
'ClideTheme.controllerOf() called with a context that is not a descendant of a ClideTheme.');
}
return w.notifier!;
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'dart:io';
import 'package:clide/kernel/src/theme/palette.dart';
import 'package:clide/kernel/src/theme/semantic.dart';
import 'package:flutter/services.dart';
import 'package:yaml/yaml.dart';
class ThemeDefinition {
const ThemeDefinition({
required this.name,
required this.displayName,
required this.dark,
required this.palette,
this.semanticOverride,
this.surfaceOverride,
this.extensionOverride,
});
final String name;
final String displayName;
final bool dark;
final Palette palette;
final SemanticRoles? semanticOverride;
final Map<String, String>? surfaceOverride;
final Map<String, String>? extensionOverride;
}
class ThemeLoader {
const ThemeLoader();
ThemeDefinition fromYamlString(String text, {String? fallbackName}) {
final doc = loadYaml(text);
if (doc is! Map) {
throw FormatException('Theme root is not a map');
}
final name = (doc['name'] as String?) ?? fallbackName;
if (name == null || name.isEmpty) {
throw const FormatException('Theme missing `name`');
}
final displayName = (doc['display_name'] as String?) ?? name;
final dark = (doc['dark'] as bool?) ?? true;
final paletteYaml = doc['palette'];
if (paletteYaml is! Map) {
throw const FormatException('Theme missing `palette`');
}
final palette = _parsePalette(paletteYaml);
// Syntax colours inject into the surface override layer so
// TokenKeys.syntax* resolve directly from the theme YAML.
final syntaxYaml = doc['syntax'];
final syntaxSurface = <String, String>{};
if (syntaxYaml is Map) {
const syntaxMap = {
'keyword': 'syntax.keyword',
'type': 'syntax.type',
'string': 'syntax.string',
'number': 'syntax.number',
'comment': 'syntax.comment',
'method': 'syntax.method',
'punct': 'syntax.punct',
};
syntaxYaml.forEach((k, v) {
final key = syntaxMap['$k'];
if (key != null && v is String) syntaxSurface[key] = v;
});
}
final semantic = doc['semantic'];
final surface = doc['surface'];
final extension = doc['extension'];
final mergedSurface = <String, String>{
...syntaxSurface,
if (surface is Map) ..._parseRefMap(surface),
};
return ThemeDefinition(
name: name,
displayName: displayName,
dark: dark,
palette: palette,
semanticOverride:
semantic is Map ? _parseSemantic(semantic, palette) : null,
surfaceOverride: mergedSurface.isNotEmpty ? mergedSurface : null,
extensionOverride: extension is Map ? _parseRefMap(extension) : null,
);
}
Future<ThemeDefinition> fromAsset(
AssetBundle bundle, String assetPath) async {
final txt = await bundle.loadString(assetPath);
final fallback = assetPath.split('/').last.replaceAll('.yaml', '');
return fromYamlString(txt, fallbackName: fallback);
}
Future<ThemeDefinition> fromFile(File f) async {
final txt = await f.readAsString();
final fallback = f.uri.pathSegments.last.replaceAll('.yaml', '');
return fromYamlString(txt, fallbackName: fallback);
}
}
Palette _parsePalette(Map src) {
final colors = <String, Color>{};
src.forEach((k, v) {
if (v is! String) return;
final c = Palette.parseHex(v);
if (c != null) colors['$k'] = c;
});
return Palette(colors);
}
SemanticRoles _parseSemantic(Map src, Palette palette) {
final roles = <String, Color>{};
src.forEach((k, v) {
if (v is! String) return;
final resolved =
v.startsWith('#') ? Palette.parseHex(v) : palette.lookup(v);
if (resolved != null) roles['$k'] = resolved;
});
return SemanticRoles(roles);
}
Map<String, String> _parseRefMap(Map src) {
final out = <String, String>{};
src.forEach((k, v) {
if (v is String) out['$k'] = v;
});
return out;
}
+22
View File
@@ -0,0 +1,22 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
@immutable
class Palette {
const Palette(this._colors);
final Map<String, Color> _colors;
Color? lookup(String name) => _colors[name];
Iterable<String> get names => _colors.keys;
static Color? parseHex(String s) {
var v = s.trim();
if (v.startsWith('#')) v = v.substring(1);
if (v.length == 6) v = 'FF$v';
if (v.length != 8) return null;
final n = int.tryParse(v, radix: 16);
if (n == null) return null;
return Color(n);
}
}
+266
View File
@@ -0,0 +1,266 @@
import 'dart:ui';
import 'package:clide/kernel/src/theme/palette.dart';
import 'package:clide/kernel/src/theme/semantic.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
/// Three-tier theme resolution.
///
/// palette (raw colors)
/// ↓ (ref-chain; defaults inherited)
/// semantic (role → palette)
/// ↓ (ref-chain; defaults inherited)
/// surface (token → semantic|palette|literal)
///
/// References take the form:
/// `semantic.<role>` — look up in [semantic]
/// `#rrggbb` / `#aarrggbb` — literal hex
/// bare name — palette lookup
class ThemeResolver {
const ThemeResolver();
SurfaceTokens resolve({
required Palette palette,
SemanticRoles? semanticOverride,
Map<String, String>? surfaceOverride,
Map<String, String>? extensionOverride,
}) {
final semantic = _buildSemantic(palette, semanticOverride);
final surface = <String, Color>{};
for (final key in TokenKeys.all) {
surface[key] = _resolveSurface(
key: key,
palette: palette,
semantic: semantic,
surfaceOverride: surfaceOverride,
);
}
final extTokens = <String, Color>{};
if (extensionOverride != null) {
for (final entry in extensionOverride.entries) {
final resolved = _resolveRef(entry.value, palette, semantic);
if (resolved != null) extTokens[entry.key] = resolved;
}
}
return SurfaceTokens(
globalForeground: surface[TokenKeys.globalForeground]!,
globalBackground: surface[TokenKeys.globalBackground]!,
globalBorder: surface[TokenKeys.globalBorder]!,
globalFocus: surface[TokenKeys.globalFocus]!,
globalTextMuted: surface[TokenKeys.globalTextMuted]!,
panelBackground: surface[TokenKeys.panelBackground]!,
panelBorder: surface[TokenKeys.panelBorder]!,
panelActiveBorder: surface[TokenKeys.panelActiveBorder]!,
panelHeader: surface[TokenKeys.panelHeader]!,
panelHeaderForeground: surface[TokenKeys.panelHeaderForeground]!,
sidebarBackground: surface[TokenKeys.sidebarBackground]!,
sidebarForeground: surface[TokenKeys.sidebarForeground]!,
sidebarItemHover: surface[TokenKeys.sidebarItemHover]!,
sidebarItemSelected: surface[TokenKeys.sidebarItemSelected]!,
sidebarSectionHeader: surface[TokenKeys.sidebarSectionHeader]!,
statusBarBackground: surface[TokenKeys.statusBarBackground]!,
statusBarForeground: surface[TokenKeys.statusBarForeground]!,
statusBarItemActiveBackground:
surface[TokenKeys.statusBarItemActiveBackground]!,
statusBarItemHoverBackground:
surface[TokenKeys.statusBarItemHoverBackground]!,
tabBarBackground: surface[TokenKeys.tabBarBackground]!,
tabActive: surface[TokenKeys.tabActive]!,
tabInactive: surface[TokenKeys.tabInactive]!,
tabActiveForeground: surface[TokenKeys.tabActiveForeground]!,
tabInactiveForeground: surface[TokenKeys.tabInactiveForeground]!,
tabActiveBorder: surface[TokenKeys.tabActiveBorder]!,
tabCloseHover: surface[TokenKeys.tabCloseHover]!,
buttonBackground: surface[TokenKeys.buttonBackground]!,
buttonForeground: surface[TokenKeys.buttonForeground]!,
buttonHoverBackground: surface[TokenKeys.buttonHoverBackground]!,
buttonActiveBackground: surface[TokenKeys.buttonActiveBackground]!,
buttonBorder: surface[TokenKeys.buttonBorder]!,
listItemBackground: surface[TokenKeys.listItemBackground]!,
listItemForeground: surface[TokenKeys.listItemForeground]!,
listItemHoverBackground: surface[TokenKeys.listItemHoverBackground]!,
listItemSelectedBackground:
surface[TokenKeys.listItemSelectedBackground]!,
listItemSelectedForeground:
surface[TokenKeys.listItemSelectedForeground]!,
scrollbarSlider: surface[TokenKeys.scrollbarSlider]!,
scrollbarSliderHover: surface[TokenKeys.scrollbarSliderHover]!,
scrollbarTrack: surface[TokenKeys.scrollbarTrack]!,
tooltipBackground: surface[TokenKeys.tooltipBackground]!,
tooltipForeground: surface[TokenKeys.tooltipForeground]!,
tooltipBorder: surface[TokenKeys.tooltipBorder]!,
dropdownBackground: surface[TokenKeys.dropdownBackground]!,
dropdownForeground: surface[TokenKeys.dropdownForeground]!,
dropdownBorder: surface[TokenKeys.dropdownBorder]!,
modalOverlayBackground: surface[TokenKeys.modalOverlayBackground]!,
modalSurfaceBackground: surface[TokenKeys.modalSurfaceBackground]!,
modalSurfaceBorder: surface[TokenKeys.modalSurfaceBorder]!,
dividerColor: surface[TokenKeys.dividerColor]!,
statusSuccess: surface[TokenKeys.statusSuccess]!,
statusWarning: surface[TokenKeys.statusWarning]!,
statusError: surface[TokenKeys.statusError]!,
statusInfo: surface[TokenKeys.statusInfo]!,
syntaxKeyword: surface[TokenKeys.syntaxKeyword]!,
syntaxType: surface[TokenKeys.syntaxType]!,
syntaxString: surface[TokenKeys.syntaxString]!,
syntaxNumber: surface[TokenKeys.syntaxNumber]!,
syntaxComment: surface[TokenKeys.syntaxComment]!,
syntaxMethod: surface[TokenKeys.syntaxMethod]!,
syntaxPunct: surface[TokenKeys.syntaxPunct]!,
extensionTokens: extTokens,
);
}
SemanticRoles _buildSemantic(Palette palette, SemanticRoles? override) {
final roles = <String, Color>{};
for (final role in SemanticKeys.all) {
final fromOverride = override?.lookup(role);
if (fromOverride != null) {
roles[role] = fromOverride;
continue;
}
for (final candidate in _defaultSemanticFallbacks[role] ?? [role]) {
final fromPalette = palette.lookup(candidate);
if (fromPalette != null) {
roles[role] = fromPalette;
break;
}
}
// If still unresolved, fall back to foreground/background so the
// theme never has a null surface color. Themes that omit these
// will land readable if uninspired.
roles.putIfAbsent(role, () {
return palette.lookup('foreground') ??
palette.lookup('background') ??
const Color(0xFFFFFFFF);
});
}
return SemanticRoles(roles);
}
Color _resolveSurface({
required String key,
required Palette palette,
required SemanticRoles semantic,
Map<String, String>? surfaceOverride,
}) {
final override = surfaceOverride?[key];
if (override != null) {
final resolved = _resolveRef(override, palette, semantic);
if (resolved != null) return resolved;
}
final candidates = _defaultSurfaceMap[key];
if (candidates != null) {
for (final ref in candidates) {
final resolved = _resolveRef(ref, palette, semantic);
if (resolved != null) return resolved;
}
}
return semantic.lookup(SemanticKeys.text) ?? const Color(0xFFFFFFFF);
}
Color? _resolveRef(String ref, Palette palette, SemanticRoles semantic) {
if (ref.startsWith('#')) return Palette.parseHex(ref);
if (ref.startsWith('semantic.')) {
return semantic.lookup(ref.substring('semantic.'.length));
}
return palette.lookup(ref);
}
}
/// Default palette names a semantic role will try, in order, when the
/// theme doesn't override the role explicitly.
const Map<String, List<String>> _defaultSemanticFallbacks = {
SemanticKeys.mainchrome: ['bgSunken', 'panel', 'surface', 'background'],
SemanticKeys.calltoaction: ['accent', 'primary'],
SemanticKeys.focus: ['accent', 'primary'],
SemanticKeys.background: ['bg', 'background'],
SemanticKeys.surface: ['surface', 'panel'],
SemanticKeys.text: ['textHi', 'foreground'],
SemanticKeys.textMuted: ['textDim', 'muted', 'secondary', 'foreground'],
SemanticKeys.success: ['ok', 'success'],
SemanticKeys.warning: ['warn', 'warning'],
SemanticKeys.error: ['err', 'error'],
SemanticKeys.info: ['info', 'primary'],
};
/// Default surface map. Every entry resolves through the semantic layer
/// where it makes sense; raw palette refs are used only where the
/// semantic layer doesn't have a role that fits.
const Map<String, List<String>> _defaultSurfaceMap = {
// global — try design keys first, then legacy semantic
TokenKeys.globalForeground: ['textHi', 'semantic.text'],
TokenKeys.globalBackground: ['bg', 'semantic.background'],
TokenKeys.globalBorder: ['border', 'semantic.surface'],
TokenKeys.globalFocus: ['accent', 'semantic.focus'],
TokenKeys.globalTextMuted: ['textDim', 'semantic.text_muted'],
// panel
TokenKeys.panelBackground: ['bgSunken', 'semantic.mainchrome'],
TokenKeys.panelBorder: ['border', 'semantic.surface'],
TokenKeys.panelActiveBorder: ['borderHi', 'semantic.focus'],
TokenKeys.panelHeader: ['surface', 'semantic.mainchrome'],
TokenKeys.panelHeaderForeground: ['text', 'semantic.text'],
// sidebar
TokenKeys.sidebarBackground: ['bgSunken', 'semantic.mainchrome'],
TokenKeys.sidebarForeground: ['text', 'semantic.text'],
TokenKeys.sidebarItemHover: ['surface', 'semantic.surface'],
TokenKeys.sidebarItemSelected: ['surfaceHi', 'semantic.focus'],
TokenKeys.sidebarSectionHeader: ['textMute', 'semantic.text_muted'],
// statusbar
TokenKeys.statusBarBackground: ['bgSunken', 'semantic.mainchrome'],
TokenKeys.statusBarForeground: ['text', 'semantic.text'],
TokenKeys.statusBarItemActiveBackground: ['accent', 'semantic.focus'],
TokenKeys.statusBarItemHoverBackground: ['surface', 'semantic.surface'],
// tabs
TokenKeys.tabBarBackground: ['bgSunken', 'semantic.mainchrome'],
TokenKeys.tabActive: ['bg', 'semantic.background'],
TokenKeys.tabInactive: ['bgSunken', 'semantic.mainchrome'],
TokenKeys.tabActiveForeground: ['textHi', 'semantic.text'],
TokenKeys.tabInactiveForeground: ['textDim', 'semantic.text_muted'],
TokenKeys.tabActiveBorder: ['accent', 'semantic.focus'],
TokenKeys.tabCloseHover: ['err', 'semantic.error'],
// buttons
TokenKeys.buttonBackground: ['accent', 'semantic.calltoaction'],
TokenKeys.buttonForeground: ['onAccent', 'semantic.background'],
TokenKeys.buttonHoverBackground: ['accentPress', 'semantic.focus'],
TokenKeys.buttonActiveBackground: ['accentPress', 'semantic.focus'],
TokenKeys.buttonBorder: ['border', 'semantic.surface'],
// list items
TokenKeys.listItemBackground: ['bg', 'semantic.background'],
TokenKeys.listItemForeground: ['text', 'semantic.text'],
TokenKeys.listItemHoverBackground: ['surface', 'semantic.surface'],
TokenKeys.listItemSelectedBackground: ['surfaceHi', 'semantic.focus'],
TokenKeys.listItemSelectedForeground: ['textHi', 'semantic.text'],
// scrollbar
TokenKeys.scrollbarSlider: ['border', 'semantic.surface'],
TokenKeys.scrollbarSliderHover: ['borderHi', 'semantic.text_muted'],
TokenKeys.scrollbarTrack: ['bgSunken', 'semantic.mainchrome'],
// tooltip
TokenKeys.tooltipBackground: ['surface', 'semantic.surface'],
TokenKeys.tooltipForeground: ['textHi', 'semantic.text'],
TokenKeys.tooltipBorder: ['borderHi', 'semantic.mainchrome'],
// dropdown
TokenKeys.dropdownBackground: ['surface', 'semantic.surface'],
TokenKeys.dropdownForeground: ['text', 'semantic.text'],
TokenKeys.dropdownBorder: ['border', 'semantic.mainchrome'],
// modal
TokenKeys.modalOverlayBackground: ['#C0000000'],
TokenKeys.modalSurfaceBackground: ['surface', 'semantic.mainchrome'],
TokenKeys.modalSurfaceBorder: ['accent', 'semantic.focus'],
// divider
TokenKeys.dividerColor: ['border', 'semantic.surface'],
// status
TokenKeys.statusSuccess: ['ok', 'semantic.success'],
TokenKeys.statusWarning: ['warn', 'semantic.warning'],
TokenKeys.statusError: ['err', 'semantic.error'],
TokenKeys.statusInfo: ['info', 'semantic.info'],
TokenKeys.syntaxKeyword: ['semantic.calltoaction'],
TokenKeys.syntaxType: ['semantic.info'],
TokenKeys.syntaxString: ['semantic.success'],
TokenKeys.syntaxNumber: ['semantic.warning'],
TokenKeys.syntaxComment: ['semantic.text_muted'],
TokenKeys.syntaxMethod: ['semantic.focus'],
TokenKeys.syntaxPunct: ['semantic.text_muted'],
};
+40
View File
@@ -0,0 +1,40 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
@immutable
class SemanticRoles {
const SemanticRoles(this._roles);
final Map<String, Color> _roles;
Color? lookup(String role) => _roles[role];
Iterable<String> get roles => _roles.keys;
}
abstract class SemanticKeys {
static const mainchrome = 'mainchrome';
static const calltoaction = 'calltoaction';
static const focus = 'focus';
static const background = 'background';
static const surface = 'surface';
static const text = 'text';
static const textMuted = 'text_muted';
static const success = 'success';
static const warning = 'warning';
static const error = 'error';
static const info = 'info';
static const all = <String>[
mainchrome,
calltoaction,
focus,
background,
surface,
text,
textMuted,
success,
warning,
error,
info,
];
}
+36
View File
@@ -0,0 +1,36 @@
# clide — cool near-black + periwinkle (default)
# Source: docs/claude-design/tokens/clide.yaml
name: clide
display_name: Clide
dark: true
palette:
background: "#20202C"
panel: "#1A1A24"
surface: "#242838"
muted: "#78809C"
foreground: "#E6E8F2"
secondary: "#B1BBE3"
primary: "#78A0F8"
accent: "#6C90DC"
success: "#7DD3A8"
warning: "#E6C370"
error: "#E87D7D"
info: "#78A0F8"
# extended palette keys for the design's richer vocabulary
surfaceHi: "#2C3046"
border: "#343850"
borderHi: "#3C445C"
textDim: "#8890AC"
textMute: "#545C84"
accentSoft: "#2178A0F8"
syntax:
keyword: "#C792EA"
type: "#78A0F8"
string: "#A8D99B"
number: "#E6C370"
comment: "#545C84"
method: "#82B1FF"
punct: "#78809C"
+36
View File
@@ -0,0 +1,36 @@
# midnight — VS Code-adjacent muted dark
# Source: docs/claude-design/tokens/midnight.yaml
name: midnight
display_name: Midnight
dark: true
palette:
background: "#1E1E1E"
panel: "#181818"
surface: "#252526"
muted: "#858585"
foreground: "#D4D4D4"
secondary: "#BBBBBB"
primary: "#569CD6"
accent: "#4785BD"
success: "#89D185"
warning: "#D7BA7D"
error: "#F48771"
info: "#569CD6"
onAccent: "#0B1220"
surfaceHi: "#2D2D2E"
border: "#333333"
borderHi: "#3F3F3F"
textDim: "#858585"
textMute: "#6A6A6A"
accentSoft: "#21569CD6"
syntax:
keyword: "#C586C0"
type: "#4EC9B0"
string: "#CE9178"
number: "#B5CEA8"
comment: "#6A9955"
method: "#DCDCAA"
punct: "#858585"
+35
View File
@@ -0,0 +1,35 @@
# paper — drafting sheet, red-pencil accent, light
# Source: docs/claude-design/tokens/paper.yaml
name: paper
display_name: Paper
dark: false
palette:
background: "#F4F1EA"
panel: "#ECE7DB"
surface: "#FBF8F1"
muted: "#8A8A82"
foreground: "#1A1A1A"
secondary: "#4A4A4A"
primary: "#C14B2A"
accent: "#A03D20"
success: "#2D8A52"
warning: "#B88A2A"
error: "#B03A2A"
info: "#2A6FC1"
surfaceHi: "#ECE7DB"
border: "#1A1A1A"
borderHi: "#4A4A4A"
textDim: "#5E5E56"
textMute: "#A8A89E"
accentSoft: "#21C14B2A"
syntax:
keyword: "#7B3F8C"
type: "#2A6FC1"
string: "#2D8A52"
number: "#B88A2A"
comment: "#8A8A82"
method: "#1E5D9E"
punct: "#4A4A4A"
@@ -0,0 +1,29 @@
# Summer Night — ported from legacy clide v1.2.0.
# Palette-only; the three-tier resolver fills semantic + surface from
# defaults. Override sections land here as the token surface grows.
name: summer-night
display_name: Summer Night
dark: true
palette:
# Accents (legacy names: primary/secondary/accent)
primary: "#00a3d2" # cyan
secondary: "#00a9b9" # teal
accent: "#fa5f8b" # pink
# Backgrounds
background: "#21262f"
surface: "#393e48"
panel: "#292e38"
# Text. `muted` is WCAG-AA-calibrated against `panel` — don't darken
# without re-running the a11y/contrast suite.
foreground: "#e2e8f5"
muted: "#a6adbb"
# Status
success: "#00ab9a"
warning: "#d08447"
error: "#f06c6f"
info: "#00a3d2"
+35
View File
@@ -0,0 +1,35 @@
# terminal — near-black + amber, tmux feel
# Source: docs/claude-design/tokens/terminal.yaml
name: terminal
display_name: Terminal
dark: true
palette:
background: "#0A0A0A"
panel: "#000000"
surface: "#111111"
muted: "#7A7A7A"
foreground: "#E6E6E6"
secondary: "#BDBDBD"
primary: "#E0B050"
accent: "#C29438"
success: "#8FDC9B"
warning: "#E0B050"
error: "#E05050"
info: "#A3C4FF"
surfaceHi: "#181818"
border: "#242424"
borderHi: "#2E2E2E"
textDim: "#7A7A7A"
textMute: "#4A4A4A"
accentSoft: "#21E0B050"
syntax:
keyword: "#E05050"
type: "#E0B050"
string: "#8FDC9B"
number: "#C792EA"
comment: "#4A4A4A"
method: "#A3C4FF"
punct: "#7A7A7A"
+325
View File
@@ -0,0 +1,325 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
/// Resolved surface tokens — the only thing widgets consume.
///
/// The token surface grows as features need more of it. Every token
/// declared here must have a default resolution in
/// [DefaultSurfaceMap] so legacy palette-only themes produce a complete
/// SurfaceTokens without declaring the full surface.
@immutable
class SurfaceTokens {
const SurfaceTokens({
// global
required this.globalForeground,
required this.globalBackground,
required this.globalBorder,
required this.globalFocus,
required this.globalTextMuted,
// panel
required this.panelBackground,
required this.panelBorder,
required this.panelActiveBorder,
required this.panelHeader,
required this.panelHeaderForeground,
// sidebar
required this.sidebarBackground,
required this.sidebarForeground,
required this.sidebarItemHover,
required this.sidebarItemSelected,
required this.sidebarSectionHeader,
// statusbar
required this.statusBarBackground,
required this.statusBarForeground,
required this.statusBarItemActiveBackground,
required this.statusBarItemHoverBackground,
// tabs
required this.tabBarBackground,
required this.tabActive,
required this.tabInactive,
required this.tabActiveForeground,
required this.tabInactiveForeground,
required this.tabActiveBorder,
required this.tabCloseHover,
// buttons
required this.buttonBackground,
required this.buttonForeground,
required this.buttonHoverBackground,
required this.buttonActiveBackground,
required this.buttonBorder,
// list items
required this.listItemBackground,
required this.listItemForeground,
required this.listItemHoverBackground,
required this.listItemSelectedBackground,
required this.listItemSelectedForeground,
// scrollbar
required this.scrollbarSlider,
required this.scrollbarSliderHover,
required this.scrollbarTrack,
// tooltip
required this.tooltipBackground,
required this.tooltipForeground,
required this.tooltipBorder,
// dropdown
required this.dropdownBackground,
required this.dropdownForeground,
required this.dropdownBorder,
// modal
required this.modalOverlayBackground,
required this.modalSurfaceBackground,
required this.modalSurfaceBorder,
// divider
required this.dividerColor,
// status
required this.statusSuccess,
required this.statusWarning,
required this.statusError,
required this.statusInfo,
// syntax
required this.syntaxKeyword,
required this.syntaxType,
required this.syntaxString,
required this.syntaxNumber,
required this.syntaxComment,
required this.syntaxMethod,
required this.syntaxPunct,
required this.extensionTokens,
});
final Color globalForeground;
final Color globalBackground;
final Color globalBorder;
final Color globalFocus;
final Color globalTextMuted;
final Color panelBackground;
final Color panelBorder;
final Color panelActiveBorder;
final Color panelHeader;
final Color panelHeaderForeground;
final Color sidebarBackground;
final Color sidebarForeground;
final Color sidebarItemHover;
final Color sidebarItemSelected;
final Color sidebarSectionHeader;
final Color statusBarBackground;
final Color statusBarForeground;
final Color statusBarItemActiveBackground;
final Color statusBarItemHoverBackground;
final Color tabBarBackground;
final Color tabActive;
final Color tabInactive;
final Color tabActiveForeground;
final Color tabInactiveForeground;
final Color tabActiveBorder;
final Color tabCloseHover;
final Color buttonBackground;
final Color buttonForeground;
final Color buttonHoverBackground;
final Color buttonActiveBackground;
final Color buttonBorder;
final Color listItemBackground;
final Color listItemForeground;
final Color listItemHoverBackground;
final Color listItemSelectedBackground;
final Color listItemSelectedForeground;
final Color scrollbarSlider;
final Color scrollbarSliderHover;
final Color scrollbarTrack;
final Color tooltipBackground;
final Color tooltipForeground;
final Color tooltipBorder;
final Color dropdownBackground;
final Color dropdownForeground;
final Color dropdownBorder;
final Color modalOverlayBackground;
final Color modalSurfaceBackground;
final Color modalSurfaceBorder;
final Color dividerColor;
final Color statusSuccess;
final Color statusWarning;
final Color statusError;
final Color statusInfo;
final Color syntaxKeyword;
final Color syntaxType;
final Color syntaxString;
final Color syntaxNumber;
final Color syntaxComment;
final Color syntaxMethod;
final Color syntaxPunct;
/// Extension-declared tokens keyed by their dotted path
/// (e.g. `ext.sqlite.table.background`).
final Map<String, Color> extensionTokens;
}
/// Canonical surface-token keys as they appear in YAML.
///
/// Keeping them in one place lets the loader, the resolver, and the
/// default map reference the same strings without typos.
abstract class TokenKeys {
// global
static const globalForeground = 'global.foreground';
static const globalBackground = 'global.background';
static const globalBorder = 'global.border';
static const globalFocus = 'global.focus';
static const globalTextMuted = 'global.textMuted';
// panel
static const panelBackground = 'panel.background';
static const panelBorder = 'panel.border';
static const panelActiveBorder = 'panel.activeBorder';
static const panelHeader = 'panel.header';
static const panelHeaderForeground = 'panel.headerForeground';
// sidebar
static const sidebarBackground = 'sidebar.background';
static const sidebarForeground = 'sidebar.foreground';
static const sidebarItemHover = 'sidebar.itemHover';
static const sidebarItemSelected = 'sidebar.itemSelected';
static const sidebarSectionHeader = 'sidebar.sectionHeader';
// statusbar
static const statusBarBackground = 'statusBar.background';
static const statusBarForeground = 'statusBar.foreground';
static const statusBarItemActiveBackground = 'statusBar.itemActiveBackground';
static const statusBarItemHoverBackground = 'statusBar.itemHoverBackground';
// tabs
static const tabBarBackground = 'tabBar.background';
static const tabActive = 'tabBar.tabActive';
static const tabInactive = 'tabBar.tabInactive';
static const tabActiveForeground = 'tabBar.tabActiveForeground';
static const tabInactiveForeground = 'tabBar.tabInactiveForeground';
static const tabActiveBorder = 'tabBar.tabActiveBorder';
static const tabCloseHover = 'tabBar.tabCloseHover';
// buttons
static const buttonBackground = 'button.background';
static const buttonForeground = 'button.foreground';
static const buttonHoverBackground = 'button.hoverBackground';
static const buttonActiveBackground = 'button.activeBackground';
static const buttonBorder = 'button.border';
// list items
static const listItemBackground = 'listItem.background';
static const listItemForeground = 'listItem.foreground';
static const listItemHoverBackground = 'listItem.hoverBackground';
static const listItemSelectedBackground = 'listItem.selectedBackground';
static const listItemSelectedForeground = 'listItem.selectedForeground';
// scrollbar
static const scrollbarSlider = 'scrollbar.slider';
static const scrollbarSliderHover = 'scrollbar.sliderHover';
static const scrollbarTrack = 'scrollbar.track';
// tooltip
static const tooltipBackground = 'tooltip.background';
static const tooltipForeground = 'tooltip.foreground';
static const tooltipBorder = 'tooltip.border';
// dropdown
static const dropdownBackground = 'dropdown.background';
static const dropdownForeground = 'dropdown.foreground';
static const dropdownBorder = 'dropdown.border';
// modal
static const modalOverlayBackground = 'modal.overlayBackground';
static const modalSurfaceBackground = 'modal.surfaceBackground';
static const modalSurfaceBorder = 'modal.surfaceBorder';
// divider
static const dividerColor = 'divider.color';
// status
static const statusSuccess = 'status.success';
static const statusWarning = 'status.warning';
static const statusError = 'status.error';
static const statusInfo = 'status.info';
// syntax
static const syntaxKeyword = 'syntax.keyword';
static const syntaxType = 'syntax.type';
static const syntaxString = 'syntax.string';
static const syntaxNumber = 'syntax.number';
static const syntaxComment = 'syntax.comment';
static const syntaxMethod = 'syntax.method';
static const syntaxPunct = 'syntax.punct';
static const all = <String>[
globalForeground,
globalBackground,
globalBorder,
globalFocus,
globalTextMuted,
panelBackground,
panelBorder,
panelActiveBorder,
panelHeader,
panelHeaderForeground,
sidebarBackground,
sidebarForeground,
sidebarItemHover,
sidebarItemSelected,
sidebarSectionHeader,
statusBarBackground,
statusBarForeground,
statusBarItemActiveBackground,
statusBarItemHoverBackground,
tabBarBackground,
tabActive,
tabInactive,
tabActiveForeground,
tabInactiveForeground,
tabActiveBorder,
tabCloseHover,
buttonBackground,
buttonForeground,
buttonHoverBackground,
buttonActiveBackground,
buttonBorder,
listItemBackground,
listItemForeground,
listItemHoverBackground,
listItemSelectedBackground,
listItemSelectedForeground,
scrollbarSlider,
scrollbarSliderHover,
scrollbarTrack,
tooltipBackground,
tooltipForeground,
tooltipBorder,
dropdownBackground,
dropdownForeground,
dropdownBorder,
modalOverlayBackground,
modalSurfaceBackground,
modalSurfaceBorder,
dividerColor,
statusSuccess,
statusWarning,
statusError,
statusInfo,
syntaxKeyword,
syntaxType,
syntaxString,
syntaxNumber,
syntaxComment,
syntaxMethod,
syntaxPunct,
];
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:clide/extension/src/contribution.dart';
import 'package:flutter/foundation.dart';
/// Tier-0 stub for OS tray / menu-bar integration.
///
/// Flutter desktop tray requires platform-channel wiring; this registry
/// holds the contributions so extensions can declare them today. Real
/// OS integration lands with a small per-platform channel in a later
/// tier.
class TrayRegistry extends ChangeNotifier {
final Map<String, TrayItemContribution> _items = {};
void add(TrayItemContribution item) {
_items[item.id] = item;
notifyListeners();
}
void remove(String id) {
if (_items.remove(id) != null) notifyListeners();
}
Iterable<TrayItemContribution> get items {
final sorted = _items.values.toList()
..sort((a, b) => a.priority.compareTo(b.priority));
return sorted;
}
}