scaffold Flutter app — kernel, extensions, widgets, Tier 0 built-ins

First real content under app/. Lays the whole Tier 0 foundation in one
commit because the pieces depend on each other circularly (kernel →
extension → widgets → built-ins all reference types from the layer
below); splitting would leave intermediate commits that don't compile.

Key shapes:

  * bare WidgetsApp root — no Material, no Cupertino, no Scaffold.
    ClideTheme InheritedWidget is the only source of tokens.
  * ClideKernel InheritedWidget aggregates 18 services (settings,
    project, extensions, theme, panels, events, ipc, commands +
    palette + keybindings, clipboard, files, notify, dialog, tray,
    secrets, os, net, focus, log, i18n). ExtensionContext exposes
    them through a stable interface.
  * ClideExtension + sealed ContributionPoint hierarchy (Tab,
    StatusItem, Toolbar, Command, TrayItem, LayoutPreset). One
    manifest ships N contributions into kernel slots.
  * Three-tier theme pipeline: palette (named colors) → semantic
    roles → ~60 surface tokens. Defaults at each layer so legacy
    palette-only themes produce a complete SurfaceTokens.
  * A11y baked in from day one — every interactive primitive wraps
    in Semantics(label:, hint:, button:); ensureSemantics() at boot;
    theme/contrast.dart helper exposes token pairs for the WCAG
    gate in the a11y test suite.
  * i18n ported from fframe's L10n pattern (text-driven, namespaced
    JSON catalogs, caller-supplied placeholders) with a proper
    locale fallback chain (exact → language → default → placeholder)
    fframe lacks.
  * Four Tier-0 built-ins live (default-layout, welcome, ipc-status,
    theme-picker) plus 17 id-reserving stubs so later tiers fill in
    without rename churn.

