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:
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 180–400)
|
||||
/// center flex (workspace on top, statusbar below)
|
||||
/// context 280 (drag 220–420)
|
||||
/// 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user