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:
+19
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -0,0 +1,3 @@
|
||||
# clide_app
|
||||
|
||||
A new Flutter project.
|
||||
@@ -0,0 +1 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 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 [];
|
||||
}
|
||||
@@ -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 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});
|
||||
}
|
||||
}
|
||||
@@ -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 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 [];
|
||||
}
|
||||
@@ -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 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 [];
|
||||
}
|
||||
@@ -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 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 [];
|
||||
}
|
||||
@@ -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 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 [];
|
||||
}
|
||||
@@ -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 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 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 [];
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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 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 [];
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' as flutter_services;
|
||||
|
||||
/// Typed, per-content-kind clipboard with a plaintext fallback.
|
||||
///
|
||||
/// Extensions write typed values (`write<GitHunk>(hunk)`) and read in
|
||||
/// the same type (`readAs<GitHunk>()`). Anything with a `toPlain`
|
||||
/// callback also syncs to the OS clipboard so external apps see
|
||||
/// reasonable text. The history ring keeps the last [historyLimit]
|
||||
/// entries per type for quick recall.
|
||||
class ClideClipboard {
|
||||
ClideClipboard({this.historyLimit = 16});
|
||||
|
||||
final int historyLimit;
|
||||
final Map<Type, List<Object>> _history = {};
|
||||
|
||||
Future<void> write<T extends Object>(
|
||||
T value, {
|
||||
String Function(T)? toPlain,
|
||||
}) async {
|
||||
final bucket = _history.putIfAbsent(T, () => <Object>[]);
|
||||
bucket.insert(0, value);
|
||||
if (bucket.length > historyLimit) bucket.removeLast();
|
||||
if (toPlain != null) {
|
||||
await flutter_services.Clipboard.setData(
|
||||
flutter_services.ClipboardData(text: toPlain(value)));
|
||||
}
|
||||
}
|
||||
|
||||
T? readAs<T extends Object>() {
|
||||
final bucket = _history[T];
|
||||
if (bucket == null || bucket.isEmpty) return null;
|
||||
return bucket.first as T;
|
||||
}
|
||||
|
||||
List<T> historyOf<T extends Object>() {
|
||||
final bucket = _history[T];
|
||||
if (bucket == null) return const [];
|
||||
return bucket.cast<T>().toList(growable: false);
|
||||
}
|
||||
|
||||
Future<String?> readPlain() async {
|
||||
final d = await flutter_services.Clipboard.getData('text/plain');
|
||||
return d?.text;
|
||||
}
|
||||
|
||||
Future<void> writePlain(String text) async {
|
||||
await flutter_services.Clipboard.setData(
|
||||
flutter_services.ClipboardData(text: text));
|
||||
final bucket = _history.putIfAbsent(String, () => <Object>[]);
|
||||
bucket.insert(0, text);
|
||||
if (bucket.length > historyLimit) bucket.removeLast();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void clear() => _history.clear();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Key combo: modifiers + primary key. Canonicalized on construction
|
||||
/// (modifiers sorted, lowercased) so equality works for lookup keys.
|
||||
@immutable
|
||||
class Keybinding {
|
||||
Keybinding({required Set<String> modifiers, required String key})
|
||||
: modifiers = _canonModifiers(modifiers),
|
||||
key = key.toLowerCase();
|
||||
|
||||
final List<String> modifiers;
|
||||
final String key;
|
||||
|
||||
static List<String> _canonModifiers(Set<String> m) {
|
||||
final normalized = m.map((s) => s.toLowerCase()).toSet().toList()..sort();
|
||||
return List.unmodifiable(normalized);
|
||||
}
|
||||
|
||||
/// Parse "ctrl+shift+g", "cmd+k", "alt+f4".
|
||||
static Keybinding parse(String spec) {
|
||||
if (spec.trim().isEmpty) {
|
||||
throw ArgumentError('empty keybinding');
|
||||
}
|
||||
final parts = spec.split('+').map((s) => s.trim()).toList();
|
||||
final key = parts.removeLast();
|
||||
if (key.isEmpty) {
|
||||
throw ArgumentError('keybinding is missing a key: "$spec"');
|
||||
}
|
||||
return Keybinding(modifiers: parts.toSet(), key: key);
|
||||
}
|
||||
|
||||
String get canonical {
|
||||
if (modifiers.isEmpty) return key;
|
||||
return '${modifiers.join('+')}+$key';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Keybinding &&
|
||||
other.key == key &&
|
||||
listEquals(other.modifiers, modifiers);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
|
||||
|
||||
@override
|
||||
String toString() => 'Keybinding($canonical)';
|
||||
}
|
||||
|
||||
class KeybindingResolver {
|
||||
final Map<Keybinding, String> _bindings = {};
|
||||
|
||||
void bind(Keybinding b, String commandId) {
|
||||
_bindings[b] = commandId;
|
||||
}
|
||||
|
||||
void unbind(Keybinding b) {
|
||||
_bindings.remove(b);
|
||||
}
|
||||
|
||||
String? commandFor(Keybinding b) => _bindings[b];
|
||||
|
||||
Iterable<MapEntry<Keybinding, String>> get entries => _bindings.entries;
|
||||
|
||||
/// Map a Flutter [KeyEvent] to a [Keybinding] suitable for lookup.
|
||||
static Keybinding? fromKeyEvent(KeyEvent event, HardwareKeyboard keyboard) {
|
||||
if (event is! KeyDownEvent) return null;
|
||||
final label = event.logicalKey.keyLabel;
|
||||
if (label.isEmpty) return null;
|
||||
final mods = <String>{};
|
||||
if (keyboard.isControlPressed) mods.add('ctrl');
|
||||
if (keyboard.isShiftPressed) mods.add('shift');
|
||||
if (keyboard.isAltPressed) mods.add('alt');
|
||||
if (keyboard.isMetaPressed) mods.add('cmd');
|
||||
return Keybinding(modifiers: mods, key: label);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:clide_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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef DialogBuilder<T> = Widget Function(
|
||||
BuildContext context,
|
||||
void Function([T? result]) dismiss,
|
||||
);
|
||||
|
||||
/// Single-at-a-time modal router.
|
||||
///
|
||||
/// Extensions call [show] with a builder; the root widget (installed by
|
||||
/// [DialogHost]) listens and renders the current dialog over a dimmed
|
||||
/// backdrop. Only one dialog is active at a time — a second [show] call
|
||||
/// while one is open awaits until the first dismisses.
|
||||
class DialogRouter extends ChangeNotifier {
|
||||
DialogBuilder<Object?>? _current;
|
||||
Completer<Object?>? _completer;
|
||||
final List<_Queued> _queue = [];
|
||||
|
||||
DialogBuilder<Object?>? get current => _current;
|
||||
bool get isOpen => _current != null;
|
||||
|
||||
Future<T?> show<T extends Object>(DialogBuilder<T> builder) {
|
||||
final completer = Completer<T?>();
|
||||
final wrapped = _wrap<T>(builder);
|
||||
if (_current == null) {
|
||||
_current = wrapped;
|
||||
_completer = Completer<Object?>();
|
||||
// forward our generic completer to the typed one
|
||||
_completer!.future.then((v) {
|
||||
if (!completer.isCompleted) completer.complete(v as T?);
|
||||
});
|
||||
notifyListeners();
|
||||
} else {
|
||||
_queue.add(_Queued(wrapped, completer));
|
||||
}
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void dismiss([Object? result]) {
|
||||
if (_current == null) return;
|
||||
final c = _completer;
|
||||
_current = null;
|
||||
_completer = null;
|
||||
if (c != null && !c.isCompleted) c.complete(result);
|
||||
if (_queue.isNotEmpty) {
|
||||
final next = _queue.removeAt(0);
|
||||
_current = next.builder;
|
||||
_completer = Completer<Object?>();
|
||||
_completer!.future.then((v) {
|
||||
if (!next.completer.isCompleted) next.completer.complete(v);
|
||||
});
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
DialogBuilder<Object?> _wrap<T>(DialogBuilder<T> builder) {
|
||||
return (ctx, dismiss) => builder(ctx, ([T? v]) => dismiss(v));
|
||||
}
|
||||
}
|
||||
|
||||
class _Queued {
|
||||
_Queued(this.builder, this.completer);
|
||||
final DialogBuilder<Object?> builder;
|
||||
// ignore: strict_raw_type
|
||||
final Completer completer;
|
||||
}
|
||||
|
||||
/// Hosts the current dialog from [DialogRouter]. Place high in the tree
|
||||
/// (inside the WidgetsApp) so dialogs overlay every other surface.
|
||||
class DialogHost extends StatelessWidget {
|
||||
const DialogHost({
|
||||
super.key,
|
||||
required this.router,
|
||||
required this.child,
|
||||
this.backdropColor = const Color(0xC0000000),
|
||||
});
|
||||
|
||||
final DialogRouter router;
|
||||
final Widget child;
|
||||
final Color backdropColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
child,
|
||||
ListenableBuilder(
|
||||
listenable: router,
|
||||
builder: (ctx, _) {
|
||||
final b = router.current;
|
||||
if (b == null) return const SizedBox.shrink();
|
||||
return Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: backdropColor,
|
||||
child: Center(
|
||||
child: b(ctx, router.dismiss),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide_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();
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
abstract class ClideEvent {
|
||||
const ClideEvent();
|
||||
|
||||
String get subsystem;
|
||||
String get kind;
|
||||
|
||||
Map<String, Object?> payload() => const {};
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ClideEventEnvelope {
|
||||
const ClideEventEnvelope(this.event, this.timestamp);
|
||||
|
||||
final ClideEvent event;
|
||||
final DateTime timestamp;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'v': 1,
|
||||
'subsystem': event.subsystem,
|
||||
'kind': event.kind,
|
||||
'ts': timestamp.toIso8601String(),
|
||||
'data': event.payload(),
|
||||
};
|
||||
}
|
||||
|
||||
class DaemonConnectionChanged extends ClideEvent {
|
||||
const DaemonConnectionChanged({required this.connected});
|
||||
final bool connected;
|
||||
@override
|
||||
String get subsystem => 'ipc';
|
||||
@override
|
||||
String get kind => 'connection-changed';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'connected': connected};
|
||||
}
|
||||
|
||||
class ThemeChanged extends ClideEvent {
|
||||
const ThemeChanged({required this.themeName});
|
||||
final String themeName;
|
||||
@override
|
||||
String get subsystem => 'theme';
|
||||
@override
|
||||
String get kind => 'changed';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'theme': themeName};
|
||||
}
|
||||
|
||||
class ProjectOpened extends ClideEvent {
|
||||
const ProjectOpened({required this.path});
|
||||
final String path;
|
||||
@override
|
||||
String get subsystem => 'project';
|
||||
@override
|
||||
String get kind => 'opened';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'path': path};
|
||||
}
|
||||
|
||||
class ProjectClosed extends ClideEvent {
|
||||
const ProjectClosed();
|
||||
@override
|
||||
String get subsystem => 'project';
|
||||
@override
|
||||
String get kind => 'closed';
|
||||
}
|
||||
|
||||
class ExtensionActivated extends ClideEvent {
|
||||
const ExtensionActivated({required this.id});
|
||||
final String id;
|
||||
@override
|
||||
String get subsystem => 'extensions';
|
||||
@override
|
||||
String get kind => 'activated';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'id': id};
|
||||
}
|
||||
|
||||
class ExtensionDeactivated extends ClideEvent {
|
||||
const ExtensionDeactivated({required this.id});
|
||||
final String id;
|
||||
@override
|
||||
String get subsystem => 'extensions';
|
||||
@override
|
||||
String get kind => 'deactivated';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'id': id};
|
||||
}
|
||||
|
||||
/// Forwarded from the daemon. Feature extensions subscribe to this and
|
||||
/// narrow by subsystem+kind, or register a converter that emits a typed
|
||||
/// `ClideEvent` subclass into the bus.
|
||||
class DaemonEvent extends ClideEvent {
|
||||
const DaemonEvent({
|
||||
required this.subsystem,
|
||||
required this.kind,
|
||||
required this.data,
|
||||
required this.ts,
|
||||
});
|
||||
|
||||
@override
|
||||
final String subsystem;
|
||||
@override
|
||||
final String kind;
|
||||
final Map<String, Object?> data;
|
||||
final DateTime ts;
|
||||
|
||||
@override
|
||||
Map<String, Object?> payload() => {'ts': ts.toIso8601String(), ...data};
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide_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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
enum LogLevel { trace, debug, info, warn, error }
|
||||
|
||||
class LogRecord {
|
||||
LogRecord({
|
||||
required this.level,
|
||||
required this.source,
|
||||
required this.message,
|
||||
required this.timestamp,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
});
|
||||
|
||||
final LogLevel level;
|
||||
final String source;
|
||||
final String message;
|
||||
final DateTime timestamp;
|
||||
final Object? error;
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final lv = level.name.toUpperCase().padRight(5);
|
||||
final buf =
|
||||
StringBuffer('${timestamp.toIso8601String()} $lv [$source] $message');
|
||||
if (error != null) buf.write(' | error=$error');
|
||||
return buf.toString();
|
||||
}
|
||||
}
|
||||
|
||||
typedef LogSink = void Function(LogRecord);
|
||||
|
||||
class Logger {
|
||||
Logger({this.minLevel = LogLevel.info, List<LogSink>? sinks})
|
||||
: _sinks = List<LogSink>.from(sinks ?? <LogSink>[stderrSink]);
|
||||
|
||||
LogLevel minLevel;
|
||||
final List<LogSink> _sinks;
|
||||
final StreamController<LogRecord> _stream =
|
||||
StreamController<LogRecord>.broadcast();
|
||||
|
||||
Stream<LogRecord> get records => _stream.stream;
|
||||
|
||||
void addSink(LogSink sink) => _sinks.add(sink);
|
||||
|
||||
void trace(String source, String message) =>
|
||||
_emit(LogLevel.trace, source, message);
|
||||
void debug(String source, String message) =>
|
||||
_emit(LogLevel.debug, source, message);
|
||||
void info(String source, String message) =>
|
||||
_emit(LogLevel.info, source, message);
|
||||
void warn(String source, String message, {Object? error}) =>
|
||||
_emit(LogLevel.warn, source, message, error: error);
|
||||
void error(String source, String message,
|
||||
{Object? error, StackTrace? stackTrace}) =>
|
||||
_emit(LogLevel.error, source, message,
|
||||
error: error, stackTrace: stackTrace);
|
||||
|
||||
void _emit(LogLevel level, String source, String message,
|
||||
{Object? error, StackTrace? stackTrace}) {
|
||||
if (level.index < minLevel.index) return;
|
||||
final rec = LogRecord(
|
||||
level: level,
|
||||
source: source,
|
||||
message: message,
|
||||
timestamp: DateTime.now().toUtc(),
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
for (final sink in _sinks) {
|
||||
try {
|
||||
sink(rec);
|
||||
} catch (_) {
|
||||
// a broken sink must not kill logging
|
||||
}
|
||||
}
|
||||
if (!_stream.isClosed) _stream.add(rec);
|
||||
}
|
||||
|
||||
Future<void> dispose() => _stream.close();
|
||||
}
|
||||
|
||||
void stderrSink(LogRecord r) {
|
||||
stderr.writeln(r);
|
||||
if (r.stackTrace != null) stderr.writeln(r.stackTrace);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum Reachability { online, offline, metered }
|
||||
|
||||
/// Tier-0 stub reachability observable. Hardcoded to `online`; real
|
||||
/// detection via a platform channel lands in a later tier.
|
||||
class NetworkStatus extends ChangeNotifier {
|
||||
Reachability _state = Reachability.online;
|
||||
Reachability get state => _state;
|
||||
bool get isOnline => _state != Reachability.offline;
|
||||
|
||||
@visibleForTesting
|
||||
void setState(Reachability r) {
|
||||
if (_state == r) return;
|
||||
_state = r;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum NotificationLevel { info, warning, error, success }
|
||||
|
||||
@immutable
|
||||
class ClideNotification {
|
||||
ClideNotification({
|
||||
required this.id,
|
||||
required this.level,
|
||||
required this.message,
|
||||
this.title,
|
||||
this.duration = const Duration(seconds: 4),
|
||||
}) : createdAt = DateTime.now().toUtc();
|
||||
|
||||
final String id;
|
||||
final NotificationLevel level;
|
||||
final String? title;
|
||||
final String message;
|
||||
final DateTime createdAt;
|
||||
final Duration duration;
|
||||
}
|
||||
|
||||
class Notifications extends ChangeNotifier {
|
||||
final List<ClideNotification> _active = [];
|
||||
final Map<String, Timer> _timers = {};
|
||||
int _seq = 0;
|
||||
|
||||
List<ClideNotification> get active => List.unmodifiable(_active);
|
||||
|
||||
void info(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.info, message, title: title, duration: duration);
|
||||
void warn(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.warning, message,
|
||||
title: title, duration: duration);
|
||||
void error(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.error, message, title: title, duration: duration);
|
||||
void success(String message, {String? title, Duration? duration}) =>
|
||||
_push(NotificationLevel.success, message,
|
||||
title: title, duration: duration);
|
||||
|
||||
void dismiss(String id) {
|
||||
_timers.remove(id)?.cancel();
|
||||
final before = _active.length;
|
||||
_active.removeWhere((n) => n.id == id);
|
||||
if (_active.length != before) notifyListeners();
|
||||
}
|
||||
|
||||
void _push(
|
||||
NotificationLevel level,
|
||||
String message, {
|
||||
String? title,
|
||||
Duration? duration,
|
||||
}) {
|
||||
final id = 'n${_seq++}';
|
||||
final n = ClideNotification(
|
||||
id: id,
|
||||
level: level,
|
||||
message: message,
|
||||
title: title,
|
||||
duration: duration ?? const Duration(seconds: 4),
|
||||
);
|
||||
_active.add(n);
|
||||
_timers[id] = Timer(n.duration, () => dismiss(id));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final t in _timers.values) {
|
||||
t.cancel();
|
||||
}
|
||||
_timers.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide_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;
|
||||
}
|
||||
}
|
||||
@@ -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 180–400)
|
||||
/// center flex (workspace on top, statusbar below)
|
||||
/// context 280 (drag 220–420)
|
||||
/// statusbar 26 (fixed height strip)
|
||||
LayoutPresetContribution classicPreset() => const LayoutPresetContribution(
|
||||
id: 'builtin.default-layout.classic',
|
||||
displayName: 'Classic',
|
||||
slots: [
|
||||
LayoutSlot(
|
||||
slot: Slots.sidebar,
|
||||
position: SlotPosition.left,
|
||||
defaultSize: 240,
|
||||
minSize: 180,
|
||||
maxSize: 400,
|
||||
),
|
||||
LayoutSlot(
|
||||
slot: Slots.workspace,
|
||||
position: SlotPosition.center,
|
||||
),
|
||||
LayoutSlot(
|
||||
slot: Slots.contextPanel,
|
||||
position: SlotPosition.right,
|
||||
defaultSize: 280,
|
||||
minSize: 220,
|
||||
maxSize: 420,
|
||||
),
|
||||
LayoutSlot(
|
||||
slot: Slots.statusbar,
|
||||
position: SlotPosition.bottom,
|
||||
defaultSize: 26,
|
||||
minSize: 26,
|
||||
maxSize: 26,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:clide_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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class SlotId {
|
||||
const SlotId(this.value);
|
||||
final String value;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is SlotId && other.value == value;
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'SlotId($value)';
|
||||
}
|
||||
|
||||
/// Kernel-reserved slot ids. Extensions can declare new slots; these are
|
||||
/// the ones the default layout presets and the kernel services target.
|
||||
abstract class Slots {
|
||||
static const sidebar = SlotId('sidebar');
|
||||
static const workspace = SlotId('workspace');
|
||||
static const contextPanel = SlotId('context');
|
||||
static const statusbar = SlotId('statusbar');
|
||||
static const toolbar = SlotId('toolbar.main');
|
||||
static const commandPalette = SlotId('commandPalette');
|
||||
static const tray = SlotId('tray');
|
||||
static const fullscreen = SlotId('fullscreen');
|
||||
}
|
||||
|
||||
enum SlotPosition { left, right, top, bottom, center, float, popout }
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// Tier-0 in-memory stub for the OS-keychain-backed vault.
|
||||
///
|
||||
/// Lands on `libsecret` (Linux) and macOS Keychain in a later tier.
|
||||
/// The async API already matches the eventual platform-channel shape,
|
||||
/// so consumers don't need to change when the real backend arrives.
|
||||
class SecretsVault {
|
||||
final Map<String, String> _memory = {};
|
||||
|
||||
Future<void> write({
|
||||
required String extensionId,
|
||||
required String key,
|
||||
required String value,
|
||||
}) async {
|
||||
_memory['$extensionId/$key'] = value;
|
||||
}
|
||||
|
||||
Future<String?> read({
|
||||
required String extensionId,
|
||||
required String key,
|
||||
}) async {
|
||||
return _memory['$extensionId/$key'];
|
||||
}
|
||||
|
||||
Future<void> delete({
|
||||
required String extensionId,
|
||||
required String key,
|
||||
}) async {
|
||||
_memory.remove('$extensionId/$key');
|
||||
}
|
||||
|
||||
Future<void> deleteAll({required String extensionId}) async {
|
||||
_memory.removeWhere((k, _) => k.startsWith('$extensionId/'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
enum SettingsScope { app, project, ext }
|
||||
|
||||
class SettingsStore extends ChangeNotifier {
|
||||
SettingsStore({required this.appDir, this.projectDir});
|
||||
|
||||
final Directory appDir;
|
||||
Directory? projectDir;
|
||||
|
||||
final Map<String, Object?> _appValues = <String, Object?>{};
|
||||
final Map<String, Object?> _projectValues = <String, Object?>{};
|
||||
|
||||
Future<void> load() async {
|
||||
_appValues
|
||||
..clear()
|
||||
..addAll(await _readFile(_appFile));
|
||||
_projectValues.clear();
|
||||
if (projectDir != null) {
|
||||
_projectValues.addAll(await _readFile(_projectFile));
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setProjectDir(Directory? dir) async {
|
||||
projectDir = dir;
|
||||
_projectValues.clear();
|
||||
if (dir != null) {
|
||||
_projectValues.addAll(await _readFile(_projectFile));
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
File get _appFile => File('${appDir.path}/settings.yaml');
|
||||
File get _projectFile => File('${projectDir!.path}/.clide/settings.yaml');
|
||||
|
||||
T? get<T>(String key) {
|
||||
final v = _lookup(key);
|
||||
if (v is T) return v;
|
||||
if (T == int && v is num) return v.toInt() as T;
|
||||
if (T == double && v is num) return v.toDouble() as T;
|
||||
return null;
|
||||
}
|
||||
|
||||
Object? _lookup(String key) {
|
||||
switch (_scopeOf(key)) {
|
||||
case SettingsScope.app:
|
||||
return _appValues[key];
|
||||
case SettingsScope.project:
|
||||
return _projectValues[key];
|
||||
case SettingsScope.ext:
|
||||
// project overrides app for the same ext.* key
|
||||
return _projectValues.containsKey(key)
|
||||
? _projectValues[key]
|
||||
: _appValues[key];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> set<T>(String key, T value) async {
|
||||
switch (_scopeOf(key)) {
|
||||
case SettingsScope.app:
|
||||
_appValues[key] = value;
|
||||
await _writeFile(_appFile, _appValues);
|
||||
case SettingsScope.project:
|
||||
if (projectDir == null) {
|
||||
throw StateError(
|
||||
'Cannot set project-scoped key with no project open: $key');
|
||||
}
|
||||
_projectValues[key] = value;
|
||||
await _writeFile(_projectFile, _projectValues);
|
||||
case SettingsScope.ext:
|
||||
// default: store under app until an ext manifest requests project scope
|
||||
_appValues[key] = value;
|
||||
await _writeFile(_appFile, _appValues);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readFile(File f) async {
|
||||
try {
|
||||
if (!await f.exists()) return <String, Object?>{};
|
||||
final txt = await f.readAsString();
|
||||
if (txt.trim().isEmpty) return <String, Object?>{};
|
||||
final yaml = loadYaml(txt);
|
||||
final out = <String, Object?>{};
|
||||
if (yaml is Map) _flatten(yaml, '', out);
|
||||
return out;
|
||||
} catch (_) {
|
||||
// On web (or in sandboxes where the path isn't writable) silently
|
||||
// degrade to an empty in-memory catalog. `set` will no-op too.
|
||||
return <String, Object?>{};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeFile(File f, Map<String, Object?> flat) async {
|
||||
try {
|
||||
await f.parent.create(recursive: true);
|
||||
await f.writeAsString(_emitYaml(_unflatten(flat)));
|
||||
} catch (_) {
|
||||
// Web / read-only sandbox: in-memory update remains valid, we
|
||||
// just can't persist. Callers already called notifyListeners.
|
||||
}
|
||||
}
|
||||
|
||||
static SettingsScope _scopeOf(String key) {
|
||||
if (key.startsWith('app.')) return SettingsScope.app;
|
||||
if (key.startsWith('project.')) return SettingsScope.project;
|
||||
if (key.startsWith('ext.')) return SettingsScope.ext;
|
||||
throw ArgumentError(
|
||||
'Settings key must start with app.|project.|ext.: "$key"');
|
||||
}
|
||||
}
|
||||
|
||||
void _flatten(Map src, String prefix, Map<String, Object?> into) {
|
||||
src.forEach((k, v) {
|
||||
final key = prefix.isEmpty ? '$k' : '$prefix.$k';
|
||||
if (v is Map) {
|
||||
_flatten(v, key, into);
|
||||
} else if (v is YamlList) {
|
||||
into[key] = v.toList();
|
||||
} else {
|
||||
into[key] = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, Object?> _unflatten(Map<String, Object?> flat) {
|
||||
final root = <String, Object?>{};
|
||||
flat.forEach((k, v) {
|
||||
final parts = k.split('.');
|
||||
var cursor = root;
|
||||
for (var i = 0; i < parts.length - 1; i++) {
|
||||
final next = cursor[parts[i]];
|
||||
if (next is Map<String, Object?>) {
|
||||
cursor = next;
|
||||
} else {
|
||||
final fresh = <String, Object?>{};
|
||||
cursor[parts[i]] = fresh;
|
||||
cursor = fresh;
|
||||
}
|
||||
}
|
||||
cursor[parts.last] = v;
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
String _emitYaml(Object? value, {int indent = 0}) {
|
||||
final buf = StringBuffer();
|
||||
_emit(buf, value, indent);
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
void _emit(StringBuffer buf, Object? v, int indent) {
|
||||
final pad = ' ' * indent;
|
||||
if (v is Map) {
|
||||
if (v.isEmpty) {
|
||||
buf.writeln('{}');
|
||||
return;
|
||||
}
|
||||
v.forEach((k, vv) {
|
||||
buf.write('$pad$k:');
|
||||
if (vv is Map && vv.isNotEmpty) {
|
||||
buf.writeln();
|
||||
_emit(buf, vv, indent + 1);
|
||||
} else if (vv is List && vv.isNotEmpty) {
|
||||
buf.writeln();
|
||||
for (final item in vv) {
|
||||
buf.write('$pad- ');
|
||||
_emitScalar(buf, item);
|
||||
buf.writeln();
|
||||
}
|
||||
} else {
|
||||
buf.write(' ');
|
||||
_emitScalar(buf, vv);
|
||||
buf.writeln();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
_emitScalar(buf, v);
|
||||
buf.writeln();
|
||||
}
|
||||
|
||||
void _emitScalar(StringBuffer buf, Object? v) {
|
||||
if (v == null) {
|
||||
buf.write('null');
|
||||
} else if (v is bool || v is num) {
|
||||
buf.write(v);
|
||||
} else if (v is String) {
|
||||
if (_needsQuoting(v)) {
|
||||
buf.write('"${v.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"');
|
||||
} else {
|
||||
buf.write(v);
|
||||
}
|
||||
} else if (v is List) {
|
||||
buf.write('[');
|
||||
for (var i = 0; i < v.length; i++) {
|
||||
if (i > 0) buf.write(', ');
|
||||
_emitScalar(buf, v[i]);
|
||||
}
|
||||
buf.write(']');
|
||||
} else {
|
||||
buf.write('"${v.toString()}"');
|
||||
}
|
||||
}
|
||||
|
||||
bool _needsQuoting(String s) {
|
||||
if (s.isEmpty) return true;
|
||||
if (RegExp(r'[:\#\n\r\t]').hasMatch(s)) return true;
|
||||
if (s != s.trim()) return true;
|
||||
const reserved = {'true', 'false', 'null', 'yes', 'no', 'on', 'off', '~'};
|
||||
if (reserved.contains(s.toLowerCase())) return true;
|
||||
if (num.tryParse(s) != null) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class Palette {
|
||||
const Palette(this._colors);
|
||||
final Map<String, Color> _colors;
|
||||
|
||||
Color? lookup(String name) => _colors[name];
|
||||
Iterable<String> get names => _colors.keys;
|
||||
|
||||
static Color? parseHex(String s) {
|
||||
var v = s.trim();
|
||||
if (v.startsWith('#')) v = v.substring(1);
|
||||
if (v.length == 6) v = 'FF$v';
|
||||
if (v.length != 8) return null;
|
||||
final n = int.tryParse(v, radix: 16);
|
||||
if (n == null) return null;
|
||||
return Color(n);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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',
|
||||
};
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
Reference in New Issue
Block a user