.gitignore extended to cover app/ sub-package artefacts and the
Playwright harness scratch dirs.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-21 15:39:41 +02:00
co-authored by Claude
parent 235cbcc046
commit b13483e2d5
167 changed files with 8270 additions and 0 deletions
+19
View File
@@ -16,6 +16,25 @@
/bin/clide.exe
# pubspec.lock is committed (supply-chain policy).
# Flutter app sub-package under app/. Same patterns, different prefix.
app/.dart_tool/
app/.flutter-plugins
app/.flutter-plugins-dependencies
app/build/
app/*.iml
app/ios/Pods/
app/macos/Pods/
app/windows/flutter/ephemeral/
app/linux/flutter/ephemeral/
app/macos/Flutter/ephemeral/
# UI harness (Playwright) — npm + output artefacts.
tools/ui/node_modules/
tools/ui/out/
tools/ui/test-results/
tools/ui/playwright-report/
tools/ui/.serve.pid
# -- ptyc (C supporter tool) --------------------------------------------
/ptyc/bin/
/ptyc/*.o
+10
View File
@@ -30,6 +30,16 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
### Added
- Flutter desktop app scaffold under `app/` with a bare `WidgetsApp` root (no Material, no Cupertino) and the Tier 0 three-column layout.
- **Kernel** (`app/lib/kernel/`) — 18 services consumed by every extension: `settings` (scope-resolved get/set across `app.*`/`project.*`/`ext.*`), `project`, `extensions`, `theme`, `panels` (slot registry + arrangement), `events`, `ipc`, `commands` (+ palette + keybinding resolver), `clipboard`, `files`, `notify`, `dialog` (single-at-a-time modal router), `tray`, `secrets`, `os`, `net`, `focus`, and `log`. Unified in a `ClideKernel` `InheritedWidget`.
- **i18n** (`app/lib/kernel/src/i18n/`) — text-driven lookup ported from [fframe](https://github.com/postmeridiem/fframe)'s `L10n`: namespaced JSON catalogs, `string()` / `interpolated()` calls with caller-supplied placeholders, and a proper locale fallback chain (exact → language → default-country → default-language → placeholder). Improves on fframe's design by adding the chain, which fframe lacks.
- **A11y from Tier 0** — `Semantics(label:, hint:, button:)` on every interactive primitive; `SemanticsBinding.ensureSemantics()` at boot; a `theme/contrast.dart` helper exposes token pairs that the a11y suite walks for WCAG-AA compliance.
- **Extension contract** (`app/lib/extension/`) — abstract `ClideExtension`, sealed `ContributionPoint` hierarchy (`TabContribution`, `StatusItemContribution`, `ToolbarButtonContribution`, `CommandContribution`, `TrayItemContribution`, `LayoutPresetContribution`). Each extension ships one manifest contributing N atoms into kernel slots. Priority-based ordering within a slot, dependency-aware activation, YAML manifest loader + scanner for `~/.clide/extensions/`.
- **Three-tier theme pipeline** — palette (named colors) → semantic roles → ~60 VS-Code-style surface tokens, each layer with defaults so palette-only themes ship. Ported `summer-night` as the first bundled theme; muted value calibrated for WCAG-AA contrast.
- **Widget primitives** (`app/lib/widgets/`) — `ClideSurface`, `ClideText`, `ClideButton`, `ClideTabBar`, `ClideDivider`, `ClideScrollbar`, `ClideTooltip`, `ClideIcon` + eight `CustomPainter`-rendered icons (folder, gear, x, chevron-left/right, dot, check, plug). All token-consuming, all Semantics-wrapped.
- **Tier 0 built-in extensions** — `builtin.default-layout` (classic three-column preset + reset command), `builtin.welcome` (workspace placeholder), `builtin.ipc-status` (live-region statusbar indicator), `builtin.theme-picker` (command + modal, bound to `ctrl+k`). Plus 17 id-reserving stubs (`builtin.claude`, `builtin.terminal`, `builtin.files`, `builtin.editor`, `builtin.git`, ...) so later tiers can fill in without rename churn.
- **Lua runtime boundary** — `app/lib/lua/` ships as typed stubs (`host`, `adapter`, `capability_api`, `render_intent`) so third-party Lua extensions can plug in at Tier 6 without retrofitting.
- `.gitignore` extended to cover `app/` sub-package artefacts (`app/.dart_tool`, `app/build`, per-platform ephemeral dirs, `app/*.iml`) and the Playwright harness under `tools/ui/` (`node_modules`, `out`, test-results).
- Dart core package at the repo root: `bin/clide.dart` (one binary, `--daemon` and one-shot subcommand modes), `lib/clide.dart` barrel exporting the shared IPC types, `lib/src/ipc/` (`envelope.dart`, `server.dart`, `paths.dart`, `schema_v1.dart`), and `lib/src/daemon/dispatcher.dart`. `clide --daemon` listens on a unix socket; `clide ping` / `clide version` round-trip through it with the ADR 0006 exit-code contract (`0/1/2/3/4`). Includes `test/ipc/` and `test/daemon/` suites covering envelope parsing, the in-process server, and a subprocess smoke that verifies signal-driven shutdown + socket unlink.
- `scripts/bazzite-flutter-setup.sh` — one-shot installer for the Flutter SDK + desktop build deps on Bazzite / Fedora Silverblue. Drops the SDK under `~/opt/flutter`, wires PATH in the user's shell rc files, and layers the Linux desktop build deps via `rpm-ostree install`.
- [ADR 0005](docs/ADRs/0005-dart-core-ptyc-peer.md) — Dart core; sidecar directory dissolved; `ptyc` as pql-peer. Establishes one Dart AOT binary for both CLI and daemon, `lib/` as the shared core, and promotes the C PTY helper to a standalone supporter tool on the same footing as pql.
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
- platform: web
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+3
View File
@@ -0,0 +1,3 @@
# clide_app
A new Flutter project.
+1
View File
@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml
+249
View File
@@ -0,0 +1,249 @@
import 'package:clide_app/extension/src/contribution.dart';
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class ClideApp extends StatelessWidget {
const ClideApp({super.key, required this.services});
final KernelServices services;
@override
Widget build(BuildContext context) {
return ClideKernel(
services: services,
child: ClideTheme(
controller: services.theme,
child: _AppRoot(services: services),
),
);
}
}
class _AppRoot extends StatelessWidget {
const _AppRoot({required this.services});
final KernelServices services;
@override
Widget build(BuildContext context) {
return WidgetsApp(
title: 'clide',
color: const Color(0xFF000000),
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) =>
PageRouteBuilder<T>(
settings: settings,
pageBuilder: (ctx, _, __) => builder(ctx),
),
home: _RootShell(services: services),
);
}
}
class _RootShell extends StatefulWidget {
const _RootShell({required this.services});
final KernelServices services;
@override
State<_RootShell> createState() => _RootShellState();
}
class _RootShellState extends State<_RootShell> {
late final FocusNode _keyFocus;
@override
void initState() {
super.initState();
_keyFocus = FocusNode()..requestFocus();
}
@override
void dispose() {
_keyFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return DefaultTextStyle(
style: TextStyle(
color: tokens.globalForeground,
fontSize: 13,
fontFamilyFallback: const [
'Inter',
'Helvetica',
'Arial',
'sans-serif',
],
),
child: KeyboardListener(
focusNode: _keyFocus,
autofocus: true,
onKeyEvent: _onKey,
child: ColoredBox(
color: tokens.globalBackground,
child: DialogHost(
router: widget.services.dialog,
child: const RootLayout(),
),
),
),
);
}
void _onKey(KeyEvent event) {
final binding = KeybindingResolver.fromKeyEvent(
event,
HardwareKeyboard.instance,
);
if (binding == null) return;
final commandId = widget.services.keybindings.commandFor(binding);
if (commandId == null) return;
widget.services.commands.execute(commandId);
}
}
class RootLayout extends StatelessWidget {
const RootLayout({super.key});
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
return ListenableBuilder(
listenable: Listenable.merge([kernel.panels, kernel.arrangement]),
builder: (ctx, _) {
final a = kernel.arrangement;
final sidebarVisible = a.isVisible(Slots.sidebar);
final contextVisible = a.isVisible(Slots.contextPanel);
final statusVisible = a.isVisible(Slots.statusbar);
final sidebarSize = a.sizeOf(Slots.sidebar) ?? 240;
final contextSize = a.sizeOf(Slots.contextPanel) ?? 280;
final statusHeight = a.sizeOf(Slots.statusbar) ?? 26;
return Column(
children: [
Expanded(
child: Row(
children: [
if (sidebarVisible) ...[
SizedBox(
width: sidebarSize,
child: SlotHost(slot: Slots.sidebar),
),
DragResizeHandle(
arrangement: a,
slot: Slots.sidebar,
axis: Axis.horizontal,
),
],
const Expanded(child: SlotHost(slot: Slots.workspace)),
if (contextVisible) ...[
DragResizeHandle(
arrangement: a,
slot: Slots.contextPanel,
axis: Axis.horizontal,
),
SizedBox(
width: contextSize,
child: SlotHost(slot: Slots.contextPanel),
),
],
],
),
),
if (statusVisible)
SizedBox(
height: statusHeight,
child: const StatusbarHost(),
),
],
);
},
);
}
}
class SlotHost extends StatelessWidget {
const SlotHost({super.key, required this.slot});
final SlotId slot;
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: Listenable.merge([kernel.panels, kernel.i18n]),
builder: (ctx, _) {
final tabs = kernel.panels.tabsFor(slot);
if (tabs.isEmpty) {
return Container(color: tokens.panelBackground);
}
final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id;
final active = tabs.firstWhere(
(t) => t.id == activeId,
orElse: () => tabs.first,
);
return Container(
color: tokens.panelBackground,
child: Column(
children: [
ClideTabBar(
items: [
for (final t in tabs)
ClideTabItem(id: t.id, title: _resolveTitle(ctx, t)),
],
activeId: active.id,
onSelect: (id) => kernel.panels.activateTab(slot, id),
),
ClideDivider(),
Expanded(child: active.build(ctx)),
],
),
);
},
);
}
String _resolveTitle(BuildContext context, TabContribution t) {
final key = t.titleKey;
final ns = t.i18nNamespace;
if (key == null || ns == null) return t.title;
return ClideKernel.of(context).i18n.string(
key,
namespace: ns,
placeholder: t.title,
);
}
}
class StatusbarHost extends StatelessWidget {
const StatusbarHost({super.key});
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: kernel.panels,
builder: (ctx, _) {
final items = kernel.panels
.contributionsFor(Slots.statusbar)
.whereType<StatusItemContribution>()
.toList();
final left = items.where((i) => i.priority < 100).toList();
final right = items.where((i) => i.priority >= 100).toList();
return Container(
color: tokens.statusBarBackground,
child: Row(
children: [
for (final item in left) item.build(ctx),
const Spacer(),
for (final item in right) item.build(ctx),
],
),
);
},
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class CanvasExtension extends ClideExtension {
@override
String get id => 'builtin.canvas';
@override
String get title => 'Canvas';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class ClaudeExtension extends ClideExtension {
@override
String get id => 'builtin.claude';
@override
String get title => 'Claude Code';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,53 @@
import 'package:clide/clide.dart';
import 'package:clide_app/extension/extension.dart';
import 'package:clide_app/kernel/kernel.dart';
class DefaultLayoutExtension extends ClideExtension {
@override
String get id => 'builtin.default-layout';
@override
String get title => 'Default layout';
@override
String get version => '0.1.0';
LayoutPresetContribution? _preset;
ClideExtensionContext? _ctx;
@override
List<ContributionPoint> get contributions => [
_preset ?? classicPreset(),
CommandContribution(
id: 'layout.reset',
command: 'layout.reset',
title: 'Layout: Reset to Classic',
run: _reset,
),
];
@override
Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx;
_preset = classicPreset();
// Register the preset's slots into the panel registry and apply
// the arrangement so every SlotHost has something to render.
ctx.arrangement.registerSlotsInto(ctx.panels, _preset!);
ctx.arrangement.applyPreset(_preset!);
}
Future<IpcResponse> _reset(List<String> args) async {
final preset = _preset;
final ctx = _ctx;
if (preset == null || ctx == null) {
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'default-layout not activated',
),
);
}
ctx.arrangement.applyPreset(preset);
return IpcResponse.ok(id: '', data: {'preset': preset.id});
}
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class DiffExtension extends ClideExtension {
@override
String get id => 'builtin.diff';
@override
String get title => 'Diff';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class EditorExtension extends ClideExtension {
@override
String get id => 'builtin.editor';
@override
String get title => 'Editor';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class ExtensionsUiExtension extends ClideExtension {
@override
String get id => 'builtin.extensions-ui';
@override
String get title => 'Extensions UI';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class FilesExtension extends ClideExtension {
@override
String get id => 'builtin.files';
@override
String get title => 'Files';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class GitExtension extends ClideExtension {
@override
String get id => 'builtin.git';
@override
String get title => 'Git';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const ['builtin.diff'];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class GrammarsCoreExtension extends ClideExtension {
@override
String get id => 'builtin.grammars.core';
@override
String get title => 'Core grammars';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const ['builtin.editor'];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class GraphExtension extends ClideExtension {
@override
String get id => 'builtin.graph';
@override
String get title => 'Graph';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const ['builtin.pql'];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1,2 @@
export 'src/extension.dart';
export 'src/status_item.dart';
@@ -0,0 +1,33 @@
import 'package:clide_app/builtin/ipc_status/src/status_item.dart';
import 'package:clide_app/extension/extension.dart';
import 'package:clide_app/kernel/kernel.dart';
class IpcStatusExtension extends ClideExtension {
@override
String get id => 'builtin.ipc-status';
@override
String get title => 'Daemon connection';
@override
String get version => '0.1.0';
DaemonClient? _ipc;
@override
Future<void> activate(ClideExtensionContext ctx) async {
_ipc = ctx.ipc;
}
@override
List<ContributionPoint> get contributions {
final ipc = _ipc;
if (ipc == null) return const [];
return [
StatusItemContribution(
id: 'ipc-status.indicator',
priority: 100, // right-side
listenable: ipc,
build: (_) => IpcStatusItem(ipc: ipc),
),
];
}
}
@@ -0,0 +1,53 @@
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class IpcStatusItem extends StatelessWidget {
const IpcStatusItem({super.key, required this.ipc});
final DaemonClient ipc;
static const _ns = 'builtin.ipc-status';
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: Listenable.merge([ipc, kernel.i18n]),
builder: (ctx, _) {
final connected = ipc.isConnected;
final color = connected ? tokens.statusSuccess : tokens.statusError;
final i = kernel.i18n;
final label = connected
? i.string('connected', namespace: _ns, placeholder: 'connected')
: i.string('disconnected',
namespace: _ns, placeholder: 'disconnected');
final hint = connected
? i.string('connected.hint',
namespace: _ns,
placeholder: 'clide daemon is reachable over the local socket')
: i.string('disconnected.hint',
namespace: _ns,
placeholder:
'clide daemon is not running — start it with `clide --daemon`');
return Semantics(
label: label,
hint: hint,
liveRegion: true,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(const PlugIcon(), size: 12, color: color),
const SizedBox(width: 6),
ClideText(label, fontSize: 12, color: color),
],
),
),
);
},
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class JiraExtension extends ClideExtension {
@override
String get id => 'builtin.jira';
@override
String get title => 'Jira';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class KeybindingsUiExtension extends ClideExtension {
@override
String get id => 'builtin.keybindings-ui';
@override
String get title => 'Keybindings UI';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class MarkdownExtension extends ClideExtension {
@override
String get id => 'builtin.markdown';
@override
String get title => 'Markdown';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const ['builtin.editor'];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class PqlExtension extends ClideExtension {
@override
String get id => 'builtin.pql';
@override
String get title => 'pql';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class ProblemsExtension extends ClideExtension {
@override
String get id => 'builtin.problems';
@override
String get title => 'Problems';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class SettingsUiExtension extends ClideExtension {
@override
String get id => 'builtin.settings-ui';
@override
String get title => 'Settings UI';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class TerminalExtension extends ClideExtension {
@override
String get id => 'builtin.terminal';
@override
String get title => 'Terminal';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,53 @@
import 'package:clide/clide.dart';
import 'package:clide_app/builtin/theme_picker/src/picker_view.dart';
import 'package:clide_app/extension/extension.dart';
class ThemePickerExtension extends ClideExtension {
@override
String get id => 'builtin.theme-picker';
@override
String get title => 'Theme picker';
@override
String get version => '0.1.0';
ClideExtensionContext? _ctx;
@override
Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx;
}
@override
List<ContributionPoint> get contributions => [
CommandContribution(
id: 'theme.pick',
command: 'theme.pick',
title: 'Theme: Pick…',
defaultBinding: 'ctrl+k',
run: _pick,
),
];
Future<IpcResponse> _pick(List<String> args) async {
final ctx = _ctx;
if (ctx == null) {
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'theme-picker not activated',
),
);
}
final selected = await ctx.dialog.show<String>(
(context, dismiss) => ThemePickerView(
controller: ctx.theme,
onDismiss: dismiss,
),
);
return IpcResponse.ok(id: '', data: {
'selected': selected ?? ctx.theme.currentName,
});
}
}
@@ -0,0 +1,172 @@
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class ThemePickerView extends StatefulWidget {
const ThemePickerView({
super.key,
required this.controller,
required this.onDismiss,
});
final ThemeController controller;
final void Function([String? selected]) onDismiss;
static const ns = 'builtin.theme-picker';
@override
State<ThemePickerView> createState() => _ThemePickerViewState();
}
class _ThemePickerViewState extends State<ThemePickerView> {
String? _hovered;
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
final themes = widget.controller.available;
final currentName = widget.controller.currentName;
final i = kernel.i18n;
return Semantics(
container: true,
label: i.string('modal.title',
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
explicitChildNodes: true,
child: ClideSurface(
width: 420,
color: tokens.modalSurfaceBackground,
border: tokens.modalSurfaceBorder,
padding: const EdgeInsets.all(16),
borderRadius: BorderRadius.circular(4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClideText(
i.string('modal.title',
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
fontSize: 15,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 8),
ClideDivider(),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 360),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final t in themes)
_ThemeRow(
name: t.name,
displayName: t.displayName,
selected: t.name == currentName,
hovered: _hovered == t.name,
hint: i.string('row.select.hint',
namespace: ThemePickerView.ns,
placeholder: 'Activate this theme'),
onEnter: () => setState(() => _hovered = t.name),
onExit: () => setState(() => _hovered = null),
onTap: () {
widget.controller.select(t.name);
widget.onDismiss(t.name);
},
),
],
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(
label: i.string('modal.cancel',
namespace: ThemePickerView.ns, placeholder: 'Cancel'),
semanticHint: i.string('modal.cancel.hint',
namespace: ThemePickerView.ns,
placeholder:
'Close the theme picker without changing the current theme'),
onPressed: () => widget.onDismiss(),
),
],
),
],
),
),
);
}
}
class _ThemeRow extends StatelessWidget {
const _ThemeRow({
required this.name,
required this.displayName,
required this.selected,
required this.hovered,
required this.hint,
required this.onEnter,
required this.onExit,
required this.onTap,
});
final String name;
final String displayName;
final bool selected;
final bool hovered;
final String hint;
final VoidCallback onEnter;
final VoidCallback onExit;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final bg = selected
? tokens.listItemSelectedBackground
: (hovered
? tokens.listItemHoverBackground
: tokens.listItemBackground);
final fg = selected
? tokens.listItemSelectedForeground
: tokens.listItemForeground;
return Semantics(
button: true,
selected: selected,
label: displayName,
hint: hint,
onTap: onTap,
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => onEnter(),
onExit: (_) => onExit(),
child: GestureDetector(
onTap: onTap,
child: Container(
color: bg,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
if (selected)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideIcon(const CheckIcon(), size: 12, color: fg),
)
else
const SizedBox(width: 20),
Expanded(
child: ClideText(displayName, color: fg),
),
ClideText(name, color: tokens.globalTextMuted, fontSize: 11),
],
),
),
),
),
);
}
}
@@ -0,0 +1,2 @@
export 'src/extension.dart';
export 'src/picker_view.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class TodosExtension extends ClideExtension {
@override
String get id => 'builtin.todos';
@override
String get title => 'TODOs';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,35 @@
import 'package:clide/clide.dart';
import 'package:clide_app/builtin/welcome/src/welcome_view.dart';
import 'package:clide_app/extension/extension.dart';
import 'package:clide_app/kernel/kernel.dart';
class WelcomeExtension extends ClideExtension {
@override
String get id => 'builtin.welcome';
@override
String get title => 'Welcome';
@override
String get version => '0.1.0';
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'welcome.view',
slot: Slots.workspace,
title: 'Welcome',
titleKey: 'tab.title',
i18nNamespace: id,
priority: -100, // anchor at the far left of the workspace tabs
build: (_) => const WelcomeView(),
),
CommandContribution(
id: 'workspace.open-project',
command: 'workspace.open-project',
title: 'Workspace: Open project…',
run: (_) async => IpcResponse.ok(
id: '',
data: const {'note': 'project picker lands in a later tier'},
),
),
];
}
@@ -0,0 +1,67 @@
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class WelcomeView extends StatelessWidget {
const WelcomeView({super.key});
static const _ns = 'builtin.welcome';
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: kernel.i18n,
builder: (ctx, _) {
final i = kernel.i18n;
return ClideSurface(
color: tokens.globalBackground,
padding: const EdgeInsets.all(32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ClideText(
i.string('title', namespace: _ns, placeholder: 'clide'),
fontSize: 40,
fontWeight: FontWeight.w300,
color: tokens.panelActiveBorder,
),
const SizedBox(height: 12),
ClideText(
i.string(
'subtitle',
namespace: _ns,
placeholder: 'Flutter desktop IDE for Claude Code',
),
muted: true,
fontSize: 14,
),
const SizedBox(height: 32),
ClideButton(
label: i.string(
'open-project',
namespace: _ns,
placeholder: 'Open project',
),
semanticHint: i.string(
'open-project.hint',
namespace: _ns,
placeholder:
'Pick a git repository to open as the workspace',
),
variant: ClideButtonVariant.primary,
onPressed: () {
// project open UI lands with the project picker tier
},
),
],
),
),
);
},
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+11
View File
@@ -0,0 +1,11 @@
/// clide extension contract.
///
/// ClideExtension = shipping unit. ContributionPoint = atom contributed
/// into a kernel slot or service. One manifest may contribute N atoms
/// across multiple slots.
library;
export 'src/contribution.dart';
export 'src/extension.dart';
export 'src/host.dart';
export 'src/manifest.dart';
+157
View File
@@ -0,0 +1,157 @@
import 'package:clide/clide.dart';
import 'package:clide_app/kernel/src/panels/slot_id.dart';
import 'package:flutter/widgets.dart';
/// One atom contributed by a [ClideExtension]. Extensions declare N of
/// these in a manifest; the kernel and slot hosts render them.
///
/// Adding a new contribution type: add a case to this sealed hierarchy,
/// extend the host dispatch in the default-layout extension, and bump
/// the extension manifest schema version.
sealed class ContributionPoint {
const ContributionPoint({required this.id});
/// Stable id for this contribution, unique within its extension.
final String id;
/// The slot this contribution targets, or `null` for non-slot
/// contributions (commands, events, grammars).
SlotId? get slot => null;
}
/// A tab in a slot that hosts tabs (sidebar / workspace / context).
class TabContribution extends ContributionPoint {
const TabContribution({
required super.id,
required this.slot,
required this.title,
required this.build,
this.icon,
this.priority = 0,
this.fileGlobs = const [],
this.listenable,
this.titleKey,
this.i18nNamespace,
});
@override
final SlotId slot;
final String title;
final WidgetBuilder build;
final Object? icon;
final int priority;
final List<String> fileGlobs;
final Listenable? listenable;
/// When set, the slot host resolves the display title via
/// `i18n.string(titleKey, namespace: i18nNamespace, placeholder: title)`.
/// [title] stays as the English fallback (also used in tests/logs).
final String? titleKey;
/// The i18n namespace to look up [titleKey] in. Extensions usually
/// pass their own `id`. Required when [titleKey] is set.
final String? i18nNamespace;
}
/// A status-bar item. Order is determined by [priority] within each
/// alignment group; negative priorities float left, positive right.
class StatusItemContribution extends ContributionPoint {
const StatusItemContribution({
required super.id,
required this.build,
this.priority = 0,
this.listenable,
});
@override
SlotId get slot => Slots.statusbar;
final WidgetBuilder build;
final int priority;
final Listenable? listenable;
}
/// A button in the main toolbar.
class ToolbarButtonContribution extends ContributionPoint {
const ToolbarButtonContribution({
required super.id,
required this.label,
required this.onPressed,
this.icon,
this.tooltip,
this.priority = 0,
});
@override
SlotId get slot => Slots.toolbar;
final String label;
final Object? icon;
final String? tooltip;
final int priority;
final VoidCallback onPressed;
}
/// A command extensions register with [CommandRegistry]. Surfaced by the
/// command palette, the keybinding resolver, and `clide` CLI subcommands.
class CommandContribution extends ContributionPoint {
const CommandContribution({
required super.id,
required this.command,
required this.run,
this.title,
this.defaultBinding,
});
final String command; // e.g. "git.commit"
final String? title; // "Git: Commit staged"
final String? defaultBinding; // e.g. "ctrl+shift+g"
final Future<IpcResponse> Function(List<String> args) run;
}
/// Registers an item in the OS tray / menu-bar.
class TrayItemContribution extends ContributionPoint {
const TrayItemContribution({
required super.id,
required this.label,
required this.onSelected,
this.priority = 0,
});
@override
SlotId get slot => Slots.tray;
final String label;
final int priority;
final VoidCallback onSelected;
}
/// A named layout arrangement. One "classic" preset ships with
/// `builtin.default-layout`; other presets can be contributed.
class LayoutPresetContribution extends ContributionPoint {
const LayoutPresetContribution({
required super.id,
required this.displayName,
required this.slots,
});
final String displayName;
final List<LayoutSlot> slots;
}
/// One slot in a [LayoutPresetContribution]. Describes where the slot
/// appears and its initial size/visibility.
class LayoutSlot {
const LayoutSlot({
required this.slot,
required this.position,
this.defaultSize,
this.minSize,
this.maxSize,
this.visible = true,
});
final SlotId slot;
final SlotPosition position;
final double? defaultSize;
final double? minSize;
final double? maxSize;
final bool visible;
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:clide_app/extension/src/contribution.dart';
import 'package:clide_app/kernel/src/clipboard.dart';
import 'package:clide_app/kernel/src/commands/palette.dart';
import 'package:clide_app/kernel/src/commands/registry.dart';
import 'package:clide_app/kernel/src/dialog.dart';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/files.dart';
import 'package:clide_app/kernel/src/focus.dart';
import 'package:clide_app/kernel/src/i18n/i18n.dart';
import 'package:clide_app/kernel/src/ipc/client.dart';
import 'package:clide_app/kernel/src/log.dart';
import 'package:clide_app/kernel/src/net.dart';
import 'package:clide_app/kernel/src/notify.dart';
import 'package:clide_app/kernel/src/os.dart';
import 'package:clide_app/kernel/src/panels/arrangement.dart';
import 'package:clide_app/kernel/src/panels/registry.dart';
import 'package:clide_app/kernel/src/project.dart';
import 'package:clide_app/kernel/src/secrets.dart';
import 'package:clide_app/kernel/src/settings.dart';
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:clide_app/kernel/src/tray.dart';
/// One shipping unit. Built-in extensions compile in as Dart subclasses;
/// third-party extensions run as Lua scripts wrapped by a `LuaExtension`
/// adapter (Tier 6).
abstract class ClideExtension {
String get id;
String get title;
String get version;
/// IDs of other extensions that must be activated before this one.
/// Missing deps → this extension is skipped at load with a warning.
List<String> get dependsOn => const [];
/// The atoms this extension contributes.
List<ContributionPoint> get contributions;
/// Called once after dependencies activate.
Future<void> activate(ClideExtensionContext ctx) async {}
/// Called when the extension is disabled or the app shuts down.
Future<void> deactivate() async {}
}
/// Handed to every [ClideExtension.activate]. Lists every kernel service
/// an extension may reach. The extension manager constructs a concrete
/// instance with refs; tests can pass fakes.
///
/// The interface deliberately lists services individually rather than
/// exposing a `KernelServices` aggregate — doing so would create an
/// import cycle between the kernel facade and this file.
abstract class ClideExtensionContext {
String get id;
Logger get log;
EventBus get events;
SettingsStore get settings;
ThemeController get theme;
I18n get i18n;
PanelRegistry get panels;
LayoutArrangement get arrangement;
CommandRegistry get commands;
PaletteController get palette;
ClideClipboard get clipboard;
FileServices get files;
Notifications get notify;
DialogRouter get dialog;
TrayRegistry get tray;
SecretsVault get secrets;
OsBridge get os;
NetworkStatus get net;
FocusTracker get focus;
ProjectManager get project;
DaemonClient get ipc;
}
/// Sugar for i18n lookups scoped to this extension's namespace.
extension ClideExtensionContextI18n on ClideExtensionContext {
/// `ctx.t('welcome.title', placeholder: 'clide')` →
/// `i18n.string('welcome.title', namespace: id, placeholder: 'clide')`.
String t(String key, {String? placeholder}) =>
i18n.string(key, namespace: id, placeholder: placeholder);
/// [t] with interpolation replacers.
String tr(
String key, {
String? placeholder,
List<I18nReplacer> replacers = const [],
}) =>
i18n.interpolated(
key,
namespace: id,
placeholder: placeholder,
replacers: replacers,
);
}
+36
View File
@@ -0,0 +1,36 @@
import 'dart:io';
import 'package:clide_app/extension/src/manifest.dart';
/// Scans the third-party extensions root for `manifest.yaml` files.
///
/// Built-ins are registered by the app at boot; this scanner handles
/// installed third-party extensions. Tier 0 returns an empty list
/// until the Lua adapter lands — every call is safe to make anyway.
class ExtensionScanner {
const ExtensionScanner();
/// Typical install root: `~/.clide/extensions/<id>/manifest.yaml`.
/// Override for tests.
Directory defaultRoot() {
final home = Platform.environment['HOME'] ?? '/tmp';
return Directory('$home/.clide/extensions');
}
Future<List<ExtensionManifest>> discover({Directory? root}) async {
final dir = root ?? defaultRoot();
if (!await dir.exists()) return const [];
final out = <ExtensionManifest>[];
await for (final entity in dir.list()) {
if (entity is! Directory) continue;
final m = File('${entity.path}/manifest.yaml');
if (!await m.exists()) continue;
try {
out.add(await ExtensionManifest.fromFile(m));
} on FormatException catch (_) {
// skip malformed manifests; the extensions-ui will surface them
}
}
return out;
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'dart:io';
import 'package:yaml/yaml.dart';
/// A parsed third-party extension manifest.
///
/// Built-in extensions don't need a manifest file — they compile in as
/// Dart subclasses of [ClideExtension]. Third-party extensions ship a
/// `manifest.yaml` under `~/.clide/extensions/<id>/` alongside their
/// Lua entrypoint; this class parses and validates that file.
class ExtensionManifest {
const ExtensionManifest({
required this.id,
required this.title,
required this.version,
required this.dependsOn,
required this.entry,
required this.schemaVersion,
});
final String id;
final String title;
final String version;
final List<String> dependsOn;
final String entry; // relative path to lua entrypoint
final int schemaVersion;
factory ExtensionManifest.fromYamlString(String text) {
final doc = loadYaml(text);
if (doc is! Map) {
throw const FormatException('manifest root is not a map');
}
final id = doc['id'];
if (id is! String || id.isEmpty) {
throw const FormatException('manifest missing `id`');
}
final title = (doc['title'] as String?) ?? id;
final version = (doc['version'] as String?) ?? '0.0.0';
final entry = (doc['entry'] as String?) ?? 'extension.lua';
final schemaVersion = (doc['schema_version'] as int?) ?? 1;
final depsYaml = doc['depends_on'];
final deps = <String>[];
if (depsYaml is YamlList) {
for (final d in depsYaml) {
if (d is String) deps.add(d);
}
}
return ExtensionManifest(
id: id,
title: title,
version: version,
dependsOn: deps,
entry: entry,
schemaVersion: schemaVersion,
);
}
static Future<ExtensionManifest> fromFile(File f) async =>
ExtensionManifest.fromYamlString(await f.readAsString());
}
+48
View File
@@ -0,0 +1,48 @@
/// clide kernel — registries, stores, and shared singleton services
/// consumed by every extension.
///
/// Admission rule: the kernel owns anything whose second concurrent user
/// would create incoherent state or divergent UX. External-interfacing
/// work generally belongs to extensions (git, pql, Linear, Jira);
/// external-interfacing *singletons* (OS clipboard, tray, keychain)
/// belong here.
///
/// Exports are added as each subsystem lands. See plan:
/// /home/jeroenschweitzer/.claude/plans/i-want-to-discuss-cozy-zebra.md
library;
export 'src/events/bus.dart';
export 'src/events/types.dart';
export 'src/ipc/client.dart';
export 'src/log.dart';
export 'src/settings.dart';
export 'src/facade.dart';
export 'src/clipboard.dart';
export 'src/commands/keybindings.dart';
export 'src/commands/palette.dart';
export 'src/commands/registry.dart';
export 'src/dialog.dart';
export 'src/extensions_manager.dart';
export 'src/files.dart';
export 'src/focus.dart';
export 'src/i18n/catalog_loader.dart';
export 'src/i18n/fallback_chain.dart';
export 'src/i18n/i18n.dart';
export 'src/net.dart';
export 'src/notify.dart';
export 'src/os.dart';
export 'src/panels/arrangement.dart';
export 'src/project.dart';
export 'src/secrets.dart';
export 'src/tray.dart';
export 'src/panels/drag_resize.dart';
export 'src/panels/layout_preset.dart';
export 'src/panels/registry.dart';
export 'src/panels/slot_id.dart';
export 'src/theme/contrast.dart';
export 'src/theme/controller.dart';
export 'src/theme/loader.dart';
export 'src/theme/palette.dart';
export 'src/theme/resolver.dart';
export 'src/theme/semantic.dart';
export 'src/theme/tokens.dart';
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' as flutter_services;
/// Typed, per-content-kind clipboard with a plaintext fallback.
///
/// Extensions write typed values (`write<GitHunk>(hunk)`) and read in
/// the same type (`readAs<GitHunk>()`). Anything with a `toPlain`
/// callback also syncs to the OS clipboard so external apps see
/// reasonable text. The history ring keeps the last [historyLimit]
/// entries per type for quick recall.
class ClideClipboard {
ClideClipboard({this.historyLimit = 16});
final int historyLimit;
final Map<Type, List<Object>> _history = {};
Future<void> write<T extends Object>(
T value, {
String Function(T)? toPlain,
}) async {
final bucket = _history.putIfAbsent(T, () => <Object>[]);
bucket.insert(0, value);
if (bucket.length > historyLimit) bucket.removeLast();
if (toPlain != null) {
await flutter_services.Clipboard.setData(
flutter_services.ClipboardData(text: toPlain(value)));
}
}
T? readAs<T extends Object>() {
final bucket = _history[T];
if (bucket == null || bucket.isEmpty) return null;
return bucket.first as T;
}
List<T> historyOf<T extends Object>() {
final bucket = _history[T];
if (bucket == null) return const [];
return bucket.cast<T>().toList(growable: false);
}
Future<String?> readPlain() async {
final d = await flutter_services.Clipboard.getData('text/plain');
return d?.text;
}
Future<void> writePlain(String text) async {
await flutter_services.Clipboard.setData(
flutter_services.ClipboardData(text: text));
final bucket = _history.putIfAbsent(String, () => <Object>[]);
bucket.insert(0, text);
if (bucket.length > historyLimit) bucket.removeLast();
}
@visibleForTesting
void clear() => _history.clear();
}
@@ -0,0 +1,78 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Key combo: modifiers + primary key. Canonicalized on construction
/// (modifiers sorted, lowercased) so equality works for lookup keys.
@immutable
class Keybinding {
Keybinding({required Set<String> modifiers, required String key})
: modifiers = _canonModifiers(modifiers),
key = key.toLowerCase();
final List<String> modifiers;
final String key;
static List<String> _canonModifiers(Set<String> m) {
final normalized = m.map((s) => s.toLowerCase()).toSet().toList()..sort();
return List.unmodifiable(normalized);
}
/// Parse "ctrl+shift+g", "cmd+k", "alt+f4".
static Keybinding parse(String spec) {
if (spec.trim().isEmpty) {
throw ArgumentError('empty keybinding');
}
final parts = spec.split('+').map((s) => s.trim()).toList();
final key = parts.removeLast();
if (key.isEmpty) {
throw ArgumentError('keybinding is missing a key: "$spec"');
}
return Keybinding(modifiers: parts.toSet(), key: key);
}
String get canonical {
if (modifiers.isEmpty) return key;
return '${modifiers.join('+')}+$key';
}
@override
bool operator ==(Object other) =>
other is Keybinding &&
other.key == key &&
listEquals(other.modifiers, modifiers);
@override
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
@override
String toString() => 'Keybinding($canonical)';
}
class KeybindingResolver {
final Map<Keybinding, String> _bindings = {};
void bind(Keybinding b, String commandId) {
_bindings[b] = commandId;
}
void unbind(Keybinding b) {
_bindings.remove(b);
}
String? commandFor(Keybinding b) => _bindings[b];
Iterable<MapEntry<Keybinding, String>> get entries => _bindings.entries;
/// Map a Flutter [KeyEvent] to a [Keybinding] suitable for lookup.
static Keybinding? fromKeyEvent(KeyEvent event, HardwareKeyboard keyboard) {
if (event is! KeyDownEvent) return null;
final label = event.logicalKey.keyLabel;
if (label.isEmpty) return null;
final mods = <String>{};
if (keyboard.isControlPressed) mods.add('ctrl');
if (keyboard.isShiftPressed) mods.add('shift');
if (keyboard.isAltPressed) mods.add('alt');
if (keyboard.isMetaPressed) mods.add('cmd');
return Keybinding(modifiers: mods, key: label);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:clide_app/extension/src/contribution.dart';
import 'package:clide_app/kernel/src/commands/registry.dart';
import 'package:flutter/foundation.dart';
class PaletteController extends ChangeNotifier {
PaletteController(this._registry);
final CommandRegistry _registry;
bool _open = false;
String _filter = '';
bool get isOpen => _open;
String get filter => _filter;
void open() {
if (_open) return;
_open = true;
notifyListeners();
}
void close() {
if (!_open) return;
_open = false;
_filter = '';
notifyListeners();
}
void toggle() => _open ? close() : open();
void setFilter(String f) {
if (_filter == f) return;
_filter = f;
notifyListeners();
}
List<CommandContribution> filtered() {
if (_filter.isEmpty) return _registry.all.toList();
final q = _filter.toLowerCase();
return _registry.all.where((c) {
final haystack = (c.title ?? c.command).toLowerCase();
return haystack.contains(q);
}).toList();
}
Future<void> invoke(String command) async {
close();
await _registry.execute(command);
}
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:clide/clide.dart';
import 'package:clide_app/extension/src/contribution.dart';
import 'package:flutter/foundation.dart';
class CommandRegistry extends ChangeNotifier {
final Map<String, CommandContribution> _byCommand = {};
void register(CommandContribution cmd) {
_byCommand[cmd.command] = cmd;
notifyListeners();
}
void unregister(String command) {
if (_byCommand.remove(command) != null) notifyListeners();
}
Iterable<CommandContribution> get all => _byCommand.values;
CommandContribution? get(String command) => _byCommand[command];
Future<IpcResponse> execute(
String command, {
List<String> args = const [],
}) async {
final c = _byCommand[command];
if (c == null) {
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'no such command: $command',
),
);
}
return c.run(args);
}
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
typedef DialogBuilder<T> = Widget Function(
BuildContext context,
void Function([T? result]) dismiss,
);
/// Single-at-a-time modal router.
///
/// Extensions call [show] with a builder; the root widget (installed by
/// [DialogHost]) listens and renders the current dialog over a dimmed
/// backdrop. Only one dialog is active at a time — a second [show] call
/// while one is open awaits until the first dismisses.
class DialogRouter extends ChangeNotifier {
DialogBuilder<Object?>? _current;
Completer<Object?>? _completer;
final List<_Queued> _queue = [];
DialogBuilder<Object?>? get current => _current;
bool get isOpen => _current != null;
Future<T?> show<T extends Object>(DialogBuilder<T> builder) {
final completer = Completer<T?>();
final wrapped = _wrap<T>(builder);
if (_current == null) {
_current = wrapped;
_completer = Completer<Object?>();
// forward our generic completer to the typed one
_completer!.future.then((v) {
if (!completer.isCompleted) completer.complete(v as T?);
});
notifyListeners();
} else {
_queue.add(_Queued(wrapped, completer));
}
return completer.future;
}
void dismiss([Object? result]) {
if (_current == null) return;
final c = _completer;
_current = null;
_completer = null;
if (c != null && !c.isCompleted) c.complete(result);
if (_queue.isNotEmpty) {
final next = _queue.removeAt(0);
_current = next.builder;
_completer = Completer<Object?>();
_completer!.future.then((v) {
if (!next.completer.isCompleted) next.completer.complete(v);
});
}
notifyListeners();
}
DialogBuilder<Object?> _wrap<T>(DialogBuilder<T> builder) {
return (ctx, dismiss) => builder(ctx, ([T? v]) => dismiss(v));
}
}
class _Queued {
_Queued(this.builder, this.completer);
final DialogBuilder<Object?> builder;
// ignore: strict_raw_type
final Completer completer;
}
/// Hosts the current dialog from [DialogRouter]. Place high in the tree
/// (inside the WidgetsApp) so dialogs overlay every other surface.
class DialogHost extends StatelessWidget {
const DialogHost({
super.key,
required this.router,
required this.child,
this.backdropColor = const Color(0xC0000000),
});
final DialogRouter router;
final Widget child;
final Color backdropColor;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
child,
ListenableBuilder(
listenable: router,
builder: (ctx, _) {
final b = router.current;
if (b == null) return const SizedBox.shrink();
return Positioned.fill(
child: ColoredBox(
color: backdropColor,
child: Center(
child: b(ctx, router.dismiss),
),
),
);
},
),
],
);
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'dart:async';
import 'package:clide_app/kernel/src/events/types.dart';
class EventBus {
EventBus();
final StreamController<ClideEventEnvelope> _controller =
StreamController<ClideEventEnvelope>.broadcast();
Stream<ClideEventEnvelope> get stream => _controller.stream;
Stream<T> on<T extends ClideEvent>() =>
_controller.stream.where((e) => e.event is T).map((e) => e.event as T);
void emit(ClideEvent event) {
if (_controller.isClosed) return;
_controller.add(ClideEventEnvelope(event, DateTime.now().toUtc()));
}
Future<void> dispose() => _controller.close();
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/foundation.dart';
@immutable
abstract class ClideEvent {
const ClideEvent();
String get subsystem;
String get kind;
Map<String, Object?> payload() => const {};
}
@immutable
class ClideEventEnvelope {
const ClideEventEnvelope(this.event, this.timestamp);
final ClideEvent event;
final DateTime timestamp;
Map<String, Object?> toJson() => {
'v': 1,
'subsystem': event.subsystem,
'kind': event.kind,
'ts': timestamp.toIso8601String(),
'data': event.payload(),
};
}
class DaemonConnectionChanged extends ClideEvent {
const DaemonConnectionChanged({required this.connected});
final bool connected;
@override
String get subsystem => 'ipc';
@override
String get kind => 'connection-changed';
@override
Map<String, Object?> payload() => {'connected': connected};
}
class ThemeChanged extends ClideEvent {
const ThemeChanged({required this.themeName});
final String themeName;
@override
String get subsystem => 'theme';
@override
String get kind => 'changed';
@override
Map<String, Object?> payload() => {'theme': themeName};
}
class ProjectOpened extends ClideEvent {
const ProjectOpened({required this.path});
final String path;
@override
String get subsystem => 'project';
@override
String get kind => 'opened';
@override
Map<String, Object?> payload() => {'path': path};
}
class ProjectClosed extends ClideEvent {
const ProjectClosed();
@override
String get subsystem => 'project';
@override
String get kind => 'closed';
}
class ExtensionActivated extends ClideEvent {
const ExtensionActivated({required this.id});
final String id;
@override
String get subsystem => 'extensions';
@override
String get kind => 'activated';
@override
Map<String, Object?> payload() => {'id': id};
}
class ExtensionDeactivated extends ClideEvent {
const ExtensionDeactivated({required this.id});
final String id;
@override
String get subsystem => 'extensions';
@override
String get kind => 'deactivated';
@override
Map<String, Object?> payload() => {'id': id};
}
/// Forwarded from the daemon. Feature extensions subscribe to this and
/// narrow by subsystem+kind, or register a converter that emits a typed
/// `ClideEvent` subclass into the bus.
class DaemonEvent extends ClideEvent {
const DaemonEvent({
required this.subsystem,
required this.kind,
required this.data,
required this.ts,
});
@override
final String subsystem;
@override
final String kind;
final Map<String, Object?> data;
final DateTime ts;
@override
Map<String, Object?> payload() => {'ts': ts.toIso8601String(), ...data};
}
+279
View File
@@ -0,0 +1,279 @@
import 'dart:async';
import 'package:clide_app/extension/src/contribution.dart';
import 'package:clide_app/extension/src/extension.dart';
import 'package:clide_app/kernel/src/clipboard.dart';
import 'package:clide_app/kernel/src/commands/keybindings.dart';
import 'package:clide_app/kernel/src/commands/palette.dart';
import 'package:clide_app/kernel/src/commands/registry.dart';
import 'package:clide_app/kernel/src/dialog.dart';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/events/types.dart';
import 'package:clide_app/kernel/src/files.dart';
import 'package:clide_app/kernel/src/focus.dart';
import 'package:clide_app/kernel/src/i18n/i18n.dart';
import 'package:clide_app/kernel/src/ipc/client.dart';
import 'package:clide_app/kernel/src/log.dart';
import 'package:clide_app/kernel/src/net.dart';
import 'package:clide_app/kernel/src/notify.dart';
import 'package:clide_app/kernel/src/os.dart';
import 'package:clide_app/kernel/src/panels/arrangement.dart';
import 'package:clide_app/kernel/src/panels/registry.dart';
import 'package:clide_app/kernel/src/project.dart';
import 'package:clide_app/kernel/src/secrets.dart';
import 'package:clide_app/kernel/src/settings.dart';
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:clide_app/kernel/src/tray.dart';
import 'package:flutter/foundation.dart';
class ExtensionManager extends ChangeNotifier {
ExtensionManager({
required this.log,
required this.events,
required this.settings,
required this.theme,
required this.i18n,
required this.panels,
required this.arrangement,
required this.commands,
required this.palette,
required this.keybindings,
required this.clipboard,
required this.files,
required this.notify,
required this.dialog,
required this.tray,
required this.secrets,
required this.os,
required this.net,
required this.focus,
required this.project,
required this.ipc,
});
final Logger log;
final EventBus events;
final SettingsStore settings;
final ThemeController theme;
final I18n i18n;
final PanelRegistry panels;
final LayoutArrangement arrangement;
final CommandRegistry commands;
final PaletteController palette;
final KeybindingResolver keybindings;
final ClideClipboard clipboard;
final FileServices files;
final Notifications notify;
final DialogRouter dialog;
final TrayRegistry tray;
final SecretsVault secrets;
final OsBridge os;
final NetworkStatus net;
final FocusTracker focus;
final ProjectManager project;
final DaemonClient ipc;
final Map<String, ClideExtension> _known = {};
final Set<String> _activated = {};
void register(ClideExtension ext) {
if (_known.containsKey(ext.id)) {
log.warn('extensions', 'duplicate registration: ${ext.id}');
return;
}
_known[ext.id] = ext;
notifyListeners();
}
Iterable<ClideExtension> get all => _known.values;
bool isActivated(String id) => _activated.contains(id);
bool isEnabled(String id) {
final v = settings.get<bool>('app.extensions.$id.enabled');
return v ?? true;
}
Future<void> setEnabled(String id, bool enabled) async {
await settings.set<bool>('app.extensions.$id.enabled', enabled);
if (enabled && !isActivated(id)) {
await activate(id);
} else if (!enabled && isActivated(id)) {
await deactivate(id);
}
}
/// Activate every enabled extension in dependency order. Missing
/// deps warn and skip.
Future<void> activateAll() async {
final order = _topoSort();
for (final id in order) {
if (!isEnabled(id)) continue;
await activate(id);
}
}
Future<void> activate(String id) async {
if (_activated.contains(id)) return;
final ext = _known[id];
if (ext == null) {
log.warn('extensions', 'unknown extension: $id');
return;
}
for (final dep in ext.dependsOn) {
if (!_activated.contains(dep)) {
log.warn(
'extensions', 'skipping ${ext.id}: dependency not activated: $dep');
return;
}
}
final ctx = _ExtensionContext(manager: this, id: ext.id);
try {
await ext.activate(ctx);
for (final c in ext.contributions) {
_applyContribution(c);
}
_activated.add(id);
events.emit(ExtensionActivated(id: id));
notifyListeners();
log.info('extensions', 'activated $id');
} catch (e, st) {
log.error('extensions', 'activate failed for $id',
error: e, stackTrace: st);
}
}
Future<void> deactivate(String id) async {
if (!_activated.contains(id)) return;
final ext = _known[id];
if (ext == null) return;
try {
await ext.deactivate();
for (final c in ext.contributions) {
_removeContribution(c);
}
_activated.remove(id);
events.emit(ExtensionDeactivated(id: id));
notifyListeners();
log.info('extensions', 'deactivated $id');
} catch (e, st) {
log.error('extensions', 'deactivate failed for $id',
error: e, stackTrace: st);
}
}
void _applyContribution(ContributionPoint c) {
switch (c) {
case TabContribution _:
case StatusItemContribution _:
case ToolbarButtonContribution _:
panels.contribute(c);
case CommandContribution cmd:
commands.register(cmd);
final binding = cmd.defaultBinding;
if (binding != null) {
keybindings.bind(Keybinding.parse(binding), cmd.command);
}
case TrayItemContribution t:
tray.add(t);
case LayoutPresetContribution _:
// Presets are consumed by the default-layout extension in its
// own activate(); nothing for the kernel to do here.
break;
}
}
void _removeContribution(ContributionPoint c) {
switch (c) {
case TabContribution _:
case StatusItemContribution _:
case ToolbarButtonContribution _:
panels.uncontribute(c.id);
case CommandContribution cmd:
commands.unregister(cmd.command);
final binding = cmd.defaultBinding;
if (binding != null) {
keybindings.unbind(Keybinding.parse(binding));
}
case TrayItemContribution t:
tray.remove(t.id);
case LayoutPresetContribution _:
break;
}
}
List<String> _topoSort() {
final order = <String>[];
final seen = <String>{};
final visiting = <String>{};
void visit(String id) {
if (seen.contains(id)) return;
if (visiting.contains(id)) {
log.warn('extensions', 'dependency cycle touching $id');
return;
}
final ext = _known[id];
if (ext == null) return;
visiting.add(id);
for (final dep in ext.dependsOn) {
visit(dep);
}
visiting.remove(id);
seen.add(id);
order.add(id);
}
for (final id in _known.keys) {
visit(id);
}
return order;
}
}
class _ExtensionContext implements ClideExtensionContext {
_ExtensionContext({required this.manager, required this.id});
final ExtensionManager manager;
@override
final String id;
@override
Logger get log => manager.log;
@override
EventBus get events => manager.events;
@override
SettingsStore get settings => manager.settings;
@override
ThemeController get theme => manager.theme;
@override
I18n get i18n => manager.i18n;
@override
PanelRegistry get panels => manager.panels;
@override
LayoutArrangement get arrangement => manager.arrangement;
@override
CommandRegistry get commands => manager.commands;
@override
PaletteController get palette => manager.palette;
@override
ClideClipboard get clipboard => manager.clipboard;
@override
FileServices get files => manager.files;
@override
Notifications get notify => manager.notify;
@override
DialogRouter get dialog => manager.dialog;
@override
TrayRegistry get tray => manager.tray;
@override
SecretsVault get secrets => manager.secrets;
@override
OsBridge get os => manager.os;
@override
NetworkStatus get net => manager.net;
@override
FocusTracker get focus => manager.focus;
@override
ProjectManager get project => manager.project;
@override
DaemonClient get ipc => manager.ipc;
}
+236
View File
@@ -0,0 +1,236 @@
import 'dart:async';
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide_app/kernel/src/clipboard.dart';
import 'package:clide_app/kernel/src/commands/keybindings.dart';
import 'package:clide_app/kernel/src/commands/palette.dart';
import 'package:clide_app/kernel/src/commands/registry.dart';
import 'package:clide_app/kernel/src/dialog.dart';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/extensions_manager.dart';
import 'package:clide_app/kernel/src/files.dart';
import 'package:clide_app/kernel/src/focus.dart';
import 'package:clide_app/kernel/src/i18n/catalog_loader.dart';
import 'package:clide_app/kernel/src/i18n/i18n.dart';
import 'package:clide_app/kernel/src/ipc/client.dart';
import 'package:clide_app/kernel/src/log.dart';
import 'package:clide_app/kernel/src/net.dart';
import 'package:clide_app/kernel/src/notify.dart';
import 'package:clide_app/kernel/src/os.dart';
import 'package:clide_app/kernel/src/panels/arrangement.dart';
import 'package:clide_app/kernel/src/panels/registry.dart';
import 'package:clide_app/kernel/src/project.dart';
import 'package:clide_app/kernel/src/secrets.dart';
import 'package:clide_app/kernel/src/settings.dart';
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:clide_app/kernel/src/theme/loader.dart';
import 'package:clide_app/kernel/src/tray.dart';
import 'package:flutter/widgets.dart';
/// Aggregated kernel services. Feature code that runs outside a
/// BuildContext (extensions, background tasks) holds a [KernelServices]
/// ref directly; widget code reaches them via [ClideKernel.of].
class KernelServices {
KernelServices({
required this.log,
required this.settings,
required this.events,
required this.ipc,
required this.theme,
required this.i18n,
required this.panels,
required this.arrangement,
required this.commands,
required this.palette,
required this.keybindings,
required this.clipboard,
required this.files,
required this.notify,
required this.dialog,
required this.tray,
required this.secrets,
required this.os,
required this.net,
required this.focus,
required this.project,
required this.extensions,
});
final Logger log;
final SettingsStore settings;
final EventBus events;
final DaemonClient ipc;
final ThemeController theme;
final I18n i18n;
final PanelRegistry panels;
final LayoutArrangement arrangement;
final CommandRegistry commands;
final PaletteController palette;
final KeybindingResolver keybindings;
final ClideClipboard clipboard;
final FileServices files;
final Notifications notify;
final DialogRouter dialog;
final TrayRegistry tray;
final SecretsVault secrets;
final OsBridge os;
final NetworkStatus net;
final FocusTracker focus;
final ProjectManager project;
final ExtensionManager extensions;
static Future<KernelServices> boot({
required Directory appDir,
required List<ThemeDefinition> bundledThemes,
required CatalogLoader i18nLoader,
List<String> preloadNamespaces = const [],
Locale defaultLocale = const Locale('en', 'US'),
Locale? initialLocale,
List<Locale> availableLocales = const [Locale('en', 'US')],
String? socketPath,
DaemonClient Function(Logger, EventBus)? daemonClientFactory,
bool autoStartDaemonClient = true,
}) async {
final log = Logger();
final events = EventBus();
final settings = SettingsStore(appDir: appDir);
await settings.load();
final i18n = I18n(
loader: i18nLoader,
log: log,
defaultLocale: defaultLocale,
initialLocale: initialLocale,
availableLocales: availableLocales,
);
for (final ns in preloadNamespaces) {
await i18n.ensureNamespaceLoaded(ns);
}
final theme = ThemeController(bundled: bundledThemes);
final panels = PanelRegistry();
final arrangement = LayoutArrangement();
final commands = CommandRegistry();
final keybindings = KeybindingResolver();
final palette = PaletteController(commands);
final clipboard = ClideClipboard();
final files = FileServices(events);
final notify = Notifications();
final dialog = DialogRouter();
final tray = TrayRegistry();
final secrets = SecretsVault();
final os = OsBridge(log: log, events: events);
final net = NetworkStatus();
final focus = FocusTracker();
final project = ProjectManager(
log: log,
events: events,
settings: settings,
);
final ipc = daemonClientFactory != null
? daemonClientFactory(log, events)
: DaemonClient(
socketPath: socketPath ?? defaultSocketPath(),
log: log,
events: events,
);
final extensions = ExtensionManager(
log: log,
events: events,
settings: settings,
theme: theme,
i18n: i18n,
panels: panels,
arrangement: arrangement,
commands: commands,
palette: palette,
keybindings: keybindings,
clipboard: clipboard,
files: files,
notify: notify,
dialog: dialog,
tray: tray,
secrets: secrets,
os: os,
net: net,
focus: focus,
project: project,
ipc: ipc,
);
if (autoStartDaemonClient) {
unawaited(ipc.start());
}
return KernelServices(
log: log,
settings: settings,
events: events,
ipc: ipc,
theme: theme,
i18n: i18n,
panels: panels,
arrangement: arrangement,
commands: commands,
palette: palette,
keybindings: keybindings,
clipboard: clipboard,
files: files,
notify: notify,
dialog: dialog,
tray: tray,
secrets: secrets,
os: os,
net: net,
focus: focus,
project: project,
extensions: extensions,
);
}
Future<void> dispose() async {
await ipc.stop();
ipc.dispose();
settings.dispose();
theme.dispose();
panels.dispose();
arrangement.dispose();
commands.dispose();
palette.dispose();
i18n.dispose();
notify.dispose();
dialog.dispose();
tray.dispose();
net.dispose();
focus.dispose();
project.dispose();
extensions.dispose();
await log.dispose();
await events.dispose();
}
}
class ClideKernel extends InheritedWidget {
const ClideKernel({
super.key,
required this.services,
required super.child,
});
final KernelServices services;
static KernelServices of(BuildContext context) {
final w = context.dependOnInheritedWidgetOfExactType<ClideKernel>();
if (w == null) {
throw FlutterError(
'ClideKernel.of() called with a context that is not a descendant of a ClideKernel.');
}
return w.services;
}
@override
bool updateShouldNotify(ClideKernel oldWidget) =>
services != oldWidget.services;
}
+61
View File
@@ -0,0 +1,61 @@
import 'dart:async';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/events/types.dart';
import 'package:clide_app/kernel/src/panels/slot_id.dart';
import 'package:flutter/foundation.dart';
class FilesDropped extends ClideEvent {
const FilesDropped({required this.paths, required this.slot});
final List<String> paths;
final SlotId slot;
@override
String get subsystem => 'files';
@override
String get kind => 'dropped';
@override
Map<String, Object?> payload() => {
'paths': paths,
'slot': slot.value,
};
}
/// Tier-0 stub for file pickers and drop targets.
///
/// Flutter desktop has no native picker API without a dep; rather than
/// add one now, pickOpen/pickSave/pickDirectory throw UnimplementedError
/// and the drop target is a no-op until we wire it through the
/// platform channel. This lets the rest of the kernel compile and makes
/// the service surface real.
class FileServices {
FileServices(this._events);
final EventBus _events;
Future<List<String>> pickOpen({
List<String> extensions = const [],
bool multiple = false,
}) async {
throw UnimplementedError('pickOpen — wired in a later tier');
}
Future<String?> pickSave({
String? defaultName,
List<String> extensions = const [],
}) async {
throw UnimplementedError('pickSave — wired in a later tier');
}
Future<String?> pickDirectory() async {
throw UnimplementedError('pickDirectory — wired in a later tier');
}
/// Invoked by the platform drop-target wiring when files land on a
/// slot. Emits a [FilesDropped] event; the slot-owning extension
/// subscribes.
@visibleForTesting
void notifyDropped({required List<String> paths, required SlotId slot}) {
_events.emit(FilesDropped(paths: paths, slot: slot));
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:clide_app/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,4 @@
{
"command.reset": { "translation": "Layout: Reset to Classic" },
"preset.classic": { "translation": "Classic" }
}
@@ -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 @@
{
"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_app/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';
}
}
+171
View File
@@ -0,0 +1,171 @@
import 'dart:ui';
import 'package:clide_app/kernel/src/i18n/catalog_loader.dart';
import 'package:clide_app/kernel/src/i18n/fallback_chain.dart';
import 'package:clide_app/kernel/src/log.dart';
import 'package:flutter/foundation.dart';
@immutable
class I18nReplacer {
const I18nReplacer({required this.from, required this.replace});
final String from;
final String replace;
}
/// Text-driven i18n — fframe-style. Keys are strings, lookups are by
/// `(namespace, key)`, missing keys fall back through a locale chain
/// and finally to the caller-supplied placeholder.
///
/// Singleton-per-kernel: `kernel.i18n`. Extensions write:
/// final t = ctx.i18n;
/// t.string('key', placeholder: '...', namespace: ext.id);
class I18n extends ChangeNotifier {
I18n({
required this.loader,
required this.log,
required Locale defaultLocale,
Locale? initialLocale,
List<Locale> availableLocales = const [Locale('en', 'US')],
}) : _defaultLocale = defaultLocale,
_current = initialLocale ?? defaultLocale,
_available = List<Locale>.unmodifiable(availableLocales);
final CatalogLoader loader;
final Logger log;
final Locale _defaultLocale;
Locale _current;
final List<Locale> _available;
/// namespace -> locale -> flat key map
final Map<String, Map<Locale, Map<String, Object?>>> _cache = {};
/// Keys we've already warned about for a given (namespace, key, locale).
/// Keeps the log quiet across repeated lookups.
final Set<String> _warnedMisses = {};
Locale get currentLocale => _current;
Locale get defaultLocale => _defaultLocale;
List<Locale> get availableLocales => _available;
/// Register a catalog that was loaded outside of [loader] — e.g. by the
/// ExtensionManager when a third-party extension activates.
void registerCatalog(
String namespace,
Locale locale,
Map<String, Object?> catalog,
) {
_cache.putIfAbsent(
namespace, () => <Locale, Map<String, Object?>>{})[locale] = catalog;
notifyListeners();
}
/// Remove every entry for a namespace (extension deactivated).
void unregisterCatalog(String namespace) {
if (_cache.remove(namespace) != null) {
notifyListeners();
}
}
/// Set the current locale and reload every already-cached namespace
/// for the new chain. Listeners fire once at the end.
Future<void> setLocale(Locale locale) async {
if (locale == _current) return;
_current = locale;
_warnedMisses.clear();
for (final ns in _cache.keys.toList()) {
await _ensureLoaded(ns);
}
notifyListeners();
}
/// Eagerly load a namespace across the whole fallback chain. Safe to
/// call more than once (subsequent calls only fill missing locales).
Future<void> ensureNamespaceLoaded(String namespace) async {
await _ensureLoaded(namespace);
}
Future<void> _ensureLoaded(String namespace) async {
final byLocale = _cache.putIfAbsent(
namespace,
() => <Locale, Map<String, Object?>>{},
);
final chain = FallbackChain(
current: _current,
defaultLocale: _defaultLocale,
).resolve();
for (final l in chain) {
if (byLocale.containsKey(l)) continue;
byLocale[l] = await loader.load(namespace, l);
}
}
/// Look up a key, walking the locale fallback chain. Returns the
/// placeholder if nothing hits; returns the key itself when placeholder
/// is null (developer fallback — keys are more useful than blanks).
String string(
String key, {
required String namespace,
String? placeholder,
}) {
final byLocale = _cache[namespace];
if (byLocale == null) {
_warnOnce(
'$namespace::MISSING_NAMESPACE::$key',
'i18n: namespace not registered: $namespace (key: $key)',
);
return placeholder ?? key;
}
final chain = FallbackChain(
current: _current,
defaultLocale: _defaultLocale,
).resolve();
for (final locale in chain) {
final catalog = byLocale[locale];
if (catalog == null) continue;
final hit = _extract(catalog, key);
if (hit != null) return hit;
}
_warnOnce(
'$namespace::${_current.languageCode}::$key',
'i18n: missing key "$key" in namespace "$namespace" (locale ${_current.toString()})',
);
return placeholder ?? key;
}
/// [string] + naive `replaceAll` interpolation per replacer.
/// Matches fframe: replacers whose [from] isn't present are silent no-ops.
String interpolated(
String key, {
required String namespace,
String? placeholder,
List<I18nReplacer> replacers = const [],
}) {
var out = string(key, namespace: namespace, placeholder: placeholder);
for (final r in replacers) {
out = out.replaceAll(r.from, r.replace);
}
return out;
}
/// Walks fframe's nested shape: `{ "translation": "..." }`. If the
/// value is a plain string we accept that too (forward-compat).
String? _extract(Map<String, Object?> catalog, String key) {
final v = catalog[key];
if (v == null) return null;
if (v is String) return v;
if (v is Map && v['translation'] is String) {
return v['translation'] as String;
}
return null;
}
void _warnOnce(String dedupeKey, String message) {
if (_warnedMisses.add(dedupeKey)) {
log.warn('i18n', message);
}
}
}
+171
View File
@@ -0,0 +1,171 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:clide/clide.dart';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/events/types.dart';
import 'package:clide_app/kernel/src/log.dart';
import 'package:flutter/foundation.dart';
class DaemonClient extends ChangeNotifier {
DaemonClient({
required this.socketPath,
required Logger log,
required EventBus events,
}) : _log = log,
_events = events;
final String socketPath;
final Logger _log;
final EventBus _events;
Socket? _socket;
bool _connected = false;
bool _disposed = false;
Timer? _reconnectTimer;
Duration _backoff = const Duration(milliseconds: 200);
int _nextId = 0;
final Map<String, Completer<IpcResponse>> _pending = {};
bool get isConnected => _connected;
Future<void> start() async {
_disposed = false;
await _connect();
}
Future<void> stop() async {
_disposed = true;
_reconnectTimer?.cancel();
_reconnectTimer = null;
final s = _socket;
_socket = null;
await s?.close();
_failPending('client stopped');
_setConnected(false);
}
Future<IpcResponse> request(
String cmd, {
Map<String, Object?> args = const {},
}) {
if (!_connected || _socket == null) {
return Future.value(IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'daemon not connected',
hint: 'is `clide --daemon` running?',
),
));
}
final id = '${_nextId++}';
final completer = Completer<IpcResponse>();
_pending[id] = completer;
final req = IpcRequest(id: id, cmd: cmd, args: args);
_socket!.writeln(req.encode());
return completer.future;
}
Future<void> _connect() async {
if (_disposed) return;
try {
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
final socket = await Socket.connect(addr, 0);
_socket = socket;
_backoff = const Duration(milliseconds: 200);
_setConnected(true);
_log.info('ipc', 'connected to $socketPath');
socket
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
_handleLine,
onDone: _handleDisconnect,
onError: (Object e) {
_log.warn('ipc', 'socket error', error: e);
_handleDisconnect();
},
cancelOnError: true,
);
} catch (e) {
_log.debug(
'ipc', 'connect failed ($e); retry in ${_backoff.inMilliseconds}ms');
_scheduleReconnect();
}
}
void _handleLine(String line) {
if (line.isEmpty) return;
try {
final msg = IpcMessage.decode(line);
switch (msg) {
case IpcResponse r:
final c = _pending.remove(r.id);
if (c != null && !c.isCompleted) c.complete(r);
case IpcEvent e:
_events.emit(DaemonEvent(
subsystem: e.subsystem,
kind: e.kind,
data: e.data,
ts: e.timestamp,
));
case IpcRequest _:
_log.warn('ipc', 'daemon sent a request — unexpected');
}
} on FormatException catch (e) {
_log.warn('ipc', 'bad line from daemon: $e');
}
}
void _handleDisconnect() {
_socket = null;
_failPending('daemon disconnected');
_setConnected(false);
_scheduleReconnect();
}
void _failPending(String reason) {
final err = IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: reason,
);
for (final entry in _pending.entries) {
if (!entry.value.isCompleted) {
entry.value.complete(IpcResponse.err(id: entry.key, error: err));
}
}
_pending.clear();
}
void _scheduleReconnect() {
if (_disposed) return;
_reconnectTimer?.cancel();
_reconnectTimer = Timer(_backoff, _connect);
_backoff = Duration(
milliseconds: math.min(_backoff.inMilliseconds * 2, 5000),
);
}
void _setConnected(bool v) {
if (_connected == v) return;
_connected = v;
_events.emit(DaemonConnectionChanged(connected: v));
notifyListeners();
}
@override
void dispose() {
_disposed = true;
_reconnectTimer?.cancel();
unawaited(_socket?.close());
_socket = null;
_failPending('client disposed');
super.dispose();
}
}
+88
View File
@@ -0,0 +1,88 @@
import 'dart:async';
import 'dart:io';
enum LogLevel { trace, debug, info, warn, error }
class LogRecord {
LogRecord({
required this.level,
required this.source,
required this.message,
required this.timestamp,
this.error,
this.stackTrace,
});
final LogLevel level;
final String source;
final String message;
final DateTime timestamp;
final Object? error;
final StackTrace? stackTrace;
@override
String toString() {
final lv = level.name.toUpperCase().padRight(5);
final buf =
StringBuffer('${timestamp.toIso8601String()} $lv [$source] $message');
if (error != null) buf.write(' | error=$error');
return buf.toString();
}
}
typedef LogSink = void Function(LogRecord);
class Logger {
Logger({this.minLevel = LogLevel.info, List<LogSink>? sinks})
: _sinks = List<LogSink>.from(sinks ?? <LogSink>[stderrSink]);
LogLevel minLevel;
final List<LogSink> _sinks;
final StreamController<LogRecord> _stream =
StreamController<LogRecord>.broadcast();
Stream<LogRecord> get records => _stream.stream;
void addSink(LogSink sink) => _sinks.add(sink);
void trace(String source, String message) =>
_emit(LogLevel.trace, source, message);
void debug(String source, String message) =>
_emit(LogLevel.debug, source, message);
void info(String source, String message) =>
_emit(LogLevel.info, source, message);
void warn(String source, String message, {Object? error}) =>
_emit(LogLevel.warn, source, message, error: error);
void error(String source, String message,
{Object? error, StackTrace? stackTrace}) =>
_emit(LogLevel.error, source, message,
error: error, stackTrace: stackTrace);
void _emit(LogLevel level, String source, String message,
{Object? error, StackTrace? stackTrace}) {
if (level.index < minLevel.index) return;
final rec = LogRecord(
level: level,
source: source,
message: message,
timestamp: DateTime.now().toUtc(),
error: error,
stackTrace: stackTrace,
);
for (final sink in _sinks) {
try {
sink(rec);
} catch (_) {
// a broken sink must not kill logging
}
}
if (!_stream.isClosed) _stream.add(rec);
}
Future<void> dispose() => _stream.close();
}
void stderrSink(LogRecord r) {
stderr.writeln(r);
if (r.stackTrace != null) stderr.writeln(r.stackTrace);
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
enum Reachability { online, offline, metered }
/// Tier-0 stub reachability observable. Hardcoded to `online`; real
/// detection via a platform channel lands in a later tier.
class NetworkStatus extends ChangeNotifier {
Reachability _state = Reachability.online;
Reachability get state => _state;
bool get isOnline => _state != Reachability.offline;
@visibleForTesting
void setState(Reachability r) {
if (_state == r) return;
_state = r;
notifyListeners();
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
enum NotificationLevel { info, warning, error, success }
@immutable
class ClideNotification {
ClideNotification({
required this.id,
required this.level,
required this.message,
this.title,
this.duration = const Duration(seconds: 4),
}) : createdAt = DateTime.now().toUtc();
final String id;
final NotificationLevel level;
final String? title;
final String message;
final DateTime createdAt;
final Duration duration;
}
class Notifications extends ChangeNotifier {
final List<ClideNotification> _active = [];
final Map<String, Timer> _timers = {};
int _seq = 0;
List<ClideNotification> get active => List.unmodifiable(_active);
void info(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.info, message, title: title, duration: duration);
void warn(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.warning, message,
title: title, duration: duration);
void error(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.error, message, title: title, duration: duration);
void success(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.success, message,
title: title, duration: duration);
void dismiss(String id) {
_timers.remove(id)?.cancel();
final before = _active.length;
_active.removeWhere((n) => n.id == id);
if (_active.length != before) notifyListeners();
}
void _push(
NotificationLevel level,
String message, {
String? title,
Duration? duration,
}) {
final id = 'n${_seq++}';
final n = ClideNotification(
id: id,
level: level,
message: message,
title: title,
duration: duration ?? const Duration(seconds: 4),
);
_active.add(n);
_timers[id] = Timer(n.duration, () => dismiss(id));
notifyListeners();
}
@override
void dispose() {
for (final t in _timers.values) {
t.cancel();
}
_timers.clear();
super.dispose();
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'dart:async';
import 'dart:io';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/events/types.dart';
import 'package:clide_app/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;
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'package:clide_app/extension/src/contribution.dart';
import 'package:clide_app/kernel/src/panels/registry.dart';
import 'package:clide_app/kernel/src/panels/slot_id.dart';
import 'package:flutter/foundation.dart';
/// Current, runtime layout state: which slots are visible, at what size,
/// and in what positions. Persisted through `settings`.
///
/// The registry knows which slots *exist* and which contributions are
/// mounted. The arrangement knows which slots are *currently* shown and
/// how big they are — user-modifiable via drag-resize.
class LayoutArrangement extends ChangeNotifier {
LayoutArrangement();
final Map<SlotId, _SlotState> _state = {};
void applyPreset(LayoutPresetContribution preset) {
_state.clear();
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;
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();
}
/// Convenience for the default-layout extension: register all slots
/// it provides into a [PanelRegistry] with defaults from this preset.
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,
});
final SlotPosition position;
final double? size;
final double? minSize;
final double? maxSize;
final bool visible;
_SlotState copyWith({
SlotPosition? position,
double? size,
double? minSize,
double? maxSize,
bool? visible,
}) {
return _SlotState(
position: position ?? this.position,
size: size ?? this.size,
minSize: minSize ?? this.minSize,
maxSize: maxSize ?? this.maxSize,
visible: visible ?? this.visible,
);
}
}
@@ -0,0 +1,75 @@
import 'package:clide_app/kernel/src/panels/arrangement.dart';
import 'package:clide_app/kernel/src/panels/slot_id.dart';
import 'package:clide_app/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_app/extension/src/contribution.dart';
import 'package:clide_app/kernel/src/panels/slot_id.dart';
/// Canonical "three-column + statusbar" preset — the default-layout
/// extension contributes this at Tier 0. Split out so tests and the
/// default-layout extension share one source of truth.
///
/// Columns (px):
/// sidebar 240 (drag 180400)
/// center flex (workspace on top, statusbar below)
/// context 280 (drag 220420)
/// statusbar 26 (fixed height strip)
LayoutPresetContribution classicPreset() => const LayoutPresetContribution(
id: 'builtin.default-layout.classic',
displayName: 'Classic',
slots: [
LayoutSlot(
slot: Slots.sidebar,
position: SlotPosition.left,
defaultSize: 240,
minSize: 180,
maxSize: 400,
),
LayoutSlot(
slot: Slots.workspace,
position: SlotPosition.center,
),
LayoutSlot(
slot: Slots.contextPanel,
position: SlotPosition.right,
defaultSize: 280,
minSize: 220,
maxSize: 420,
),
LayoutSlot(
slot: Slots.statusbar,
position: SlotPosition.bottom,
defaultSize: 26,
minSize: 26,
maxSize: 26,
),
],
);
+87
View File
@@ -0,0 +1,87 @@
import 'package:clide_app/extension/src/contribution.dart';
import 'package:clide_app/kernel/src/panels/slot_id.dart';
import 'package:flutter/foundation.dart';
@immutable
class SlotDefinition {
const SlotDefinition({
required this.id,
required this.position,
this.defaultSize,
this.minSize,
this.maxSize,
});
final SlotId id;
final SlotPosition position;
final double? defaultSize;
final double? minSize;
final double? maxSize;
}
class PanelRegistry extends ChangeNotifier {
final Map<SlotId, SlotDefinition> _defs = {};
final Map<SlotId, List<ContributionPoint>> _mounts = {};
final Map<SlotId, String?> _activeTab = {};
void registerSlot(SlotDefinition def) {
_defs[def.id] = def;
_mounts.putIfAbsent(def.id, () => <ContributionPoint>[]);
notifyListeners();
}
void contribute(ContributionPoint point) {
final slot = point.slot;
if (slot == null) return; // non-slot contributions go elsewhere
final list = _mounts.putIfAbsent(slot, () => <ContributionPoint>[]);
list.add(point);
list.sort((a, b) => _priority(a).compareTo(_priority(b)));
// first tab-contribution in the sidebar/workspace/context becomes the
// default active tab until the user picks another
if (_activeTab[slot] == null && point is TabContribution) {
_activeTab[slot] = point.id;
}
notifyListeners();
}
void uncontribute(String contributionId) {
for (final entry in _mounts.entries) {
final before = entry.value.length;
entry.value.removeWhere((c) => c.id == contributionId);
if (entry.value.length != before) {
if (_activeTab[entry.key] == contributionId) {
_activeTab[entry.key] =
entry.value.whereType<TabContribution>().isEmpty
? null
: entry.value.whereType<TabContribution>().first.id;
}
}
}
notifyListeners();
}
Iterable<SlotDefinition> get slots => _defs.values;
SlotDefinition? definitionFor(SlotId id) => _defs[id];
List<ContributionPoint> contributionsFor(SlotId id) =>
List.unmodifiable(_mounts[id] ?? const []);
List<TabContribution> tabsFor(SlotId id) =>
contributionsFor(id).whereType<TabContribution>().toList();
String? activeTabIn(SlotId id) => _activeTab[id];
void activateTab(SlotId id, String tabId) {
if (_activeTab[id] == tabId) return;
_activeTab[id] = tabId;
notifyListeners();
}
int _priority(ContributionPoint p) {
if (p is TabContribution) return p.priority;
if (p is StatusItemContribution) return p.priority;
if (p is ToolbarButtonContribution) return p.priority;
if (p is TrayItemContribution) return p.priority;
return 0;
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/foundation.dart';
@immutable
class SlotId {
const SlotId(this.value);
final String value;
@override
bool operator ==(Object other) => other is SlotId && other.value == value;
@override
int get hashCode => value.hashCode;
@override
String toString() => 'SlotId($value)';
}
/// Kernel-reserved slot ids. Extensions can declare new slots; these are
/// the ones the default layout presets and the kernel services target.
abstract class Slots {
static const sidebar = SlotId('sidebar');
static const workspace = SlotId('workspace');
static const contextPanel = SlotId('context');
static const statusbar = SlotId('statusbar');
static const toolbar = SlotId('toolbar.main');
static const commandPalette = SlotId('commandPalette');
static const tray = SlotId('tray');
static const fullscreen = SlotId('fullscreen');
}
enum SlotPosition { left, right, top, bottom, center, float, popout }
+67
View File
@@ -0,0 +1,67 @@
import 'dart:io';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/events/types.dart';
import 'package:clide_app/kernel/src/log.dart';
import 'package:clide_app/kernel/src/settings.dart';
import 'package:flutter/foundation.dart';
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;
/// Open a project by path. Runs `git rev-parse --show-toplevel` to
/// find the workspace root. Returns true on success.
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);
_events.emit(ProjectOpened(path: root));
notifyListeners();
return true;
}
Future<void> close() async {
if (_current == null) return;
_current = null;
await _settings.setProjectDir(null);
_events.emit(const ProjectClosed());
notifyListeners();
}
/// Walks up from [path] via `git rev-parse --show-toplevel`. Returns
/// null if the path is outside a git repo or git isn't available.
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;
}
}
}
+34
View File
@@ -0,0 +1,34 @@
/// Tier-0 in-memory stub for the OS-keychain-backed vault.
///
/// Lands on `libsecret` (Linux) and macOS Keychain in a later tier.
/// The async API already matches the eventual platform-channel shape,
/// so consumers don't need to change when the real backend arrives.
class SecretsVault {
final Map<String, String> _memory = {};
Future<void> write({
required String extensionId,
required String key,
required String value,
}) async {
_memory['$extensionId/$key'] = value;
}
Future<String?> read({
required String extensionId,
required String key,
}) async {
return _memory['$extensionId/$key'];
}
Future<void> delete({
required String extensionId,
required String key,
}) async {
_memory.remove('$extensionId/$key');
}
Future<void> deleteAll({required String extensionId}) async {
_memory.removeWhere((k, _) => k.startsWith('$extensionId/'));
}
}
+218
View File
@@ -0,0 +1,218 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:yaml/yaml.dart';
enum SettingsScope { app, project, ext }
class SettingsStore extends ChangeNotifier {
SettingsStore({required this.appDir, this.projectDir});
final Directory appDir;
Directory? projectDir;
final Map<String, Object?> _appValues = <String, Object?>{};
final Map<String, Object?> _projectValues = <String, Object?>{};
Future<void> load() async {
_appValues
..clear()
..addAll(await _readFile(_appFile));
_projectValues.clear();
if (projectDir != null) {
_projectValues.addAll(await _readFile(_projectFile));
}
notifyListeners();
}
Future<void> setProjectDir(Directory? dir) async {
projectDir = dir;
_projectValues.clear();
if (dir != null) {
_projectValues.addAll(await _readFile(_projectFile));
}
notifyListeners();
}
File get _appFile => File('${appDir.path}/settings.yaml');
File get _projectFile => File('${projectDir!.path}/.clide/settings.yaml');
T? get<T>(String key) {
final v = _lookup(key);
if (v is T) return v;
if (T == int && v is num) return v.toInt() as T;
if (T == double && v is num) return v.toDouble() as T;
return null;
}
Object? _lookup(String key) {
switch (_scopeOf(key)) {
case SettingsScope.app:
return _appValues[key];
case SettingsScope.project:
return _projectValues[key];
case SettingsScope.ext:
// project overrides app for the same ext.* key
return _projectValues.containsKey(key)
? _projectValues[key]
: _appValues[key];
}
}
Future<void> set<T>(String key, T value) async {
switch (_scopeOf(key)) {
case SettingsScope.app:
_appValues[key] = value;
await _writeFile(_appFile, _appValues);
case SettingsScope.project:
if (projectDir == null) {
throw StateError(
'Cannot set project-scoped key with no project open: $key');
}
_projectValues[key] = value;
await _writeFile(_projectFile, _projectValues);
case SettingsScope.ext:
// default: store under app until an ext manifest requests project scope
_appValues[key] = value;
await _writeFile(_appFile, _appValues);
}
notifyListeners();
}
Future<Map<String, Object?>> _readFile(File f) async {
try {
if (!await f.exists()) return <String, Object?>{};
final txt = await f.readAsString();
if (txt.trim().isEmpty) return <String, Object?>{};
final yaml = loadYaml(txt);
final out = <String, Object?>{};
if (yaml is Map) _flatten(yaml, '', out);
return out;
} catch (_) {
// On web (or in sandboxes where the path isn't writable) silently
// degrade to an empty in-memory catalog. `set` will no-op too.
return <String, Object?>{};
}
}
Future<void> _writeFile(File f, Map<String, Object?> flat) async {
try {
await f.parent.create(recursive: true);
await f.writeAsString(_emitYaml(_unflatten(flat)));
} catch (_) {
// Web / read-only sandbox: in-memory update remains valid, we
// just can't persist. Callers already called notifyListeners.
}
}
static SettingsScope _scopeOf(String key) {
if (key.startsWith('app.')) return SettingsScope.app;
if (key.startsWith('project.')) return SettingsScope.project;
if (key.startsWith('ext.')) return SettingsScope.ext;
throw ArgumentError(
'Settings key must start with app.|project.|ext.: "$key"');
}
}
void _flatten(Map src, String prefix, Map<String, Object?> into) {
src.forEach((k, v) {
final key = prefix.isEmpty ? '$k' : '$prefix.$k';
if (v is Map) {
_flatten(v, key, into);
} else if (v is YamlList) {
into[key] = v.toList();
} else {
into[key] = v;
}
});
}
Map<String, Object?> _unflatten(Map<String, Object?> flat) {
final root = <String, Object?>{};
flat.forEach((k, v) {
final parts = k.split('.');
var cursor = root;
for (var i = 0; i < parts.length - 1; i++) {
final next = cursor[parts[i]];
if (next is Map<String, Object?>) {
cursor = next;
} else {
final fresh = <String, Object?>{};
cursor[parts[i]] = fresh;
cursor = fresh;
}
}
cursor[parts.last] = v;
});
return root;
}
String _emitYaml(Object? value, {int indent = 0}) {
final buf = StringBuffer();
_emit(buf, value, indent);
return buf.toString();
}
void _emit(StringBuffer buf, Object? v, int indent) {
final pad = ' ' * indent;
if (v is Map) {
if (v.isEmpty) {
buf.writeln('{}');
return;
}
v.forEach((k, vv) {
buf.write('$pad$k:');
if (vv is Map && vv.isNotEmpty) {
buf.writeln();
_emit(buf, vv, indent + 1);
} else if (vv is List && vv.isNotEmpty) {
buf.writeln();
for (final item in vv) {
buf.write('$pad- ');
_emitScalar(buf, item);
buf.writeln();
}
} else {
buf.write(' ');
_emitScalar(buf, vv);
buf.writeln();
}
});
return;
}
_emitScalar(buf, v);
buf.writeln();
}
void _emitScalar(StringBuffer buf, Object? v) {
if (v == null) {
buf.write('null');
} else if (v is bool || v is num) {
buf.write(v);
} else if (v is String) {
if (_needsQuoting(v)) {
buf.write('"${v.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"');
} else {
buf.write(v);
}
} else if (v is List) {
buf.write('[');
for (var i = 0; i < v.length; i++) {
if (i > 0) buf.write(', ');
_emitScalar(buf, v[i]);
}
buf.write(']');
} else {
buf.write('"${v.toString()}"');
}
}
bool _needsQuoting(String s) {
if (s.isEmpty) return true;
if (RegExp(r'[:\#\n\r\t]').hasMatch(s)) return true;
if (s != s.trim()) return true;
const reserved = {'true', 'false', 'null', 'yes', 'no', 'on', 'off', '~'};
if (reserved.contains(s.toLowerCase())) return true;
if (num.tryParse(s) != null) return true;
return false;
}
+151
View File
@@ -0,0 +1,151 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:clide_app/kernel/src/theme/tokens.dart';
import 'package:flutter/foundation.dart';
/// A foreground/background token pair the a11y contrast suite walks.
@immutable
class ContrastPair {
const ContrastPair({
required this.name,
required this.foreground,
required this.background,
this.largeText = false,
});
final String name;
final Color foreground;
final Color background;
/// WCAG AA threshold for "large text" (18pt, or 14pt bold) is 3:1;
/// normal text is 4.5:1. Mark a pair as [largeText] when the rendered
/// typography qualifies.
final bool largeText;
}
/// Compute the WCAG 2.x relative-luminance ratio between two colors.
///
/// Alpha is pre-composited against a neutral grey so semi-transparent
/// tokens don't spuriously pass. Returns a value in `[1, 21]`.
double contrastRatio(Color a, Color b, {Color onto = const Color(0xFF808080)}) {
final la = _relativeLuminance(_composite(a, onto));
final lb = _relativeLuminance(_composite(b, onto));
final brighter = math.max(la, lb);
final darker = math.min(la, lb);
return (brighter + 0.05) / (darker + 0.05);
}
/// Minimum ratio required for this pair per WCAG AA.
double minimumRatio(ContrastPair pair) => pair.largeText ? 3.0 : 4.5;
/// Canonical set of token pairs each bundled theme must honour.
///
/// The a11y contrast test walks this list per-theme.
List<ContrastPair> canonicalPairs(SurfaceTokens s) => [
ContrastPair(
name: 'global.text_on_background',
foreground: s.globalForeground,
background: s.globalBackground,
),
ContrastPair(
name: 'panel.header_foreground_on_panel',
foreground: s.panelHeaderForeground,
background: s.panelHeader,
),
ContrastPair(
name: 'sidebar.foreground_on_sidebar',
foreground: s.sidebarForeground,
background: s.sidebarBackground,
),
ContrastPair(
name: 'statusbar.foreground_on_statusbar',
foreground: s.statusBarForeground,
background: s.statusBarBackground,
),
ContrastPair(
name: 'tab.active_text_on_active_bg',
foreground: s.tabActiveForeground,
background: s.tabActive,
),
ContrastPair(
name: 'tab.inactive_text_on_inactive_bg',
foreground: s.tabInactiveForeground,
background: s.tabInactive,
),
ContrastPair(
name: 'button.text_on_button',
foreground: s.buttonForeground,
background: s.buttonBackground,
),
ContrastPair(
name: 'listItem.selected_text_on_selected_bg',
foreground: s.listItemSelectedForeground,
background: s.listItemSelectedBackground,
),
ContrastPair(
name: 'listItem.text_on_list',
foreground: s.listItemForeground,
background: s.listItemBackground,
),
ContrastPair(
name: 'tooltip.text_on_tooltip',
foreground: s.tooltipForeground,
background: s.tooltipBackground,
),
ContrastPair(
name: 'dropdown.text_on_dropdown',
foreground: s.dropdownForeground,
background: s.dropdownBackground,
),
];
/// Convenience for tests: returns the list of pairs that fail WCAG AA.
List<ContrastFailure> failingPairs(SurfaceTokens tokens) {
final out = <ContrastFailure>[];
for (final p in canonicalPairs(tokens)) {
final ratio = contrastRatio(p.foreground, p.background);
final need = minimumRatio(p);
if (ratio < need) {
out.add(ContrastFailure(pair: p, ratio: ratio, minimum: need));
}
}
return out;
}
@immutable
class ContrastFailure {
const ContrastFailure({
required this.pair,
required this.ratio,
required this.minimum,
});
final ContrastPair pair;
final double ratio;
final double minimum;
@override
String toString() => 'contrast ${pair.name}: ${ratio.toStringAsFixed(2)} < '
'${minimum.toStringAsFixed(1)}';
}
// -- internals ---------------------------------------------------------------
Color _composite(Color src, Color dst) {
final a = src.a;
if (a >= 0.999) return src;
double mix(double s, double d) => s * a + d * (1 - a);
return Color.from(
alpha: 1.0,
red: mix(src.r, dst.r),
green: mix(src.g, dst.g),
blue: mix(src.b, dst.b),
);
}
double _relativeLuminance(Color c) {
double chan(double v) =>
v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
return 0.2126 * chan(c.r) + 0.7152 * chan(c.g) + 0.0722 * chan(c.b);
}
+106
View File
@@ -0,0 +1,106 @@
import 'package:clide_app/kernel/src/theme/loader.dart';
import 'package:clide_app/kernel/src/theme/resolver.dart';
import 'package:clide_app/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!;
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'dart:io';
import 'package:clide_app/kernel/src/theme/palette.dart';
import 'package:clide_app/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);
final semantic = doc['semantic'];
final surface = doc['surface'];
final extension = doc['extension'];
return ThemeDefinition(
name: name,
displayName: displayName,
dark: dark,
palette: palette,
semanticOverride:
semantic is Map ? _parseSemantic(semantic, palette) : null,
surfaceOverride: surface is Map ? _parseRefMap(surface) : null,
extensionOverride: extension is Map ? _parseRefMap(extension) : null,
);
}
Future<ThemeDefinition> fromAsset(
AssetBundle bundle, String assetPath) async {
final txt = await bundle.loadString(assetPath);
final fallback = assetPath.split('/').last.replaceAll('.yaml', '');
return fromYamlString(txt, fallbackName: fallback);
}
Future<ThemeDefinition> fromFile(File f) async {
final txt = await f.readAsString();
final fallback = f.uri.pathSegments.last.replaceAll('.yaml', '');
return fromYamlString(txt, fallbackName: fallback);
}
}
Palette _parsePalette(Map src) {
final colors = <String, Color>{};
src.forEach((k, v) {
if (v is! String) return;
final c = Palette.parseHex(v);
if (c != null) colors['$k'] = c;
});
return Palette(colors);
}
SemanticRoles _parseSemantic(Map src, Palette palette) {
final roles = <String, Color>{};
src.forEach((k, v) {
if (v is! String) return;
final resolved =
v.startsWith('#') ? Palette.parseHex(v) : palette.lookup(v);
if (resolved != null) roles['$k'] = resolved;
});
return SemanticRoles(roles);
}
Map<String, String> _parseRefMap(Map src) {
final out = <String, String>{};
src.forEach((k, v) {
if (v is String) out['$k'] = v;
});
return out;
}
+22
View File
@@ -0,0 +1,22 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
@immutable
class Palette {
const Palette(this._colors);
final Map<String, Color> _colors;
Color? lookup(String name) => _colors[name];
Iterable<String> get names => _colors.keys;
static Color? parseHex(String s) {
var v = s.trim();
if (v.startsWith('#')) v = v.substring(1);
if (v.length == 6) v = 'FF$v';
if (v.length != 8) return null;
final n = int.tryParse(v, radix: 16);
if (n == null) return null;
return Color(n);
}
}
+238
View File
@@ -0,0 +1,238 @@
import 'dart:ui';
import 'package:clide_app/kernel/src/theme/palette.dart';
import 'package:clide_app/kernel/src/theme/semantic.dart';
import 'package:clide_app/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]!,
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 defaultRef = _defaultSurfaceMap[key];
if (defaultRef != null) {
final resolved = _resolveRef(defaultRef, palette, semantic);
if (resolved != null) return resolved;
}
// Last-ditch: something has to render. Fall back to semantic text.
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: ['panel', 'surface', 'background'],
SemanticKeys.calltoaction: ['accent', 'primary'],
SemanticKeys.focus: ['primary', 'accent'],
SemanticKeys.background: ['background'],
SemanticKeys.surface: ['surface', 'panel'],
SemanticKeys.text: ['foreground'],
SemanticKeys.textMuted: ['muted', 'secondary', 'foreground'],
SemanticKeys.success: ['success'],
SemanticKeys.warning: ['warning'],
SemanticKeys.error: ['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, String> _defaultSurfaceMap = {
TokenKeys.globalForeground: 'semantic.text',
TokenKeys.globalBackground: 'semantic.background',
TokenKeys.globalBorder: 'semantic.surface',
TokenKeys.globalFocus: 'semantic.focus',
TokenKeys.globalTextMuted: 'semantic.text_muted',
TokenKeys.panelBackground: 'semantic.mainchrome',
TokenKeys.panelBorder: 'semantic.surface',
TokenKeys.panelActiveBorder: 'semantic.focus',
TokenKeys.panelHeader: 'semantic.mainchrome',
TokenKeys.panelHeaderForeground: 'semantic.text',
TokenKeys.sidebarBackground: 'semantic.mainchrome',
TokenKeys.sidebarForeground: 'semantic.text',
TokenKeys.sidebarItemHover: 'semantic.surface',
TokenKeys.sidebarItemSelected: 'semantic.focus',
TokenKeys.sidebarSectionHeader: 'semantic.text_muted',
TokenKeys.statusBarBackground: 'semantic.mainchrome',
TokenKeys.statusBarForeground: 'semantic.text',
TokenKeys.statusBarItemActiveBackground: 'semantic.focus',
TokenKeys.statusBarItemHoverBackground: 'semantic.surface',
TokenKeys.tabBarBackground: 'semantic.mainchrome',
TokenKeys.tabActive: 'semantic.background',
TokenKeys.tabInactive: 'semantic.mainchrome',
TokenKeys.tabActiveForeground: 'semantic.text',
TokenKeys.tabInactiveForeground: 'semantic.text_muted',
TokenKeys.tabActiveBorder: 'semantic.focus',
TokenKeys.tabCloseHover: 'semantic.error',
TokenKeys.buttonBackground: 'semantic.surface',
TokenKeys.buttonForeground: 'semantic.text',
TokenKeys.buttonHoverBackground: 'semantic.mainchrome',
TokenKeys.buttonActiveBackground: 'semantic.focus',
TokenKeys.buttonBorder: 'semantic.surface',
TokenKeys.listItemBackground: 'semantic.background',
TokenKeys.listItemForeground: 'semantic.text',
TokenKeys.listItemHoverBackground: 'semantic.surface',
TokenKeys.listItemSelectedBackground: 'semantic.focus',
TokenKeys.listItemSelectedForeground: 'semantic.background',
TokenKeys.scrollbarSlider: 'semantic.surface',
TokenKeys.scrollbarSliderHover: 'semantic.text_muted',
TokenKeys.scrollbarTrack: 'semantic.mainchrome',
TokenKeys.tooltipBackground: 'semantic.surface',
TokenKeys.tooltipForeground: 'semantic.text',
TokenKeys.tooltipBorder: 'semantic.mainchrome',
TokenKeys.dropdownBackground: 'semantic.surface',
TokenKeys.dropdownForeground: 'semantic.text',
TokenKeys.dropdownBorder: 'semantic.mainchrome',
TokenKeys.modalOverlayBackground: '#C0000000',
TokenKeys.modalSurfaceBackground: 'semantic.mainchrome',
TokenKeys.modalSurfaceBorder: 'semantic.focus',
TokenKeys.dividerColor: 'semantic.surface',
TokenKeys.statusSuccess: 'semantic.success',
TokenKeys.statusWarning: 'semantic.warning',
TokenKeys.statusError: 'semantic.error',
TokenKeys.statusInfo: 'semantic.info',
};
+40
View File
@@ -0,0 +1,40 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
@immutable
class SemanticRoles {
const SemanticRoles(this._roles);
final Map<String, Color> _roles;
Color? lookup(String role) => _roles[role];
Iterable<String> get roles => _roles.keys;
}
abstract class SemanticKeys {
static const mainchrome = 'mainchrome';
static const calltoaction = 'calltoaction';
static const focus = 'focus';
static const background = 'background';
static const surface = 'surface';
static const text = 'text';
static const textMuted = 'text_muted';
static const success = 'success';
static const warning = 'warning';
static const error = 'error';
static const info = 'info';
static const all = <String>[
mainchrome,
calltoaction,
focus,
background,
surface,
text,
textMuted,
success,
warning,
error,
info,
];
}
@@ -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"
+293
View File
@@ -0,0 +1,293 @@
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,
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;
/// 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';
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,
];
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:clide_app/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;
}
}
+13
View File
@@ -0,0 +1,13 @@
/// Lua extension runtime boundary (Tier 6 impl; Tier 0 stubs only).
///
/// Third-party extensions run in a vendored `liblua` loaded via
/// `dart:ffi`, sandboxed to a narrow `clide.*` capability API, and
/// render via a declarative widget-intent DSL. The `LuaExtension`
/// adapter proxies to the Lua state so Lua extensions implement the
/// same `ClideExtension` contract as Dart built-ins.
library;
export 'src/adapter.dart';
export 'src/capability_api.dart';
export 'src/host.dart';
export 'src/render_intent.dart';
+30
View File
@@ -0,0 +1,30 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. The real adapter wraps a parsed Lua extension manifest
/// and a handle to the Lua state; contributions are proxied to
/// callbacks registered by `clide.contribute(...)` from the Lua side.
class LuaExtension extends ClideExtension {
LuaExtension({
required this.id,
required this.title,
required this.version,
this.dependsOn = const [],
});
@override
final String id;
@override
final String title;
@override
final String version;
@override
final List<String> dependsOn;
@override
List<ContributionPoint> get contributions => const [];
@override
Future<void> activate(ClideExtensionContext ctx) async {
throw UnsupportedError('Lua runtime lands at Tier 6.');
}
}

Some files were not shown because too many files have changed in this diff Show More