diff --git a/.gitignore b/.gitignore index 8f550862..a0d70ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da8a2d5..18274712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..3820a95c --- /dev/null +++ b/app/.gitignore @@ -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 diff --git a/app/.metadata b/app/.metadata new file mode 100644 index 00000000..93e71337 --- /dev/null +++ b/app/.metadata @@ -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' diff --git a/app/README.md b/app/README.md new file mode 100644 index 00000000..2cf77c1a --- /dev/null +++ b/app/README.md @@ -0,0 +1,3 @@ +# clide_app + +A new Flutter project. diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml new file mode 100644 index 00000000..f9b30346 --- /dev/null +++ b/app/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/app/lib/app.dart b/app/lib/app.dart new file mode 100644 index 00000000..ba030a7c --- /dev/null +++ b/app/lib/app.dart @@ -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: (RouteSettings settings, WidgetBuilder builder) => + PageRouteBuilder( + 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() + .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), + ], + ), + ); + }, + ); + } +} diff --git a/app/lib/builtin/canvas/canvas.dart b/app/lib/builtin/canvas/canvas.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/canvas/canvas.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/canvas/src/extension.dart b/app/lib/builtin/canvas/src/extension.dart new file mode 100644 index 00000000..bc2068ba --- /dev/null +++ b/app/lib/builtin/canvas/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 get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/claude/claude.dart b/app/lib/builtin/claude/claude.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/claude/claude.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/claude/src/extension.dart b/app/lib/builtin/claude/src/extension.dart new file mode 100644 index 00000000..1c48c1aa --- /dev/null +++ b/app/lib/builtin/claude/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 get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/default_layout/default_layout.dart b/app/lib/builtin/default_layout/default_layout.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/default_layout/default_layout.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/default_layout/src/extension.dart b/app/lib/builtin/default_layout/src/extension.dart new file mode 100644 index 00000000..b09668a3 --- /dev/null +++ b/app/lib/builtin/default_layout/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 get contributions => [ + _preset ?? classicPreset(), + CommandContribution( + id: 'layout.reset', + command: 'layout.reset', + title: 'Layout: Reset to Classic', + run: _reset, + ), + ]; + + @override + Future 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 _reset(List 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}); + } +} diff --git a/app/lib/builtin/diff/diff.dart b/app/lib/builtin/diff/diff.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/diff/diff.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/diff/src/extension.dart b/app/lib/builtin/diff/src/extension.dart new file mode 100644 index 00000000..7708aaa0 --- /dev/null +++ b/app/lib/builtin/diff/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 get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/editor/editor.dart b/app/lib/builtin/editor/editor.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/editor/editor.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/editor/src/extension.dart b/app/lib/builtin/editor/src/extension.dart new file mode 100644 index 00000000..08eff0cf --- /dev/null +++ b/app/lib/builtin/editor/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 get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/extensions_ui/extensions_ui.dart b/app/lib/builtin/extensions_ui/extensions_ui.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/extensions_ui/extensions_ui.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/extensions_ui/src/extension.dart b/app/lib/builtin/extensions_ui/src/extension.dart new file mode 100644 index 00000000..5f8b1e35 --- /dev/null +++ b/app/lib/builtin/extensions_ui/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 get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/files/files.dart b/app/lib/builtin/files/files.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/files/files.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/files/src/extension.dart b/app/lib/builtin/files/src/extension.dart new file mode 100644 index 00000000..f800f20e --- /dev/null +++ b/app/lib/builtin/files/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 get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/git/git.dart b/app/lib/builtin/git/git.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/git/git.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/git/src/extension.dart b/app/lib/builtin/git/src/extension.dart new file mode 100644 index 00000000..cc70e4d8 --- /dev/null +++ b/app/lib/builtin/git/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 get dependsOn => const ['builtin.diff']; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/grammars_core/grammars_core.dart b/app/lib/builtin/grammars_core/grammars_core.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/grammars_core/grammars_core.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/grammars_core/src/extension.dart b/app/lib/builtin/grammars_core/src/extension.dart new file mode 100644 index 00000000..c6880586 --- /dev/null +++ b/app/lib/builtin/grammars_core/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 get dependsOn => const ['builtin.editor']; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/graph/graph.dart b/app/lib/builtin/graph/graph.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/graph/graph.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/graph/src/extension.dart b/app/lib/builtin/graph/src/extension.dart new file mode 100644 index 00000000..ca0d9da7 --- /dev/null +++ b/app/lib/builtin/graph/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 get dependsOn => const ['builtin.pql']; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/ipc_status/ipc_status.dart b/app/lib/builtin/ipc_status/ipc_status.dart new file mode 100644 index 00000000..46d9aaef --- /dev/null +++ b/app/lib/builtin/ipc_status/ipc_status.dart @@ -0,0 +1,2 @@ +export 'src/extension.dart'; +export 'src/status_item.dart'; diff --git a/app/lib/builtin/ipc_status/src/extension.dart b/app/lib/builtin/ipc_status/src/extension.dart new file mode 100644 index 00000000..62b015bf --- /dev/null +++ b/app/lib/builtin/ipc_status/src/extension.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 activate(ClideExtensionContext ctx) async { + _ipc = ctx.ipc; + } + + @override + List 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), + ), + ]; + } +} diff --git a/app/lib/builtin/ipc_status/src/status_item.dart b/app/lib/builtin/ipc_status/src/status_item.dart new file mode 100644 index 00000000..8d33daba --- /dev/null +++ b/app/lib/builtin/ipc_status/src/status_item.dart @@ -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), + ], + ), + ), + ); + }, + ); + } +} diff --git a/app/lib/builtin/jira/jira.dart b/app/lib/builtin/jira/jira.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/jira/jira.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/jira/src/extension.dart b/app/lib/builtin/jira/src/extension.dart new file mode 100644 index 00000000..c924aaaf --- /dev/null +++ b/app/lib/builtin/jira/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 get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/keybindings_ui/keybindings_ui.dart b/app/lib/builtin/keybindings_ui/keybindings_ui.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/keybindings_ui/keybindings_ui.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/keybindings_ui/src/extension.dart b/app/lib/builtin/keybindings_ui/src/extension.dart new file mode 100644 index 00000000..6fb913a3 --- /dev/null +++ b/app/lib/builtin/keybindings_ui/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 get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/markdown/markdown.dart b/app/lib/builtin/markdown/markdown.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/markdown/markdown.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/markdown/src/extension.dart b/app/lib/builtin/markdown/src/extension.dart new file mode 100644 index 00000000..7b06fa65 --- /dev/null +++ b/app/lib/builtin/markdown/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 get dependsOn => const ['builtin.editor']; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/pql/pql.dart b/app/lib/builtin/pql/pql.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/pql/pql.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/pql/src/extension.dart b/app/lib/builtin/pql/src/extension.dart new file mode 100644 index 00000000..cd118ae4 --- /dev/null +++ b/app/lib/builtin/pql/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 get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/problems/problems.dart b/app/lib/builtin/problems/problems.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/problems/problems.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/problems/src/extension.dart b/app/lib/builtin/problems/src/extension.dart new file mode 100644 index 00000000..7337459b --- /dev/null +++ b/app/lib/builtin/problems/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 get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/settings_ui/settings_ui.dart b/app/lib/builtin/settings_ui/settings_ui.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/settings_ui/settings_ui.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/settings_ui/src/extension.dart b/app/lib/builtin/settings_ui/src/extension.dart new file mode 100644 index 00000000..b7a90330 --- /dev/null +++ b/app/lib/builtin/settings_ui/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 get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/terminal/src/extension.dart b/app/lib/builtin/terminal/src/extension.dart new file mode 100644 index 00000000..b0a48149 --- /dev/null +++ b/app/lib/builtin/terminal/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 TerminalExtension extends ClideExtension { + @override + String get id => 'builtin.terminal'; + @override + String get title => 'Terminal'; + @override + String get version => '0.0.0-stub'; + @override + List get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/terminal/terminal.dart b/app/lib/builtin/terminal/terminal.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/terminal/terminal.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/theme_picker/src/extension.dart b/app/lib/builtin/theme_picker/src/extension.dart new file mode 100644 index 00000000..a296f3bf --- /dev/null +++ b/app/lib/builtin/theme_picker/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 activate(ClideExtensionContext ctx) async { + _ctx = ctx; + } + + @override + List get contributions => [ + CommandContribution( + id: 'theme.pick', + command: 'theme.pick', + title: 'Theme: Pick…', + defaultBinding: 'ctrl+k', + run: _pick, + ), + ]; + + Future _pick(List 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( + (context, dismiss) => ThemePickerView( + controller: ctx.theme, + onDismiss: dismiss, + ), + ); + return IpcResponse.ok(id: '', data: { + 'selected': selected ?? ctx.theme.currentName, + }); + } +} diff --git a/app/lib/builtin/theme_picker/src/picker_view.dart b/app/lib/builtin/theme_picker/src/picker_view.dart new file mode 100644 index 00000000..0d0119c9 --- /dev/null +++ b/app/lib/builtin/theme_picker/src/picker_view.dart @@ -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 createState() => _ThemePickerViewState(); +} + +class _ThemePickerViewState extends State { + 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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/builtin/theme_picker/theme_picker.dart b/app/lib/builtin/theme_picker/theme_picker.dart new file mode 100644 index 00000000..9a298824 --- /dev/null +++ b/app/lib/builtin/theme_picker/theme_picker.dart @@ -0,0 +1,2 @@ +export 'src/extension.dart'; +export 'src/picker_view.dart'; diff --git a/app/lib/builtin/todos/src/extension.dart b/app/lib/builtin/todos/src/extension.dart new file mode 100644 index 00000000..c23a76c9 --- /dev/null +++ b/app/lib/builtin/todos/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 TodosExtension extends ClideExtension { + @override + String get id => 'builtin.todos'; + @override + String get title => 'TODOs'; + @override + String get version => '0.0.0-stub'; + @override + List get dependsOn => const []; + @override + List get contributions => const []; +} diff --git a/app/lib/builtin/todos/todos.dart b/app/lib/builtin/todos/todos.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/todos/todos.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/builtin/welcome/src/extension.dart b/app/lib/builtin/welcome/src/extension.dart new file mode 100644 index 00000000..1e7699fc --- /dev/null +++ b/app/lib/builtin/welcome/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 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'}, + ), + ), + ]; +} diff --git a/app/lib/builtin/welcome/src/welcome_view.dart b/app/lib/builtin/welcome/src/welcome_view.dart new file mode 100644 index 00000000..1d9ceb2a --- /dev/null +++ b/app/lib/builtin/welcome/src/welcome_view.dart @@ -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 + }, + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/app/lib/builtin/welcome/welcome.dart b/app/lib/builtin/welcome/welcome.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/app/lib/builtin/welcome/welcome.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/app/lib/extension/extension.dart b/app/lib/extension/extension.dart new file mode 100644 index 00000000..52ab24f1 --- /dev/null +++ b/app/lib/extension/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'; diff --git a/app/lib/extension/src/contribution.dart b/app/lib/extension/src/contribution.dart new file mode 100644 index 00000000..d30daac7 --- /dev/null +++ b/app/lib/extension/src/contribution.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 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 Function(List 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 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; +} diff --git a/app/lib/extension/src/extension.dart b/app/lib/extension/src/extension.dart new file mode 100644 index 00000000..0b43c061 --- /dev/null +++ b/app/lib/extension/src/extension.dart @@ -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 get dependsOn => const []; + + /// The atoms this extension contributes. + List get contributions; + + /// Called once after dependencies activate. + Future activate(ClideExtensionContext ctx) async {} + + /// Called when the extension is disabled or the app shuts down. + Future 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 replacers = const [], + }) => + i18n.interpolated( + key, + namespace: id, + placeholder: placeholder, + replacers: replacers, + ); +} diff --git a/app/lib/extension/src/host.dart b/app/lib/extension/src/host.dart new file mode 100644 index 00000000..90db520a --- /dev/null +++ b/app/lib/extension/src/host.dart @@ -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//manifest.yaml`. + /// Override for tests. + Directory defaultRoot() { + final home = Platform.environment['HOME'] ?? '/tmp'; + return Directory('$home/.clide/extensions'); + } + + Future> discover({Directory? root}) async { + final dir = root ?? defaultRoot(); + if (!await dir.exists()) return const []; + final out = []; + 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; + } +} diff --git a/app/lib/extension/src/manifest.dart b/app/lib/extension/src/manifest.dart new file mode 100644 index 00000000..d6db9919 --- /dev/null +++ b/app/lib/extension/src/manifest.dart @@ -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//` 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 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 = []; + 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 fromFile(File f) async => + ExtensionManifest.fromYamlString(await f.readAsString()); +} diff --git a/app/lib/kernel/kernel.dart b/app/lib/kernel/kernel.dart new file mode 100644 index 00000000..c57da238 --- /dev/null +++ b/app/lib/kernel/kernel.dart @@ -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'; diff --git a/app/lib/kernel/src/clipboard.dart b/app/lib/kernel/src/clipboard.dart new file mode 100644 index 00000000..8701404b --- /dev/null +++ b/app/lib/kernel/src/clipboard.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(hunk)`) and read in +/// the same type (`readAs()`). 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> _history = {}; + + Future write( + T value, { + String Function(T)? toPlain, + }) async { + final bucket = _history.putIfAbsent(T, () => []); + 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() { + final bucket = _history[T]; + if (bucket == null || bucket.isEmpty) return null; + return bucket.first as T; + } + + List historyOf() { + final bucket = _history[T]; + if (bucket == null) return const []; + return bucket.cast().toList(growable: false); + } + + Future readPlain() async { + final d = await flutter_services.Clipboard.getData('text/plain'); + return d?.text; + } + + Future writePlain(String text) async { + await flutter_services.Clipboard.setData( + flutter_services.ClipboardData(text: text)); + final bucket = _history.putIfAbsent(String, () => []); + bucket.insert(0, text); + if (bucket.length > historyLimit) bucket.removeLast(); + } + + @visibleForTesting + void clear() => _history.clear(); +} diff --git a/app/lib/kernel/src/commands/keybindings.dart b/app/lib/kernel/src/commands/keybindings.dart new file mode 100644 index 00000000..84fdbde2 --- /dev/null +++ b/app/lib/kernel/src/commands/keybindings.dart @@ -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 modifiers, required String key}) + : modifiers = _canonModifiers(modifiers), + key = key.toLowerCase(); + + final List modifiers; + final String key; + + static List _canonModifiers(Set 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 _bindings = {}; + + void bind(Keybinding b, String commandId) { + _bindings[b] = commandId; + } + + void unbind(Keybinding b) { + _bindings.remove(b); + } + + String? commandFor(Keybinding b) => _bindings[b]; + + Iterable> 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 = {}; + 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); + } +} diff --git a/app/lib/kernel/src/commands/palette.dart b/app/lib/kernel/src/commands/palette.dart new file mode 100644 index 00000000..cb28a2e3 --- /dev/null +++ b/app/lib/kernel/src/commands/palette.dart @@ -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 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 invoke(String command) async { + close(); + await _registry.execute(command); + } +} diff --git a/app/lib/kernel/src/commands/registry.dart b/app/lib/kernel/src/commands/registry.dart new file mode 100644 index 00000000..27036e9d --- /dev/null +++ b/app/lib/kernel/src/commands/registry.dart @@ -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 _byCommand = {}; + + void register(CommandContribution cmd) { + _byCommand[cmd.command] = cmd; + notifyListeners(); + } + + void unregister(String command) { + if (_byCommand.remove(command) != null) notifyListeners(); + } + + Iterable get all => _byCommand.values; + CommandContribution? get(String command) => _byCommand[command]; + + Future execute( + String command, { + List 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); + } +} diff --git a/app/lib/kernel/src/dialog.dart b/app/lib/kernel/src/dialog.dart new file mode 100644 index 00000000..6b88cf82 --- /dev/null +++ b/app/lib/kernel/src/dialog.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +typedef DialogBuilder = 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? _current; + Completer? _completer; + final List<_Queued> _queue = []; + + DialogBuilder? get current => _current; + bool get isOpen => _current != null; + + Future show(DialogBuilder builder) { + final completer = Completer(); + final wrapped = _wrap(builder); + if (_current == null) { + _current = wrapped; + _completer = Completer(); + // 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(); + _completer!.future.then((v) { + if (!next.completer.isCompleted) next.completer.complete(v); + }); + } + notifyListeners(); + } + + DialogBuilder _wrap(DialogBuilder builder) { + return (ctx, dismiss) => builder(ctx, ([T? v]) => dismiss(v)); + } +} + +class _Queued { + _Queued(this.builder, this.completer); + final DialogBuilder 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), + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/app/lib/kernel/src/events/bus.dart b/app/lib/kernel/src/events/bus.dart new file mode 100644 index 00000000..a99bcc35 --- /dev/null +++ b/app/lib/kernel/src/events/bus.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:clide_app/kernel/src/events/types.dart'; + +class EventBus { + EventBus(); + + final StreamController _controller = + StreamController.broadcast(); + + Stream get stream => _controller.stream; + + Stream on() => + _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 dispose() => _controller.close(); +} diff --git a/app/lib/kernel/src/events/types.dart b/app/lib/kernel/src/events/types.dart new file mode 100644 index 00000000..b43d44e5 --- /dev/null +++ b/app/lib/kernel/src/events/types.dart @@ -0,0 +1,112 @@ +import 'package:flutter/foundation.dart'; + +@immutable +abstract class ClideEvent { + const ClideEvent(); + + String get subsystem; + String get kind; + + Map payload() => const {}; +} + +@immutable +class ClideEventEnvelope { + const ClideEventEnvelope(this.event, this.timestamp); + + final ClideEvent event; + final DateTime timestamp; + + Map 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 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 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 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 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 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 data; + final DateTime ts; + + @override + Map payload() => {'ts': ts.toIso8601String(), ...data}; +} diff --git a/app/lib/kernel/src/extensions_manager.dart b/app/lib/kernel/src/extensions_manager.dart new file mode 100644 index 00000000..7d6c9a6f --- /dev/null +++ b/app/lib/kernel/src/extensions_manager.dart @@ -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 _known = {}; + final Set _activated = {}; + + void register(ClideExtension ext) { + if (_known.containsKey(ext.id)) { + log.warn('extensions', 'duplicate registration: ${ext.id}'); + return; + } + _known[ext.id] = ext; + notifyListeners(); + } + + Iterable get all => _known.values; + bool isActivated(String id) => _activated.contains(id); + + bool isEnabled(String id) { + final v = settings.get('app.extensions.$id.enabled'); + return v ?? true; + } + + Future setEnabled(String id, bool enabled) async { + await settings.set('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 activateAll() async { + final order = _topoSort(); + for (final id in order) { + if (!isEnabled(id)) continue; + await activate(id); + } + } + + Future 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 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 _topoSort() { + final order = []; + final seen = {}; + final visiting = {}; + + 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; +} diff --git a/app/lib/kernel/src/facade.dart b/app/lib/kernel/src/facade.dart new file mode 100644 index 00000000..718f5a81 --- /dev/null +++ b/app/lib/kernel/src/facade.dart @@ -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 boot({ + required Directory appDir, + required List bundledThemes, + required CatalogLoader i18nLoader, + List preloadNamespaces = const [], + Locale defaultLocale = const Locale('en', 'US'), + Locale? initialLocale, + List 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 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(); + 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; +} diff --git a/app/lib/kernel/src/files.dart b/app/lib/kernel/src/files.dart new file mode 100644 index 00000000..e11565ff --- /dev/null +++ b/app/lib/kernel/src/files.dart @@ -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 paths; + final SlotId slot; + + @override + String get subsystem => 'files'; + @override + String get kind => 'dropped'; + @override + Map 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> pickOpen({ + List extensions = const [], + bool multiple = false, + }) async { + throw UnimplementedError('pickOpen — wired in a later tier'); + } + + Future pickSave({ + String? defaultName, + List extensions = const [], + }) async { + throw UnimplementedError('pickSave — wired in a later tier'); + } + + Future 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 paths, required SlotId slot}) { + _events.emit(FilesDropped(paths: paths, slot: slot)); + } +} diff --git a/app/lib/kernel/src/focus.dart b/app/lib/kernel/src/focus.dart new file mode 100644 index 00000000..73ce32a8 --- /dev/null +++ b/app/lib/kernel/src/focus.dart @@ -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(); + } +} diff --git a/app/lib/kernel/src/i18n/catalog/builtin.default-layout_en_us.json b/app/lib/kernel/src/i18n/catalog/builtin.default-layout_en_us.json new file mode 100644 index 00000000..290f3418 --- /dev/null +++ b/app/lib/kernel/src/i18n/catalog/builtin.default-layout_en_us.json @@ -0,0 +1,4 @@ +{ + "command.reset": { "translation": "Layout: Reset to Classic" }, + "preset.classic": { "translation": "Classic" } +} diff --git a/app/lib/kernel/src/i18n/catalog/builtin.ipc-status_en_us.json b/app/lib/kernel/src/i18n/catalog/builtin.ipc-status_en_us.json new file mode 100644 index 00000000..04402f8d --- /dev/null +++ b/app/lib/kernel/src/i18n/catalog/builtin.ipc-status_en_us.json @@ -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`" } +} diff --git a/app/lib/kernel/src/i18n/catalog/builtin.theme-picker_en_us.json b/app/lib/kernel/src/i18n/catalog/builtin.theme-picker_en_us.json new file mode 100644 index 00000000..80572214 --- /dev/null +++ b/app/lib/kernel/src/i18n/catalog/builtin.theme-picker_en_us.json @@ -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" } +} diff --git a/app/lib/kernel/src/i18n/catalog/builtin.welcome_en_us.json b/app/lib/kernel/src/i18n/catalog/builtin.welcome_en_us.json new file mode 100644 index 00000000..af25d7db --- /dev/null +++ b/app/lib/kernel/src/i18n/catalog/builtin.welcome_en_us.json @@ -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" } +} diff --git a/app/lib/kernel/src/i18n/catalog_loader.dart b/app/lib/kernel/src/i18n/catalog_loader.dart new file mode 100644 index 00000000..18850aab --- /dev/null +++ b/app/lib/kernel/src/i18n/catalog_loader.dart @@ -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//`). +/// +/// 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> 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> 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(); + 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> 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(); + } 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>> _map; + + @override + Future> 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; +} diff --git a/app/lib/kernel/src/i18n/fallback_chain.dart b/app/lib/kernel/src/i18n/fallback_chain.dart new file mode 100644 index 00000000..714ed997 --- /dev/null +++ b/app/lib/kernel/src/i18n/fallback_chain.dart @@ -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 resolve() { + final out = []; + 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'; + } +} diff --git a/app/lib/kernel/src/i18n/i18n.dart b/app/lib/kernel/src/i18n/i18n.dart new file mode 100644 index 00000000..f25efd29 --- /dev/null +++ b/app/lib/kernel/src/i18n/i18n.dart @@ -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 availableLocales = const [Locale('en', 'US')], + }) : _defaultLocale = defaultLocale, + _current = initialLocale ?? defaultLocale, + _available = List.unmodifiable(availableLocales); + + final CatalogLoader loader; + final Logger log; + + final Locale _defaultLocale; + Locale _current; + final List _available; + + /// namespace -> locale -> flat key map + final Map>> _cache = {}; + + /// Keys we've already warned about for a given (namespace, key, locale). + /// Keeps the log quiet across repeated lookups. + final Set _warnedMisses = {}; + + Locale get currentLocale => _current; + Locale get defaultLocale => _defaultLocale; + List 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 catalog, + ) { + _cache.putIfAbsent( + namespace, () => >{})[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 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 ensureNamespaceLoaded(String namespace) async { + await _ensureLoaded(namespace); + } + + Future _ensureLoaded(String namespace) async { + final byLocale = _cache.putIfAbsent( + namespace, + () => >{}, + ); + 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 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 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); + } + } +} diff --git a/app/lib/kernel/src/ipc/client.dart b/app/lib/kernel/src/ipc/client.dart new file mode 100644 index 00000000..f0c410ec --- /dev/null +++ b/app/lib/kernel/src/ipc/client.dart @@ -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> _pending = {}; + + bool get isConnected => _connected; + + Future start() async { + _disposed = false; + await _connect(); + } + + Future stop() async { + _disposed = true; + _reconnectTimer?.cancel(); + _reconnectTimer = null; + final s = _socket; + _socket = null; + await s?.close(); + _failPending('client stopped'); + _setConnected(false); + } + + Future request( + String cmd, { + Map 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(); + _pending[id] = completer; + final req = IpcRequest(id: id, cmd: cmd, args: args); + _socket!.writeln(req.encode()); + return completer.future; + } + + Future _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>() + .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(); + } +} diff --git a/app/lib/kernel/src/log.dart b/app/lib/kernel/src/log.dart new file mode 100644 index 00000000..ec2d9c92 --- /dev/null +++ b/app/lib/kernel/src/log.dart @@ -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? sinks}) + : _sinks = List.from(sinks ?? [stderrSink]); + + LogLevel minLevel; + final List _sinks; + final StreamController _stream = + StreamController.broadcast(); + + Stream 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 dispose() => _stream.close(); +} + +void stderrSink(LogRecord r) { + stderr.writeln(r); + if (r.stackTrace != null) stderr.writeln(r.stackTrace); +} diff --git a/app/lib/kernel/src/net.dart b/app/lib/kernel/src/net.dart new file mode 100644 index 00000000..a12759aa --- /dev/null +++ b/app/lib/kernel/src/net.dart @@ -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(); + } +} diff --git a/app/lib/kernel/src/notify.dart b/app/lib/kernel/src/notify.dart new file mode 100644 index 00000000..fb71759d --- /dev/null +++ b/app/lib/kernel/src/notify.dart @@ -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 _active = []; + final Map _timers = {}; + int _seq = 0; + + List 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(); + } +} diff --git a/app/lib/kernel/src/os.dart b/app/lib/kernel/src/os.dart new file mode 100644 index 00000000..56e12cce --- /dev/null +++ b/app/lib/kernel/src/os.dart @@ -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 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 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? _openCommand() { + if (Platform.isLinux) return ['xdg-open']; + if (Platform.isMacOS) return ['open']; + if (Platform.isWindows) return ['cmd', '/c', 'start', '']; + return null; + } + + static List? _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; + } +} diff --git a/app/lib/kernel/src/panels/arrangement.dart b/app/lib/kernel/src/panels/arrangement.dart new file mode 100644 index 00000000..784c0203 --- /dev/null +++ b/app/lib/kernel/src/panels/arrangement.dart @@ -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 _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 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, + ); + } +} diff --git a/app/lib/kernel/src/panels/drag_resize.dart b/app/lib/kernel/src/panels/drag_resize.dart new file mode 100644 index 00000000..eeec462e --- /dev/null +++ b/app/lib/kernel/src/panels/drag_resize.dart @@ -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 createState() => _DragResizeHandleState(); +} + +class _DragResizeHandleState extends State { + 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; + } +} diff --git a/app/lib/kernel/src/panels/layout_preset.dart b/app/lib/kernel/src/panels/layout_preset.dart new file mode 100644 index 00000000..9d7455c9 --- /dev/null +++ b/app/lib/kernel/src/panels/layout_preset.dart @@ -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, + ), + ], + ); diff --git a/app/lib/kernel/src/panels/registry.dart b/app/lib/kernel/src/panels/registry.dart new file mode 100644 index 00000000..33f9eaab --- /dev/null +++ b/app/lib/kernel/src/panels/registry.dart @@ -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 _defs = {}; + final Map> _mounts = {}; + final Map _activeTab = {}; + + void registerSlot(SlotDefinition def) { + _defs[def.id] = def; + _mounts.putIfAbsent(def.id, () => []); + notifyListeners(); + } + + void contribute(ContributionPoint point) { + final slot = point.slot; + if (slot == null) return; // non-slot contributions go elsewhere + final list = _mounts.putIfAbsent(slot, () => []); + 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().isEmpty + ? null + : entry.value.whereType().first.id; + } + } + } + notifyListeners(); + } + + Iterable get slots => _defs.values; + SlotDefinition? definitionFor(SlotId id) => _defs[id]; + + List contributionsFor(SlotId id) => + List.unmodifiable(_mounts[id] ?? const []); + + List tabsFor(SlotId id) => + contributionsFor(id).whereType().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; + } +} diff --git a/app/lib/kernel/src/panels/slot_id.dart b/app/lib/kernel/src/panels/slot_id.dart new file mode 100644 index 00000000..f77ac55c --- /dev/null +++ b/app/lib/kernel/src/panels/slot_id.dart @@ -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 } diff --git a/app/lib/kernel/src/project.dart b/app/lib/kernel/src/project.dart new file mode 100644 index 00000000..a6bb84bd --- /dev/null +++ b/app/lib/kernel/src/project.dart @@ -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 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 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 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; + } + } +} diff --git a/app/lib/kernel/src/secrets.dart b/app/lib/kernel/src/secrets.dart new file mode 100644 index 00000000..b3537cd2 --- /dev/null +++ b/app/lib/kernel/src/secrets.dart @@ -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 _memory = {}; + + Future write({ + required String extensionId, + required String key, + required String value, + }) async { + _memory['$extensionId/$key'] = value; + } + + Future read({ + required String extensionId, + required String key, + }) async { + return _memory['$extensionId/$key']; + } + + Future delete({ + required String extensionId, + required String key, + }) async { + _memory.remove('$extensionId/$key'); + } + + Future deleteAll({required String extensionId}) async { + _memory.removeWhere((k, _) => k.startsWith('$extensionId/')); + } +} diff --git a/app/lib/kernel/src/settings.dart b/app/lib/kernel/src/settings.dart new file mode 100644 index 00000000..1bf3a90f --- /dev/null +++ b/app/lib/kernel/src/settings.dart @@ -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 _appValues = {}; + final Map _projectValues = {}; + + Future load() async { + _appValues + ..clear() + ..addAll(await _readFile(_appFile)); + _projectValues.clear(); + if (projectDir != null) { + _projectValues.addAll(await _readFile(_projectFile)); + } + notifyListeners(); + } + + Future 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(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 set(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> _readFile(File f) async { + try { + if (!await f.exists()) return {}; + final txt = await f.readAsString(); + if (txt.trim().isEmpty) return {}; + final yaml = loadYaml(txt); + final out = {}; + 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 {}; + } + } + + Future _writeFile(File f, Map 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 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 _unflatten(Map flat) { + final root = {}; + 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) { + cursor = next; + } else { + final fresh = {}; + 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; +} diff --git a/app/lib/kernel/src/theme/contrast.dart b/app/lib/kernel/src/theme/contrast.dart new file mode 100644 index 00000000..66a766cd --- /dev/null +++ b/app/lib/kernel/src/theme/contrast.dart @@ -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 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 failingPairs(SurfaceTokens tokens) { + final out = []; + 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); +} diff --git a/app/lib/kernel/src/theme/controller.dart b/app/lib/kernel/src/theme/controller.dart new file mode 100644 index 00000000..de3bd279 --- /dev/null +++ b/app/lib/kernel/src/theme/controller.dart @@ -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 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 _defs; + + late String _currentName; + late ClideThemeData _current; + + ClideThemeData get current => _current; + String get currentName => _currentName; + List 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 { + const ClideTheme({ + super.key, + required ThemeController controller, + required super.child, + }) : super(notifier: controller); + + static ClideThemeData of(BuildContext context) { + final w = context.dependOnInheritedWidgetOfExactType(); + 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(); + if (w == null) { + throw FlutterError( + 'ClideTheme.controllerOf() called with a context that is not a descendant of a ClideTheme.'); + } + return w.notifier!; + } +} diff --git a/app/lib/kernel/src/theme/loader.dart b/app/lib/kernel/src/theme/loader.dart new file mode 100644 index 00000000..7ad6eb37 --- /dev/null +++ b/app/lib/kernel/src/theme/loader.dart @@ -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? surfaceOverride; + final Map? 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 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 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 = {}; + 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 = {}; + 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 _parseRefMap(Map src) { + final out = {}; + src.forEach((k, v) { + if (v is String) out['$k'] = v; + }); + return out; +} diff --git a/app/lib/kernel/src/theme/palette.dart b/app/lib/kernel/src/theme/palette.dart new file mode 100644 index 00000000..de7c38cc --- /dev/null +++ b/app/lib/kernel/src/theme/palette.dart @@ -0,0 +1,22 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +@immutable +class Palette { + const Palette(this._colors); + final Map _colors; + + Color? lookup(String name) => _colors[name]; + Iterable 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); + } +} diff --git a/app/lib/kernel/src/theme/resolver.dart b/app/lib/kernel/src/theme/resolver.dart new file mode 100644 index 00000000..2af9f4e7 --- /dev/null +++ b/app/lib/kernel/src/theme/resolver.dart @@ -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.` — look up in [semantic] +/// `#rrggbb` / `#aarrggbb` — literal hex +/// bare name — palette lookup +class ThemeResolver { + const ThemeResolver(); + + SurfaceTokens resolve({ + required Palette palette, + SemanticRoles? semanticOverride, + Map? surfaceOverride, + Map? extensionOverride, + }) { + final semantic = _buildSemantic(palette, semanticOverride); + final surface = {}; + for (final key in TokenKeys.all) { + surface[key] = _resolveSurface( + key: key, + palette: palette, + semantic: semantic, + surfaceOverride: surfaceOverride, + ); + } + + final extTokens = {}; + 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 = {}; + 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? 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> _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 _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', +}; diff --git a/app/lib/kernel/src/theme/semantic.dart b/app/lib/kernel/src/theme/semantic.dart new file mode 100644 index 00000000..b04c857b --- /dev/null +++ b/app/lib/kernel/src/theme/semantic.dart @@ -0,0 +1,40 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +@immutable +class SemanticRoles { + const SemanticRoles(this._roles); + final Map _roles; + + Color? lookup(String role) => _roles[role]; + Iterable 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 = [ + mainchrome, + calltoaction, + focus, + background, + surface, + text, + textMuted, + success, + warning, + error, + info, + ]; +} diff --git a/app/lib/kernel/src/theme/themes/summer-night.yaml b/app/lib/kernel/src/theme/themes/summer-night.yaml new file mode 100644 index 00000000..41be6069 --- /dev/null +++ b/app/lib/kernel/src/theme/themes/summer-night.yaml @@ -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" diff --git a/app/lib/kernel/src/theme/tokens.dart b/app/lib/kernel/src/theme/tokens.dart new file mode 100644 index 00000000..a18f5d3e --- /dev/null +++ b/app/lib/kernel/src/theme/tokens.dart @@ -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 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 = [ + 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, + ]; +} diff --git a/app/lib/kernel/src/tray.dart b/app/lib/kernel/src/tray.dart new file mode 100644 index 00000000..3bc3981a --- /dev/null +++ b/app/lib/kernel/src/tray.dart @@ -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 _items = {}; + + void add(TrayItemContribution item) { + _items[item.id] = item; + notifyListeners(); + } + + void remove(String id) { + if (_items.remove(id) != null) notifyListeners(); + } + + Iterable get items { + final sorted = _items.values.toList() + ..sort((a, b) => a.priority.compareTo(b.priority)); + return sorted; + } +} diff --git a/app/lib/lua/lua.dart b/app/lib/lua/lua.dart new file mode 100644 index 00000000..d79435b3 --- /dev/null +++ b/app/lib/lua/lua.dart @@ -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'; diff --git a/app/lib/lua/src/adapter.dart b/app/lib/lua/src/adapter.dart new file mode 100644 index 00000000..b74586b7 --- /dev/null +++ b/app/lib/lua/src/adapter.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 dependsOn; + + @override + List get contributions => const []; + + @override + Future activate(ClideExtensionContext ctx) async { + throw UnsupportedError('Lua runtime lands at Tier 6.'); + } +} diff --git a/app/lib/lua/src/capability_api.dart b/app/lib/lua/src/capability_api.dart new file mode 100644 index 00000000..28c27dde --- /dev/null +++ b/app/lib/lua/src/capability_api.dart @@ -0,0 +1,13 @@ +/// Tier-0 stub for the sandboxed `clide.*` table exposed to third-party +/// Lua extensions. +/// +/// The surface is deliberately narrow: `clide.ipc.request`, +/// `clide.events.on`, `clide.log`, `clide.contribute`, and scoped +/// kernel-service getters that mirror `ClideExtensionContext`. Lua +/// code has no `io`, no `os.execute`, no `package.loadlib`, no `debug` +/// — those are removed from the global state at sandbox init. +/// +/// Full binding table lands with the Lua runtime at Tier 6. +class CapabilityApi { + const CapabilityApi(); +} diff --git a/app/lib/lua/src/host.dart b/app/lib/lua/src/host.dart new file mode 100644 index 00000000..d20972ca --- /dev/null +++ b/app/lib/lua/src/host.dart @@ -0,0 +1,14 @@ +/// Tier-0 stub. The real implementation loads a vendored liblua via +/// `dart:ffi` and exposes a `LuaState` handle that [LuaExtension] uses +/// to run script callbacks. Lands at Tier 6. +class LuaHost { + LuaHost._(); + + /// Boot the vendored liblua. Throws until Tier 6. + static Future start() async { + throw UnsupportedError( + 'Lua runtime lands at Tier 6 (supporter tool sibling of ptyc).'); + } + + Future dispose() async {} +} diff --git a/app/lib/lua/src/render_intent.dart b/app/lib/lua/src/render_intent.dart new file mode 100644 index 00000000..32968d9f --- /dev/null +++ b/app/lib/lua/src/render_intent.dart @@ -0,0 +1,14 @@ +/// Tier-0 stub for the declarative widget DSL Lua extensions return +/// from their `on_render` callbacks. +/// +/// Lua can't construct Flutter widgets; instead it returns tables like +/// `{type="list", items={...}}` or `{type="stack", children={...}}`, +/// which the Dart renderer maps to widget primitives. The vocabulary +/// is closed and documented — extensions declare intent, the shell +/// renders it with consistent theming. +/// +/// The concrete intent shapes (list, tree, stack, button, text, input, +/// editor-embed) land with the Lua adapter at Tier 6. +sealed class RenderIntent { + const RenderIntent(); +} diff --git a/app/lib/main.dart b/app/lib/main.dart new file mode 100644 index 00000000..685ff17d --- /dev/null +++ b/app/lib/main.dart @@ -0,0 +1,119 @@ +import 'package:clide_app/app.dart'; +import 'package:clide_app/builtin/canvas/canvas.dart'; +import 'package:clide_app/builtin/claude/claude.dart'; +import 'package:clide_app/builtin/default_layout/default_layout.dart'; +import 'package:clide_app/builtin/diff/diff.dart'; +import 'package:clide_app/builtin/editor/editor.dart'; +import 'package:clide_app/builtin/extensions_ui/extensions_ui.dart'; +import 'package:clide_app/builtin/files/files.dart'; +import 'package:clide_app/builtin/git/git.dart'; +import 'package:clide_app/builtin/grammars_core/grammars_core.dart'; +import 'package:clide_app/builtin/graph/graph.dart'; +import 'package:clide_app/builtin/ipc_status/ipc_status.dart'; +import 'package:clide_app/builtin/jira/jira.dart'; +import 'package:clide_app/builtin/keybindings_ui/keybindings_ui.dart'; +import 'package:clide_app/builtin/markdown/markdown.dart'; +import 'package:clide_app/builtin/pql/pql.dart'; +import 'package:clide_app/builtin/problems/problems.dart'; +import 'package:clide_app/builtin/settings_ui/settings_ui.dart'; +import 'package:clide_app/builtin/terminal/terminal.dart'; +import 'package:clide_app/builtin/theme_picker/theme_picker.dart'; +import 'package:clide_app/builtin/todos/todos.dart'; +import 'package:clide_app/builtin/welcome/welcome.dart'; +import 'dart:io' show Directory, Platform; + +import 'package:clide_app/kernel/kernel.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter/widgets.dart'; + +Future main() async { + final binding = WidgetsFlutterBinding.ensureInitialized(); + + // Enable the semantics tree at boot so screen readers (Orca, VoiceOver, + // NVDA) + the headless-browser automation harness both work out of the + // box. Production release builds could gate this on a platform check, + // but a11y-first means always-on. + binding.ensureSemantics(); + + final appDir = await _resolveAppDir(); + final themes = await _loadBundledThemes(); + + final services = await KernelServices.boot( + appDir: appDir, + bundledThemes: themes, + i18nLoader: AssetCatalogLoader(bundle: rootBundle), + preloadNamespaces: _tier0Namespaces, + // The local unix-socket daemon is desktop-only. On web the + // connection indicator stays `disconnected` — that's the honest + // state: the Flutter-web build can't reach a local socket. + autoStartDaemonClient: !kIsWeb, + socketPath: kIsWeb ? '/clide-web-no-socket' : null, + ); + + // Register every built-in. Tier 0 activates only the four that do + // real work; the rest compile in as stubs so the extensions-ui can + // list them when Tier 6 lands. + services.extensions + ..register(DefaultLayoutExtension()) + ..register(WelcomeExtension()) + ..register(IpcStatusExtension()) + ..register(ThemePickerExtension()) + ..register(ClaudeExtension()) + ..register(TerminalExtension()) + ..register(FilesExtension()) + ..register(EditorExtension()) + ..register(GrammarsCoreExtension()) + ..register(MarkdownExtension()) + ..register(DiffExtension()) + ..register(GitExtension()) + ..register(PqlExtension()) + ..register(TodosExtension()) + ..register(ProblemsExtension()) + ..register(JiraExtension()) + ..register(CanvasExtension()) + ..register(GraphExtension()) + ..register(SettingsUiExtension()) + ..register(ExtensionsUiExtension()) + ..register(KeybindingsUiExtension()); + + await services.extensions.activateAll(); + + runApp(ClideApp(services: services)); +} + +/// Resolve the app-settings directory. +/// +/// On web we don't touch the filesystem — hand back a sentinel dir so +/// `SettingsStore.load()` silently no-ops (its read-file helper returns +/// `{}` when the file doesn't exist). +Future _resolveAppDir() async { + if (kIsWeb) return Directory('/clide-web-no-disk'); + final home = Platform.environment['HOME'] ?? '/tmp'; + final xdg = Platform.environment['XDG_CONFIG_HOME'] ?? '$home/.config'; + final dir = Directory('$xdg/clide'); + await dir.create(recursive: true); + return dir; +} + +Future> _loadBundledThemes() async { + const loader = ThemeLoader(); + const paths = [ + 'lib/kernel/src/theme/themes/summer-night.yaml', + ]; + final out = []; + for (final p in paths) { + out.add(await loader.fromAsset(rootBundle, p)); + } + return out; +} + +/// Every Tier-0 extension that ships an i18n catalog. Extensions +/// registered but not active (the 17 stubs) don't preload — their +/// catalogs load lazily on activate in later tiers. +const List _tier0Namespaces = [ + 'builtin.default-layout', + 'builtin.welcome', + 'builtin.ipc-status', + 'builtin.theme-picker', +]; diff --git a/app/lib/widgets/src/clide_button.dart b/app/lib/widgets/src/clide_button.dart new file mode 100644 index 00000000..206ffed4 --- /dev/null +++ b/app/lib/widgets/src/clide_button.dart @@ -0,0 +1,105 @@ +import 'package:clide_app/kernel/src/theme/controller.dart'; +import 'package:clide_app/widgets/src/clide_text.dart'; +import 'package:flutter/widgets.dart'; + +enum ClideButtonVariant { normal, primary, subtle } + +class ClideButton extends StatefulWidget { + const ClideButton({ + super.key, + required this.label, + required this.onPressed, + this.variant = ClideButtonVariant.normal, + this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + this.semanticLabel, + this.semanticHint, + }); + + final String label; + final VoidCallback? onPressed; + final ClideButtonVariant variant; + final EdgeInsetsGeometry padding; + + /// Overrides [label] for screen readers (use when the visible label is + /// an icon-only glyph or a noun that reads oddly when announced). + final String? semanticLabel; + + /// Screen-reader hint describing the button's effect. Optional. + final String? semanticHint; + + @override + State createState() => _ClideButtonState(); +} + +class _ClideButtonState extends State { + bool _hovered = false; + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final enabled = widget.onPressed != null; + + Color bg; + Color fg; + switch (widget.variant) { + case ClideButtonVariant.normal: + bg = _pressed + ? tokens.buttonActiveBackground + : _hovered + ? tokens.buttonHoverBackground + : tokens.buttonBackground; + fg = tokens.buttonForeground; + case ClideButtonVariant.primary: + bg = _pressed + ? tokens.panelActiveBorder + : _hovered + ? tokens.buttonActiveBackground + : tokens.buttonActiveBackground; + fg = tokens.globalBackground; + case ClideButtonVariant.subtle: + bg = _hovered + ? tokens.listItemHoverBackground + : tokens.listItemBackground; + fg = tokens.listItemForeground; + } + + return Semantics( + button: true, + enabled: enabled, + label: widget.semanticLabel ?? widget.label, + hint: widget.semanticHint, + onTap: enabled ? widget.onPressed : null, + excludeSemantics: true, + child: MouseRegion( + cursor: + enabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapCancel: () => setState(() => _pressed = false), + onTapUp: (_) { + setState(() => _pressed = false); + widget.onPressed?.call(); + }, + child: Container( + padding: widget.padding, + decoration: BoxDecoration( + color: bg, + border: Border.all(color: tokens.buttonBorder, width: 1), + borderRadius: BorderRadius.circular(3), + ), + child: ClideText( + widget.label, + color: fg, + fontWeight: widget.variant == ClideButtonVariant.primary + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/src/clide_divider.dart b/app/lib/widgets/src/clide_divider.dart new file mode 100644 index 00000000..7221ea86 --- /dev/null +++ b/app/lib/widgets/src/clide_divider.dart @@ -0,0 +1,23 @@ +import 'package:clide_app/kernel/src/theme/controller.dart'; +import 'package:flutter/widgets.dart'; + +class ClideDivider extends StatelessWidget { + const ClideDivider({ + super.key, + this.axis = Axis.horizontal, + this.thickness = 1, + }); + + final Axis axis; + final double thickness; + + @override + Widget build(BuildContext context) { + final color = ClideTheme.of(context).surface.dividerColor; + return Container( + width: axis == Axis.vertical ? thickness : null, + height: axis == Axis.horizontal ? thickness : null, + color: color, + ); + } +} diff --git a/app/lib/widgets/src/clide_icon.dart b/app/lib/widgets/src/clide_icon.dart new file mode 100644 index 00000000..52443247 --- /dev/null +++ b/app/lib/widgets/src/clide_icon.dart @@ -0,0 +1,57 @@ +import 'package:clide_app/kernel/src/theme/controller.dart'; +import 'package:flutter/widgets.dart'; + +/// Stateless painter producing a single-color icon. Every icon in +/// [icons/] subclasses this; widgets wrap with [ClideIcon] for size + +/// color-from-theme. +abstract class ClideIconPainter { + const ClideIconPainter(); + + /// Paint the icon into a unit square (0,0 .. 1,1). + void paint(Canvas canvas, Color color); +} + +class ClideIcon extends StatelessWidget { + const ClideIcon( + this.painter, { + super.key, + this.size = 14, + this.color, + }); + + final ClideIconPainter painter; + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + final resolved = color ?? ClideTheme.of(context).surface.globalForeground; + return SizedBox( + width: size, + height: size, + child: CustomPaint( + size: Size(size, size), + painter: _IconPainterAdapter(painter: painter, color: resolved), + ), + ); + } +} + +class _IconPainterAdapter extends CustomPainter { + _IconPainterAdapter({required this.painter, required this.color}); + + final ClideIconPainter painter; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.scale(size.width, size.height); + painter.paint(canvas, color); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _IconPainterAdapter old) => + old.painter != painter || old.color != color; +} diff --git a/app/lib/widgets/src/clide_scrollbar.dart b/app/lib/widgets/src/clide_scrollbar.dart new file mode 100644 index 00000000..6627c258 --- /dev/null +++ b/app/lib/widgets/src/clide_scrollbar.dart @@ -0,0 +1,54 @@ +import 'package:clide_app/kernel/src/theme/controller.dart'; +import 'package:flutter/widgets.dart'; + +/// Thin themed scrollbar. Tier-0 shell; more refined scrolling (velocity +/// multiplier, keyboard nav) lands with the editor. +class ClideScrollbar extends StatelessWidget { + const ClideScrollbar({ + super.key, + required this.controller, + required this.child, + this.axis = Axis.vertical, + }); + + final ScrollController controller; + final Widget child; + final Axis axis; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return ScrollbarTheme( + slider: tokens.scrollbarSlider, + sliderHover: tokens.scrollbarSliderHover, + track: tokens.scrollbarTrack, + child: RawScrollbar( + controller: controller, + thumbColor: tokens.scrollbarSlider, + thickness: 8, + radius: const Radius.circular(4), + child: child, + ), + ); + } +} + +class ScrollbarTheme extends InheritedWidget { + const ScrollbarTheme({ + super.key, + required this.slider, + required this.sliderHover, + required this.track, + required super.child, + }); + + final Color slider; + final Color sliderHover; + final Color track; + + @override + bool updateShouldNotify(ScrollbarTheme old) => + slider != old.slider || + sliderHover != old.sliderHover || + track != old.track; +} diff --git a/app/lib/widgets/src/clide_surface.dart b/app/lib/widgets/src/clide_surface.dart new file mode 100644 index 00000000..d780d774 --- /dev/null +++ b/app/lib/widgets/src/clide_surface.dart @@ -0,0 +1,42 @@ +import 'package:clide_app/kernel/src/theme/controller.dart'; +import 'package:flutter/widgets.dart'; + +/// Themed container. Replaces Material's Card / Scaffold body surfaces +/// for clide widgets — pulls color, border, and padding from the +/// current theme's surface tokens. +class ClideSurface extends StatelessWidget { + const ClideSurface({ + super.key, + this.child, + this.color, + this.border, + this.padding = EdgeInsets.zero, + this.width, + this.height, + this.borderRadius, + }); + + final Widget? child; + final Color? color; + final Color? border; + final EdgeInsetsGeometry padding; + final double? width; + final double? height; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Container( + width: width, + height: height, + padding: padding, + decoration: BoxDecoration( + color: color ?? tokens.panelBackground, + border: border == null ? null : Border.all(color: border!, width: 1), + borderRadius: borderRadius, + ), + child: child, + ); + } +} diff --git a/app/lib/widgets/src/clide_tab_bar.dart b/app/lib/widgets/src/clide_tab_bar.dart new file mode 100644 index 00000000..d28b2fbb --- /dev/null +++ b/app/lib/widgets/src/clide_tab_bar.dart @@ -0,0 +1,126 @@ +import 'package:clide_app/kernel/src/theme/controller.dart'; +import 'package:clide_app/widgets/src/clide_icon.dart'; +import 'package:clide_app/widgets/src/clide_text.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +class ClideTabItem { + const ClideTabItem({ + required this.id, + required this.title, + this.icon, + }); + + final String id; + final String title; + final ClideIconPainter? icon; +} + +class ClideTabBar extends StatelessWidget { + const ClideTabBar({ + super.key, + required this.items, + required this.activeId, + required this.onSelect, + this.height = 28, + this.semanticContainerLabel, + }); + + final List items; + final String? activeId; + final ValueChanged onSelect; + final double height; + + /// Optional container-level label for screen readers (e.g. "Sidebar tabs"). + final String? semanticContainerLabel; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Semantics( + container: true, + label: semanticContainerLabel, + explicitChildNodes: true, + child: Container( + height: height, + color: tokens.tabBarBackground, + child: Row( + children: [ + for (final item in items) + _Tab( + item: item, + active: item.id == activeId, + onTap: () => onSelect(item.id), + ), + ], + ), + ), + ); + } +} + +class _Tab extends StatefulWidget { + const _Tab({required this.item, required this.active, required this.onTap}); + + final ClideTabItem item; + final bool active; + final VoidCallback onTap; + + @override + State<_Tab> createState() => _TabState(); +} + +class _TabState extends State<_Tab> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final bg = widget.active + ? tokens.tabActive + : (_hovered ? tokens.tabInactive : tokens.tabInactive); + final fg = widget.active + ? tokens.tabActiveForeground + : tokens.tabInactiveForeground; + + return Semantics( + button: true, + selected: widget.active, + label: widget.item.title, + onTap: widget.onTap, + excludeSemantics: true, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: bg, + border: Border( + bottom: BorderSide( + color: widget.active + ? tokens.tabActiveBorder + : const Color(0x00000000), + width: 2, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.item.icon != null) ...[ + ClideIcon(widget.item.icon!, size: 12, color: fg), + const SizedBox(width: 6), + ], + ClideText(widget.item.title, color: fg), + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/src/clide_text.dart b/app/lib/widgets/src/clide_text.dart new file mode 100644 index 00000000..b7debfd3 --- /dev/null +++ b/app/lib/widgets/src/clide_text.dart @@ -0,0 +1,45 @@ +import 'package:clide_app/kernel/src/theme/controller.dart'; +import 'package:flutter/widgets.dart'; + +/// Theme-aware Text. Defaults pull from the global foreground token. +class ClideText extends StatelessWidget { + const ClideText( + this.data, { + super.key, + this.color, + this.fontSize = 13, + this.fontWeight = FontWeight.w400, + this.muted = false, + this.maxLines, + this.overflow, + this.textAlign, + }); + + final String data; + final Color? color; + final double fontSize; + final FontWeight fontWeight; + final bool muted; + final int? maxLines; + final TextOverflow? overflow; + final TextAlign? textAlign; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final resolved = + color ?? (muted ? tokens.globalTextMuted : tokens.globalForeground); + return Text( + data, + maxLines: maxLines, + overflow: overflow, + textAlign: textAlign, + style: TextStyle( + color: resolved, + fontSize: fontSize, + fontWeight: fontWeight, + fontFamilyFallback: const ['Inter', 'Helvetica', 'Arial', 'sans-serif'], + ), + ); + } +} diff --git a/app/lib/widgets/src/clide_tooltip.dart b/app/lib/widgets/src/clide_tooltip.dart new file mode 100644 index 00000000..f78b0048 --- /dev/null +++ b/app/lib/widgets/src/clide_tooltip.dart @@ -0,0 +1,79 @@ +import 'package:clide_app/kernel/src/theme/controller.dart'; +import 'package:clide_app/widgets/src/clide_text.dart'; +import 'package:flutter/widgets.dart'; + +/// Very small tooltip — hover a child to reveal a label. Uses an +/// OverlayEntry to draw above everything else without Material. +class ClideTooltip extends StatefulWidget { + const ClideTooltip({ + super.key, + required this.message, + required this.child, + this.showDelay = const Duration(milliseconds: 500), + }); + + final String message; + final Widget child; + final Duration showDelay; + + @override + State createState() => _ClideTooltipState(); +} + +class _ClideTooltipState extends State { + OverlayEntry? _entry; + + void _show() { + final overlay = Overlay.maybeOf(context); + if (overlay == null) return; + _entry?.remove(); + final box = context.findRenderObject() as RenderBox?; + if (box == null) return; + final offset = box.localToGlobal(Offset(0, box.size.height + 4)); + _entry = OverlayEntry( + builder: (ctx) { + final tokens = ClideTheme.of(ctx).surface; + return Positioned( + left: offset.dx, + top: offset.dy, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: tokens.tooltipBackground, + border: Border.all(color: tokens.tooltipBorder), + borderRadius: BorderRadius.circular(3), + ), + child: ClideText(widget.message, color: tokens.tooltipForeground), + ), + ); + }, + ); + overlay.insert(_entry!); + } + + void _hide() { + _entry?.remove(); + _entry = null; + } + + @override + void dispose() { + _hide(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Semantics( + tooltip: widget.message, + child: MouseRegion( + onEnter: (_) async { + await Future.delayed(widget.showDelay); + if (mounted) _show(); + }, + onExit: (_) => _hide(), + child: widget.child, + ), + ); + } +} diff --git a/app/lib/widgets/src/icons/check.dart b/app/lib/widgets/src/icons/check.dart new file mode 100644 index 00000000..df12b39f --- /dev/null +++ b/app/lib/widgets/src/icons/check.dart @@ -0,0 +1,21 @@ +import 'package:clide_app/widgets/src/clide_icon.dart'; +import 'package:flutter/widgets.dart'; + +class CheckIcon extends ClideIconPainter { + const CheckIcon(); + + @override + void paint(Canvas canvas, Color color) { + final p = Paint() + ..color = color + ..strokeWidth = 0.14 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final path = Path() + ..moveTo(0.18, 0.52) + ..lineTo(0.42, 0.74) + ..lineTo(0.82, 0.30); + canvas.drawPath(path, p); + } +} diff --git a/app/lib/widgets/src/icons/chevron.dart b/app/lib/widgets/src/icons/chevron.dart new file mode 100644 index 00000000..315448b1 --- /dev/null +++ b/app/lib/widgets/src/icons/chevron.dart @@ -0,0 +1,40 @@ +import 'package:clide_app/widgets/src/clide_icon.dart'; +import 'package:flutter/widgets.dart'; + +class ChevronRightIcon extends ClideIconPainter { + const ChevronRightIcon(); + + @override + void paint(Canvas canvas, Color color) { + final p = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 0.12 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final path = Path() + ..moveTo(0.36, 0.22) + ..lineTo(0.66, 0.50) + ..lineTo(0.36, 0.78); + canvas.drawPath(path, p); + } +} + +class ChevronDownIcon extends ClideIconPainter { + const ChevronDownIcon(); + + @override + void paint(Canvas canvas, Color color) { + final p = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 0.12 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final path = Path() + ..moveTo(0.22, 0.36) + ..lineTo(0.50, 0.66) + ..lineTo(0.78, 0.36); + canvas.drawPath(path, p); + } +} diff --git a/app/lib/widgets/src/icons/dot.dart b/app/lib/widgets/src/icons/dot.dart new file mode 100644 index 00000000..f8c75c67 --- /dev/null +++ b/app/lib/widgets/src/icons/dot.dart @@ -0,0 +1,11 @@ +import 'package:clide_app/widgets/src/clide_icon.dart'; +import 'package:flutter/widgets.dart'; + +class DotIcon extends ClideIconPainter { + const DotIcon(); + + @override + void paint(Canvas canvas, Color color) { + canvas.drawCircle(const Offset(0.5, 0.5), 0.18, Paint()..color = color); + } +} diff --git a/app/lib/widgets/src/icons/folder.dart b/app/lib/widgets/src/icons/folder.dart new file mode 100644 index 00000000..7c092246 --- /dev/null +++ b/app/lib/widgets/src/icons/folder.dart @@ -0,0 +1,23 @@ +import 'package:clide_app/widgets/src/clide_icon.dart'; +import 'package:flutter/widgets.dart'; + +class FolderIcon extends ClideIconPainter { + const FolderIcon(); + + @override + void paint(Canvas canvas, Color color) { + final p = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 0.08; + final path = Path() + ..moveTo(0.08, 0.30) + ..lineTo(0.40, 0.30) + ..lineTo(0.48, 0.22) + ..lineTo(0.92, 0.22) + ..lineTo(0.92, 0.80) + ..lineTo(0.08, 0.80) + ..close(); + canvas.drawPath(path, p); + } +} diff --git a/app/lib/widgets/src/icons/gear.dart b/app/lib/widgets/src/icons/gear.dart new file mode 100644 index 00000000..a4aaa7fb --- /dev/null +++ b/app/lib/widgets/src/icons/gear.dart @@ -0,0 +1,38 @@ +import 'dart:math' as math; + +import 'package:clide_app/widgets/src/clide_icon.dart'; +import 'package:flutter/widgets.dart'; + +class GearIcon extends ClideIconPainter { + const GearIcon(); + + @override + void paint(Canvas canvas, Color color) { + final p = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 0.08 + ..strokeCap = StrokeCap.round; + const center = Offset(0.5, 0.5); + const teeth = 8; + const innerR = 0.25; + const outerR = 0.40; + + final path = Path(); + for (var i = 0; i < teeth; i++) { + final a1 = (i / teeth) * 2 * math.pi; + final a2 = ((i + 0.5) / teeth) * 2 * math.pi; + final p1 = center + Offset(math.cos(a1) * innerR, math.sin(a1) * innerR); + final p2 = center + Offset(math.cos(a1) * outerR, math.sin(a1) * outerR); + final p3 = center + Offset(math.cos(a2) * outerR, math.sin(a2) * outerR); + final p4 = center + Offset(math.cos(a2) * innerR, math.sin(a2) * innerR); + path + ..moveTo(p1.dx, p1.dy) + ..lineTo(p2.dx, p2.dy) + ..lineTo(p3.dx, p3.dy) + ..lineTo(p4.dx, p4.dy); + } + canvas.drawPath(path, p); + canvas.drawCircle(center, 0.12, p); + } +} diff --git a/app/lib/widgets/src/icons/plug.dart b/app/lib/widgets/src/icons/plug.dart new file mode 100644 index 00000000..7f302830 --- /dev/null +++ b/app/lib/widgets/src/icons/plug.dart @@ -0,0 +1,31 @@ +import 'package:clide_app/widgets/src/clide_icon.dart'; +import 'package:flutter/widgets.dart'; + +/// Plug-shaped connection icon. Used by the ipc-status statusbar item +/// and by future connection-state affordances. +class PlugIcon extends ClideIconPainter { + const PlugIcon(); + + @override + void paint(Canvas canvas, Color color) { + final p = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 0.10 + ..strokeCap = StrokeCap.round; + // body + final body = Path() + ..moveTo(0.30, 0.30) + ..lineTo(0.60, 0.30) + ..lineTo(0.60, 0.55) + ..arcToPoint(const Offset(0.30, 0.55), + radius: const Radius.circular(0.15), clockwise: false) + ..close(); + canvas.drawPath(body, p); + // cord + canvas.drawLine(const Offset(0.45, 0.60), const Offset(0.45, 0.88), p); + // prongs + canvas.drawLine(const Offset(0.38, 0.18), const Offset(0.38, 0.30), p); + canvas.drawLine(const Offset(0.52, 0.18), const Offset(0.52, 0.30), p); + } +} diff --git a/app/lib/widgets/src/icons/x.dart b/app/lib/widgets/src/icons/x.dart new file mode 100644 index 00000000..aaf66b9b --- /dev/null +++ b/app/lib/widgets/src/icons/x.dart @@ -0,0 +1,17 @@ +import 'package:clide_app/widgets/src/clide_icon.dart'; +import 'package:flutter/widgets.dart'; + +class CloseIcon extends ClideIconPainter { + const CloseIcon(); + + @override + void paint(Canvas canvas, Color color) { + final p = Paint() + ..color = color + ..strokeWidth = 0.10 + ..strokeCap = StrokeCap.round; + canvas + ..drawLine(const Offset(0.22, 0.22), const Offset(0.78, 0.78), p) + ..drawLine(const Offset(0.78, 0.22), const Offset(0.22, 0.78), p); + } +} diff --git a/app/lib/widgets/widgets.dart b/app/lib/widgets/widgets.dart new file mode 100644 index 00000000..6efc53bc --- /dev/null +++ b/app/lib/widgets/widgets.dart @@ -0,0 +1,22 @@ +/// clide widget primitives. +/// +/// Hand-rolled, theme-token-consuming building blocks. No Material or +/// Cupertino. Feature code composes these; it never reaches down into +/// Flutter chrome widgets directly. +library; + +export 'src/clide_button.dart'; +export 'src/clide_divider.dart'; +export 'src/clide_icon.dart'; +export 'src/clide_scrollbar.dart'; +export 'src/clide_surface.dart'; +export 'src/clide_tab_bar.dart'; +export 'src/clide_text.dart'; +export 'src/clide_tooltip.dart'; +export 'src/icons/check.dart'; +export 'src/icons/chevron.dart'; +export 'src/icons/dot.dart'; +export 'src/icons/folder.dart'; +export 'src/icons/gear.dart'; +export 'src/icons/plug.dart'; +export 'src/icons/x.dart'; diff --git a/app/linux/.gitignore b/app/linux/.gitignore new file mode 100644 index 00000000..d3896c98 --- /dev/null +++ b/app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/app/linux/CMakeLists.txt b/app/linux/CMakeLists.txt new file mode 100644 index 00000000..7ed744e9 --- /dev/null +++ b/app/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "clide_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "net.schweitz.clide.clide_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/app/linux/flutter/CMakeLists.txt b/app/linux/flutter/CMakeLists.txt new file mode 100644 index 00000000..d5bd0164 --- /dev/null +++ b/app/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..e71a16d2 --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/app/linux/flutter/generated_plugin_registrant.h b/app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..e0f0a47b --- /dev/null +++ b/app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake new file mode 100644 index 00000000..2e1de87a --- /dev/null +++ b/app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/app/linux/runner/CMakeLists.txt b/app/linux/runner/CMakeLists.txt new file mode 100644 index 00000000..e97dabc7 --- /dev/null +++ b/app/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/app/linux/runner/main.cc b/app/linux/runner/main.cc new file mode 100644 index 00000000..e7c5c543 --- /dev/null +++ b/app/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc new file mode 100644 index 00000000..5152745a --- /dev/null +++ b/app/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "clide_app"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "clide_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/app/linux/runner/my_application.h b/app/linux/runner/my_application.h new file mode 100644 index 00000000..db16367a --- /dev/null +++ b/app/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/app/macos/.gitignore b/app/macos/.gitignore new file mode 100644 index 00000000..746adbb6 --- /dev/null +++ b/app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/app/macos/Flutter/Flutter-Debug.xcconfig b/app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 00000000..c2efd0b6 --- /dev/null +++ b/app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/Flutter-Release.xcconfig b/app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 00000000..c2efd0b6 --- /dev/null +++ b/app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 00000000..cccf817a --- /dev/null +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..6a05f18d --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* clide_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "clide_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* clide_app.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* clide_app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.schweitz.clide.clideApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/clide_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/clide_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.schweitz.clide.clideApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/clide_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/clide_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.schweitz.clide.clideApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/clide_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/clide_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..e110c69d --- /dev/null +++ b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner.xcworkspace/contents.xcworkspacedata b/app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift new file mode 100644 index 00000000..b3c17614 --- /dev/null +++ b/app/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..a2ec33f1 --- /dev/null +++ b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 00000000..82b6f9d9 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 00000000..13b35eba Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 00000000..0a3f5fa4 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 00000000..bdb57226 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 00000000..f083318e Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 00000000..326c0e72 Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 00000000..2f1632cf Binary files /dev/null and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/app/macos/Runner/Base.lproj/MainMenu.xib b/app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 00000000..80e867a4 --- /dev/null +++ b/app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/macos/Runner/Configs/AppInfo.xcconfig b/app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 00000000..ba8c1b59 --- /dev/null +++ b/app/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = clide_app + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = net.schweitz.clide.clideApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 net.schweitz.clide. All rights reserved. diff --git a/app/macos/Runner/Configs/Debug.xcconfig b/app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 00000000..36b0fd94 --- /dev/null +++ b/app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Release.xcconfig b/app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 00000000..dff4f495 --- /dev/null +++ b/app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Warnings.xcconfig b/app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 00000000..42bcbf47 --- /dev/null +++ b/app/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/app/macos/Runner/DebugProfile.entitlements b/app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 00000000..dddb8a30 --- /dev/null +++ b/app/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist new file mode 100644 index 00000000..4789daa6 --- /dev/null +++ b/app/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 00000000..3cc05eb2 --- /dev/null +++ b/app/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/app/macos/Runner/Release.entitlements b/app/macos/Runner/Release.entitlements new file mode 100644 index 00000000..852fa1a4 --- /dev/null +++ b/app/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/app/macos/RunnerTests/RunnerTests.swift b/app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/app/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/app/pubspec.lock b/app/pubspec.lock new file mode 100644 index 00000000..8ccb9af9 --- /dev/null +++ b/app/pubspec.lock @@ -0,0 +1,299 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + alchemist: + dependency: "direct dev" + description: + name: alchemist + sha256: "4c5f5146b7539eb9a53211d81489f09f08fc3821f754a370f493477a9ef9e390" + url: "https://pub.dev" + source: hosted + version: "0.12.1" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clide: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "2.0.0-dev" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + url: "https://pub.dev" + source: hosted + version: "15.1.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + yaml: + dependency: "direct main" + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0-0 <4.0.0" + flutter: ">=3.32.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml new file mode 100644 index 00000000..9e636742 --- /dev/null +++ b/app/pubspec.yaml @@ -0,0 +1,55 @@ +# clide_app — Flutter desktop UI. +# +# Renders clide's IDE surfaces; depends on the root `clide` Dart core +# package (shared with the CLI + daemon). Prefer-zero-deps; every +# third-party entry below is exact-pinned and justified. No carets. + +name: clide_app +description: >- + Flutter desktop IDE for Claude Code — renders the UI. Talks to the + Dart core (CLI + daemon) via IPC; all PTY/subprocess/filesystem work + lives in the daemon (ADR 0005). +publish_to: none +version: 2.0.0-dev +repository: https://git.schweitz.net/jpmschweitzer/clide + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.19.0" + +dependencies: + flutter: + sdk: flutter + + # Dart core — shared types, IPC protocol, anything CLI + daemon + app + # all import. Kept Flutter-free so `dart compile exe` produces a lean + # static binary (see ADR 0005). + clide: + path: ../ + + # YAML — the one justified exception to prefer-zero-deps. Dart-team + # maintained, used for theme files and extension manifests. + yaml: 3.1.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: 6.0.0 + + # Integration tests for the real-app startup-regression gate. + integration_test: + sdk: flutter + + # Mocks at IO/IPC boundaries; hand-rolled fakes for ChangeNotifiers. + mocktail: 1.0.4 + + # Golden-test framework (Ahem font, cross-platform pixel stability). + # Used only for widget primitives, not compositions. + alchemist: 0.12.1 + +flutter: + uses-material-design: false + + assets: + - lib/kernel/src/theme/themes/ + - lib/kernel/src/i18n/catalog/ diff --git a/app/web/favicon.png b/app/web/favicon.png new file mode 100644 index 00000000..8aaa46ac Binary files /dev/null and b/app/web/favicon.png differ diff --git a/app/web/icons/Icon-192.png b/app/web/icons/Icon-192.png new file mode 100644 index 00000000..b749bfef Binary files /dev/null and b/app/web/icons/Icon-192.png differ diff --git a/app/web/icons/Icon-512.png b/app/web/icons/Icon-512.png new file mode 100644 index 00000000..88cfd48d Binary files /dev/null and b/app/web/icons/Icon-512.png differ diff --git a/app/web/icons/Icon-maskable-192.png b/app/web/icons/Icon-maskable-192.png new file mode 100644 index 00000000..eb9b4d76 Binary files /dev/null and b/app/web/icons/Icon-maskable-192.png differ diff --git a/app/web/icons/Icon-maskable-512.png b/app/web/icons/Icon-maskable-512.png new file mode 100644 index 00000000..d69c5669 Binary files /dev/null and b/app/web/icons/Icon-maskable-512.png differ diff --git a/app/web/index.html b/app/web/index.html new file mode 100644 index 00000000..9f4f6269 --- /dev/null +++ b/app/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + clide_app + + + + + + + diff --git a/app/web/manifest.json b/app/web/manifest.json new file mode 100644 index 00000000..64c04ff3 --- /dev/null +++ b/app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "clide_app", + "short_name": "clide_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}