dissolve app/ into repo root (D-056)
Single Flutter package at the repo root. All code, tests, assets, and platform directories moved from app/ to root. Package renamed from clide_app to clide — all imports rewritten. Merged pubspec combines core (ffi) and app (flutter, yaml, xterm) dependencies. Makefile simplified: no APP_PRESENT conditionals, no cd, no daemon lifecycle. 317 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+528
@@ -0,0 +1,528 @@
|
||||
import 'package:clide/builtin/welcome/src/welcome_view.dart';
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/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(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'clide',
|
||||
color: const Color(0xFF000000),
|
||||
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder<T>(
|
||||
settings: settings,
|
||||
pageBuilder: (ctx, _, __) => builder(ctx),
|
||||
),
|
||||
home: _RootShell(services: services),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RootShell extends StatefulWidget {
|
||||
const _RootShell({required this.services});
|
||||
final KernelServices services;
|
||||
|
||||
@override
|
||||
State<_RootShell> createState() => _RootShellState();
|
||||
}
|
||||
|
||||
class _RootShellState extends State<_RootShell> {
|
||||
late final FocusNode _keyFocus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keyFocus = FocusNode()..requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: 15,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontFamily: clideUiFamily,
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
),
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 sidebarCollapsed = a.isCollapsed(Slots.sidebar);
|
||||
final contextVisible = a.isVisible(Slots.contextPanel);
|
||||
final contextCollapsed = a.isCollapsed(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 && sidebarCollapsed)
|
||||
ClideSpine(
|
||||
label: _sidebarSpineLabel(kernel),
|
||||
side: SpineSide.left,
|
||||
onExpand: () => a.setCollapsed(Slots.sidebar, false),
|
||||
)
|
||||
else 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 && contextCollapsed)
|
||||
ClideSpine(
|
||||
label: 'context',
|
||||
side: SpineSide.right,
|
||||
onExpand: () => a.setCollapsed(Slots.contextPanel, false),
|
||||
)
|
||||
else if (contextVisible) ...[
|
||||
DragResizeHandle(
|
||||
arrangement: a,
|
||||
slot: Slots.contextPanel,
|
||||
axis: Axis.horizontal,
|
||||
),
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: SlotHost(slot: Slots.contextPanel),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (statusVisible)
|
||||
Container(
|
||||
height: statusHeight,
|
||||
decoration: BoxDecoration(border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.dividerColor))),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (sidebarVisible && !sidebarCollapsed)
|
||||
SizedBox(width: sidebarSize, child: _BottomRail(slot: Slots.sidebar))
|
||||
else if (sidebarVisible && sidebarCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
Expanded(child: const StatusbarHost()),
|
||||
if (contextVisible && !contextCollapsed)
|
||||
SizedBox(width: contextSize, child: _BottomRail(slot: Slots.contextPanel))
|
||||
else if (contextVisible && contextCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String _sidebarSpineLabel(KernelServices kernel) {
|
||||
final activeTab = kernel.panels.activeTabIn(Slots.sidebar);
|
||||
if (activeTab == null) return 'overview';
|
||||
final tabs = kernel.panels.tabsFor(Slots.sidebar);
|
||||
for (final t in tabs) {
|
||||
if (t.id == activeTab) return t.title.toLowerCase();
|
||||
}
|
||||
return 'overview';
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
if (slot == Slots.sidebar) {
|
||||
return _SidebarSlot(
|
||||
tabs: tabs,
|
||||
active: active,
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
);
|
||||
}
|
||||
|
||||
if (slot == Slots.contextPanel) {
|
||||
return _ContextSlot(
|
||||
tabs: tabs,
|
||||
active: active,
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
);
|
||||
}
|
||||
|
||||
if (slot == Slots.workspace) {
|
||||
return _WorkspaceSlot(tabs: tabs, active: active);
|
||||
}
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static 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 _SidebarSlot extends StatelessWidget {
|
||||
const _SidebarSlot({
|
||||
required this.tabs,
|
||||
required this.active,
|
||||
required this.activeId,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
color: tokens.sidebarBackground,
|
||||
alignment: Alignment.topLeft,
|
||||
child: active.build(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkspaceSlot extends StatelessWidget {
|
||||
const _WorkspaceSlot({required this.tabs, required this.active});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
|
||||
static const _editorTabId = 'editor.active';
|
||||
static const _claudeTabId = 'claude.primary';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.arrangement,
|
||||
builder: (ctx, _) {
|
||||
final editorOpen = kernel.arrangement.editorOpen;
|
||||
final editorTab = tabs.where((t) => t.id == _editorTabId).firstOrNull;
|
||||
|
||||
final claude = tabs.where((t) => t.id == _claudeTabId).firstOrNull;
|
||||
final primary = claude ?? active;
|
||||
|
||||
if (!editorOpen || editorTab == null) {
|
||||
return Container(color: tokens.panelBackground, child: primary.build(ctx));
|
||||
}
|
||||
|
||||
final ratio = kernel.arrangement.editorRatio;
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final totalHeight = constraints.maxHeight;
|
||||
final editorHeight = (totalHeight * ratio).clamp(60.0, totalHeight - 60.0);
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(height: editorHeight, child: editorTab.build(ctx)),
|
||||
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
|
||||
Expanded(child: primary.build(ctx)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorDragHandle extends StatefulWidget {
|
||||
const _EditorDragHandle({required this.arrangement, required this.totalHeight});
|
||||
|
||||
final LayoutArrangement arrangement;
|
||||
final double totalHeight;
|
||||
|
||||
@override
|
||||
State<_EditorDragHandle> createState() => _EditorDragHandleState();
|
||||
}
|
||||
|
||||
class _EditorDragHandleState extends State<_EditorDragHandle> {
|
||||
bool _hovered = false;
|
||||
double? _dragStartRatio;
|
||||
double? _dragStartY;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
onPointerDown: (e) {
|
||||
_dragStartRatio = widget.arrangement.editorRatio;
|
||||
_dragStartY = e.position.dy;
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
final startR = _dragStartRatio;
|
||||
final startY = _dragStartY;
|
||||
if (startR == null || startY == null || widget.totalHeight <= 0) return;
|
||||
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
|
||||
widget.arrangement.setEditorRatio(startR + deltaRatio);
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
_dragStartRatio = null;
|
||||
_dragStartY = null;
|
||||
},
|
||||
child: Container(height: 4, color: _hovered ? tokens.panelActiveBorder : tokens.panelBorder),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ContextSlot extends StatelessWidget {
|
||||
const _ContextSlot({
|
||||
required this.tabs,
|
||||
required this.active,
|
||||
required this.activeId,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
alignment: Alignment.topLeft,
|
||||
child: active.build(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomRail extends StatelessWidget {
|
||||
const _BottomRail({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: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
final tabs = kernel.panels.tabsFor(slot);
|
||||
if (tabs.isEmpty) return Container(color: tokens.statusBarBackground);
|
||||
final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id;
|
||||
return Container(
|
||||
color: tokens.statusBarBackground,
|
||||
child: ClideIconRail(
|
||||
items: [
|
||||
for (final t in tabs)
|
||||
ClideIconRailItem(
|
||||
id: t.id,
|
||||
icon: _iconFor(slot, t),
|
||||
tooltip: SlotHost._resolveTitle(ctx, t),
|
||||
),
|
||||
],
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static ClideIconPainter _iconFor(SlotId slot, TabContribution t) {
|
||||
if (t.icon is ClideIconPainter) return t.icon as ClideIconPainter;
|
||||
if (slot == Slots.sidebar) {
|
||||
return switch (t.id) {
|
||||
'files.tree' => PhosphorIcons.folder,
|
||||
'git.panel' => PhosphorIcons.gitBranch,
|
||||
'pql.panel' => PhosphorIcons.magnifyingGlass,
|
||||
'problems.panel' => PhosphorIcons.warningCircle,
|
||||
'decisions.panel' => PhosphorIcons.lightbulb,
|
||||
'tickets.panel' => PhosphorIcons.ticket,
|
||||
_ => PhosphorIcons.circlesFour,
|
||||
};
|
||||
}
|
||||
return switch (t.id) {
|
||||
'markdown.viewer' => PhosphorIcons.eye,
|
||||
'graph.view' => PhosphorIcons.graph,
|
||||
'pql.backlinks' => PhosphorIcons.link,
|
||||
_ => PhosphorIcons.circlesFour,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class StatusbarHost extends StatelessWidget {
|
||||
const StatusbarHost({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
final items = kernel.panels.contributionsFor(Slots.statusbar).whereType<StatusItemContribution>().toList();
|
||||
final left = items.where((i) => i.priority < 100).toList();
|
||||
final right = items.where((i) => i.priority >= 100).toList();
|
||||
return Container(
|
||||
color: tokens.statusBarBackground,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
for (final item in left) item.build(ctx),
|
||||
const Spacer(),
|
||||
for (final item in right) item.build(ctx),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeOverlay extends StatelessWidget {
|
||||
const _WelcomeOverlay();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.project,
|
||||
builder: (ctx, _) {
|
||||
if (kernel.project.isOpen) return const SizedBox.shrink();
|
||||
final tokens = ClideTheme.of(ctx).surface;
|
||||
return ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: const WelcomeView(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class CanvasExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.canvas';
|
||||
@override
|
||||
String get title => 'Canvas';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'session_naming.dart';
|
||||
|
||||
/// Claude pane. Opinionated per D-041:
|
||||
///
|
||||
/// - [isPrimary]=true: the session name is stable per repo
|
||||
/// (`clide-claude-<hash>`) so reopening the app re-attaches to a
|
||||
/// running `claude` under tmux. No close button rendered —
|
||||
/// close-gestures (tab × on the header) minimise, not kill.
|
||||
/// - [isPrimary]=false: session name includes a `-N` suffix for
|
||||
/// this clide run. Closes normally; `pane.close` kills the tmux
|
||||
/// session.
|
||||
///
|
||||
/// Requires `tmux` on the daemon's PATH. If it isn't there, the pane
|
||||
/// falls back to spawning `claude` directly and loses persistence —
|
||||
/// an explicit state message lands in the header subtitle.
|
||||
class ClaudePane extends StatefulWidget {
|
||||
const ClaudePane({
|
||||
super.key,
|
||||
this.isPrimary = true,
|
||||
this.secondaryIndex,
|
||||
this.showChrome = true,
|
||||
}) : assert(isPrimary || secondaryIndex != null,
|
||||
'secondary panes need an index');
|
||||
|
||||
final bool isPrimary;
|
||||
final bool showChrome;
|
||||
|
||||
/// 1-based secondary-session index. Ignored when [isPrimary].
|
||||
final int? secondaryIndex;
|
||||
|
||||
@override
|
||||
State<ClaudePane> createState() => _ClaudePaneState();
|
||||
}
|
||||
|
||||
class _ClaudePaneState extends State<ClaudePane> {
|
||||
static const _maxLines = 5000;
|
||||
|
||||
late final Terminal _terminal;
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
String? _paneId;
|
||||
String? _error;
|
||||
String _statusLine = 'attaching…';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_terminal = Terminal(maxLines: _maxLines);
|
||||
_terminal.onOutput = _onOutput;
|
||||
_terminal.onResize = _onResize;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _spawn());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
final id = _paneId;
|
||||
_paneId = null;
|
||||
if (id != null && !widget.isPrimary) {
|
||||
// Secondary: killing the pane kills the tmux session too —
|
||||
// that's the D-041 policy ("closing a secondary pops back to
|
||||
// primary"). The daemon's pane.close is idempotent.
|
||||
unawaited(_ipc()?.request('pane.close', args: {'id': id}));
|
||||
}
|
||||
// Primary: don't close on dispose. The next time this pane is
|
||||
// rebuilt (next app launch, or tab reopen), tmux new-session -A
|
||||
// re-attaches to the same running claude.
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _spawn() async {
|
||||
if (!mounted) return;
|
||||
final ipc = _ipc();
|
||||
if (ipc == null || !ipc.isConnected) {
|
||||
setState(() => _error = 'Daemon not connected. Start `clide --daemon`.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve repo root via files.root. If that fails (no daemon, no
|
||||
// git root), fall back to cwd — the session name will just be
|
||||
// based on wherever the daemon is running.
|
||||
String repoRoot = Directory.current.path;
|
||||
final rootResp = await ipc.request('files.root');
|
||||
if (rootResp.ok) {
|
||||
repoRoot = (rootResp.data['path'] as String?) ?? repoRoot;
|
||||
}
|
||||
|
||||
final sessionName = widget.isPrimary
|
||||
? primarySessionName(repoRoot)
|
||||
: secondarySessionName(repoRoot, widget.secondaryIndex!);
|
||||
|
||||
// Try tmux-wrapped first (persistence). Fall back to direct claude
|
||||
// if tmux spawn errors.
|
||||
var argv = <String>[
|
||||
'tmux',
|
||||
'new-session',
|
||||
'-A',
|
||||
'-s',
|
||||
sessionName,
|
||||
'--',
|
||||
'claude',
|
||||
];
|
||||
var resp = await ipc.request('pane.spawn', args: {
|
||||
'argv': argv,
|
||||
'kind': PaneKind.claude.wire,
|
||||
'cwd': repoRoot,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
'title': sessionName,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
// tmux probably missing — try bare claude so the pane still
|
||||
// works, at the cost of persistence.
|
||||
argv = ['claude'];
|
||||
resp = await ipc.request('pane.spawn', args: {
|
||||
'argv': argv,
|
||||
'kind': PaneKind.claude.wire,
|
||||
'cwd': repoRoot,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
'title': sessionName,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
_error = resp.error?.message ?? 'spawn failed';
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() => _statusLine = 'no-tmux · fresh every launch');
|
||||
} else {
|
||||
setState(() => _statusLine = 'tmux · $sessionName');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
_paneId = resp.data['id'] as String?;
|
||||
// PID available in resp.data['pid'] if needed for debugging.
|
||||
_subscribe();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
final kernel = _kernel();
|
||||
if (kernel == null) return;
|
||||
_eventSub = kernel.events.on<DaemonEvent>().listen((e) {
|
||||
if (e.subsystem != 'pane' || e.data['id'] != _paneId) return;
|
||||
switch (e.kind) {
|
||||
case 'pane.output':
|
||||
final b64 = e.data['bytes_b64'];
|
||||
if (b64 is String) {
|
||||
_terminal.write(utf8.decode(base64Decode(b64), allowMalformed: true));
|
||||
}
|
||||
case 'pane.exit':
|
||||
if (widget.isPrimary) {
|
||||
// Primary exiting is unusual — tmux sessions survive
|
||||
// normal disconnects. Surface it but don't auto-respawn;
|
||||
// the user decides.
|
||||
setState(() => _statusLine = 'session exited — restart clide to retry');
|
||||
} else {
|
||||
setState(() => _statusLine = 'session exited');
|
||||
}
|
||||
case 'pane.closed':
|
||||
_paneId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onOutput(String text) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_ipc()?.request('pane.write', args: {'id': id, 'text': text});
|
||||
}
|
||||
|
||||
void _onResize(int cols, int rows, int _, int __) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
}
|
||||
|
||||
DaemonClient? _ipc() => _kernel()?.ipc;
|
||||
|
||||
KernelServices? _kernel() {
|
||||
try {
|
||||
return ClideKernel.of(context);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.isPrimary
|
||||
? 'claude — primary'
|
||||
: 'claude — secondary ${widget.secondaryIndex}';
|
||||
final body = _error != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ClideText(_error!, muted: true),
|
||||
)
|
||||
: ClidePtyView(terminal: _terminal, label: title);
|
||||
|
||||
if (!widget.showChrome) return body;
|
||||
|
||||
return ClidePaneChrome(
|
||||
title: title,
|
||||
subtitle: _error ?? _statusLine,
|
||||
onClose: widget.isPrimary
|
||||
? null
|
||||
: () {
|
||||
final id = _paneId;
|
||||
if (id != null) {
|
||||
unawaited(_ipc()?.request('pane.close', args: {'id': id}));
|
||||
}
|
||||
},
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'claude_pane.dart';
|
||||
|
||||
class ClaudeSessionHost extends StatefulWidget {
|
||||
const ClaudeSessionHost({super.key});
|
||||
|
||||
@override
|
||||
State<ClaudeSessionHost> createState() => ClaudeSessionHostState();
|
||||
}
|
||||
|
||||
class ClaudeSessionHostState extends State<ClaudeSessionHost> {
|
||||
final List<_Session> _sessions = [];
|
||||
int _activeIndex = 0;
|
||||
int _nextSecondary = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sessions.add(_Session(isPrimary: true, label: 'primary'));
|
||||
}
|
||||
|
||||
void addSecondary() {
|
||||
final index = _nextSecondary++;
|
||||
setState(() {
|
||||
_sessions.add(_Session(isPrimary: false, secondaryIndex: index, label: 'session $index'));
|
||||
_activeIndex = _sessions.length - 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _close(int index) {
|
||||
if (index < 0 || index >= _sessions.length) return;
|
||||
if (_sessions[index].isPrimary) return;
|
||||
setState(() {
|
||||
_sessions.removeAt(index);
|
||||
if (_activeIndex >= _sessions.length) _activeIndex = _sessions.length - 1;
|
||||
if (_activeIndex < 0) _activeIndex = 0;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final showTabs = _sessions.length > 1;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (showTabs)
|
||||
_TabRow(
|
||||
sessions: _sessions,
|
||||
activeIndex: _activeIndex,
|
||||
tokens: tokens,
|
||||
onSelect: (i) => setState(() => _activeIndex = i),
|
||||
onClose: _close,
|
||||
onAdd: addSecondary,
|
||||
),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: _activeIndex,
|
||||
children: [
|
||||
for (final s in _sessions)
|
||||
ClaudePane(
|
||||
key: s.key,
|
||||
isPrimary: s.isPrimary,
|
||||
secondaryIndex: s.secondaryIndex,
|
||||
showChrome: !showTabs,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Session {
|
||||
_Session({required this.isPrimary, this.secondaryIndex, required this.label}) : key = GlobalKey();
|
||||
final bool isPrimary;
|
||||
final int? secondaryIndex;
|
||||
final String label;
|
||||
final GlobalKey key;
|
||||
}
|
||||
|
||||
class _TabRow extends StatelessWidget {
|
||||
const _TabRow({
|
||||
required this.sessions,
|
||||
required this.activeIndex,
|
||||
required this.tokens,
|
||||
required this.onSelect,
|
||||
required this.onClose,
|
||||
required this.onAdd,
|
||||
});
|
||||
|
||||
final List<_Session> sessions;
|
||||
final int activeIndex;
|
||||
final SurfaceTokens tokens;
|
||||
final ValueChanged<int> onSelect;
|
||||
final ValueChanged<int> onClose;
|
||||
final VoidCallback onAdd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onDoubleTap: onAdd,
|
||||
child: Container(
|
||||
height: 28,
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: tokens.dividerColor))),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < sessions.length; i++) _Tab(session: sessions[i], active: i == activeIndex, tokens: tokens, onTap: () => onSelect(i), onClose: sessions[i].isPrimary ? null : () => onClose(i)),
|
||||
const SizedBox(width: 4),
|
||||
_AddButton(tokens: tokens, onTap: onAdd),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab extends StatefulWidget {
|
||||
const _Tab({required this.session, required this.active, required this.tokens, required this.onTap, this.onClose});
|
||||
final _Session session;
|
||||
final bool active;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
@override
|
||||
State<_Tab> createState() => _TabState();
|
||||
}
|
||||
|
||||
class _TabState extends State<_Tab> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered && !widget.active ? widget.tokens.tabInactive : null,
|
||||
border: Border(bottom: BorderSide(color: widget.active ? widget.tokens.tabActiveBorder : const Color(0x00000000), width: 2)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(
|
||||
widget.session.label,
|
||||
fontSize: 12,
|
||||
color: widget.active ? widget.tokens.tabActiveForeground : widget.tokens.tabInactiveForeground,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
if (widget.onClose != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
GestureDetector(
|
||||
onTap: widget.onClose,
|
||||
child: ClideIcon(PhosphorIcons.xMark, size: 10, color: _hovered ? widget.tokens.globalForeground : widget.tokens.globalTextMuted),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddButton extends StatefulWidget {
|
||||
const _AddButton({required this.tokens, required this.onTap});
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_AddButton> createState() => _AddButtonState();
|
||||
}
|
||||
|
||||
class _AddButtonState extends State<_AddButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: ClideText('+', fontSize: 14, color: _hovered ? widget.tokens.globalForeground : widget.tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_session_host.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClaudeExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.claude';
|
||||
@override
|
||||
String get title => 'Claude';
|
||||
@override
|
||||
String get version => '0.2.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
ClideExtensionContext? _ctx;
|
||||
final GlobalKey<ClaudeSessionHostState> _hostKey = GlobalKey();
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'claude.primary',
|
||||
slot: Slots.workspace,
|
||||
title: 'Claude',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: 90,
|
||||
build: (_) => ClaudeSessionHost(key: _hostKey),
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.new-secondary',
|
||||
command: 'claude.new-secondary',
|
||||
title: 'Claude: open a secondary session',
|
||||
run: (_) async {
|
||||
_hostKey.currentState?.addSecondary();
|
||||
return IpcResponse.ok(id: '', data: const {'status': 'spawned'});
|
||||
},
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'claude.kill-all-sessions',
|
||||
command: 'claude.kill-all-sessions',
|
||||
title: 'Claude: kill all tmux sessions for this repo',
|
||||
run: _killAllSessions,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deactivate() async {
|
||||
await _killAllSessions([]);
|
||||
}
|
||||
|
||||
Future<IpcResponse> _killAllSessions(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return IpcResponse.ok(id: '', data: const {});
|
||||
final resp = await ctx.ipc.request('pane.list');
|
||||
if (!resp.ok) return resp;
|
||||
final panes = resp.data['panes'];
|
||||
if (panes is List) {
|
||||
for (final p in panes) {
|
||||
if (p is Map && p['kind'] == 'claude') {
|
||||
final id = p['id'] as String?;
|
||||
if (id != null) {
|
||||
await ctx.ipc.request('pane.close', args: {'id': id});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return IpcResponse.ok(id: '', data: const {'status': 'killed'});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/// Derive deterministic tmux session names for Claude panes (D-041).
|
||||
///
|
||||
/// The primary session name encodes the repo path in a human-readable
|
||||
/// form: `clide-claude-<path-slug>`. For example:
|
||||
/// ~/projects/clide → clide-claude-projects-clide
|
||||
/// /var/mnt/data/myapp → clide-claude-var-mnt-data-myapp
|
||||
///
|
||||
/// Secondary sessions append `-N`.
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
/// Stable session name for the primary Claude pane of [repoRoot].
|
||||
String primarySessionName(String repoRoot) {
|
||||
return 'clide-claude-${_slugify(repoRoot)}';
|
||||
}
|
||||
|
||||
/// Nth secondary session name. [n] starts at 1.
|
||||
String secondarySessionName(String repoRoot, int n) {
|
||||
return '${primarySessionName(repoRoot)}-$n';
|
||||
}
|
||||
|
||||
// tmux session names max out at 256 chars; keep ours well under.
|
||||
const _maxSlugLen = 80;
|
||||
|
||||
String _slugify(String path) {
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
var p = path;
|
||||
if (home.isNotEmpty && p.startsWith(home)) {
|
||||
p = p.substring(home.length);
|
||||
}
|
||||
p = p.replaceAll('/', '-').replaceAll('.', '');
|
||||
while (p.startsWith('-')) {
|
||||
p = p.substring(1);
|
||||
}
|
||||
while (p.endsWith('-')) {
|
||||
p = p.substring(0, p.length - 1);
|
||||
}
|
||||
if (p.isEmpty) p = 'root';
|
||||
if (p.length > _maxSlugLen) return _hash(path);
|
||||
return p;
|
||||
}
|
||||
|
||||
String _hash(String s) {
|
||||
var h = 2166136261;
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
h ^= s.codeUnitAt(i);
|
||||
h = (h * 16777619) & 0xffffffff;
|
||||
}
|
||||
return h.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:clide/extension/extension.dart';
|
||||
|
||||
/// Tier-reserved stub. Will surface a sidebar tab with sub-tabs
|
||||
/// Settings / Skills / Agents / Hooks / MCP — `.claude/` as a
|
||||
/// first-class IDE surface. Orthogonal to pql; purely clide-internal.
|
||||
/// Commands: `claude.settings.open`, `claude.skills.new`,
|
||||
/// `claude.skills.edit`, `claude.agents.new`, `claude.agents.edit`,
|
||||
/// `claude.hooks.log`, `claude.mcp.status`.
|
||||
///
|
||||
/// Distinct from `builtin.claude`, which is reserved for Tier 1's
|
||||
/// "run Claude Code in a PTY pane."
|
||||
class ClaudeControlExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.claude-control';
|
||||
@override
|
||||
String get title => 'Claude control';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class DecisionsView extends StatefulWidget {
|
||||
const DecisionsView({super.key});
|
||||
|
||||
@override
|
||||
State<DecisionsView> createState() => _DecisionsViewState();
|
||||
}
|
||||
|
||||
class _DecisionsViewState extends State<DecisionsView> {
|
||||
List<_DecisionEntry> _decisions = [];
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_loading || _decisions.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('pql.exec', args: {
|
||||
'argv': ['decisions', 'list', '--type', 'confirmed'],
|
||||
});
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
_error = resp.error?.message ?? 'failed to load decisions';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final raw = resp.data['stdout'] as String? ?? '[]';
|
||||
try {
|
||||
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
|
||||
setState(() {
|
||||
_decisions = list.map(_DecisionEntry.fromJson).toList();
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'parse error: $e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (_loading) {
|
||||
return const Center(child: ClideText('Loading decisions...', muted: true));
|
||||
}
|
||||
if (_error != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(_error!, muted: true),
|
||||
);
|
||||
}
|
||||
if (_decisions.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: _decisions.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final d = _decisions[i];
|
||||
return _DecisionRow(entry: d, tokens: tokens);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DecisionEntry {
|
||||
const _DecisionEntry({required this.id, required this.title, this.domain, this.status});
|
||||
final String id;
|
||||
final String title;
|
||||
final String? domain;
|
||||
final String? status;
|
||||
|
||||
factory _DecisionEntry.fromJson(Map<String, dynamic> json) => _DecisionEntry(
|
||||
id: json['id'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
domain: json['domain'] as String?,
|
||||
status: json['status'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class _DecisionRow extends StatefulWidget {
|
||||
const _DecisionRow({required this.entry, required this.tokens});
|
||||
final _DecisionEntry entry;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
State<_DecisionRow> createState() => _DecisionRowState();
|
||||
}
|
||||
|
||||
class _DecisionRowState extends State<_DecisionRow> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Container(
|
||||
color: _hovered ? widget.tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(widget.entry.id, color: widget.tokens.globalTextMuted, fontSize: 12),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: ClideText(widget.entry.title, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:clide/builtin/decisions/src/decisions_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
class DecisionsExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.decisions';
|
||||
@override
|
||||
String get title => 'Decisions';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'decisions.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Decisions',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -20,
|
||||
build: (_) => const DecisionsView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,303 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/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.2.0';
|
||||
|
||||
LayoutPresetContribution? _preset;
|
||||
ClideExtensionContext? _ctx;
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
_preset ?? classicPreset(),
|
||||
CommandContribution(
|
||||
id: 'layout.reset',
|
||||
command: 'layout.reset',
|
||||
title: 'Layout: Reset to Classic',
|
||||
run: _reset,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'palette.toggle',
|
||||
command: 'palette.toggle',
|
||||
title: 'Command Palette',
|
||||
defaultBinding: 'ctrl+shift+p',
|
||||
run: _togglePalette,
|
||||
),
|
||||
// Collapse toggles (D-051, D-054)
|
||||
CommandContribution(
|
||||
id: 'sidebar.collapse',
|
||||
command: 'sidebar.collapse',
|
||||
title: 'Toggle Sidebar Collapse',
|
||||
defaultBinding: 'ctrl+shift+1',
|
||||
run: _collapseSidebar,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'context.collapse',
|
||||
command: 'context.collapse',
|
||||
title: 'Toggle Context Panel Collapse',
|
||||
defaultBinding: 'ctrl+shift+3',
|
||||
run: _collapseContext,
|
||||
),
|
||||
// Panel focus (D-054)
|
||||
CommandContribution(
|
||||
id: 'panel.focus.left',
|
||||
command: 'panel.focus.left',
|
||||
title: 'Focus Left Panel',
|
||||
defaultBinding: 'ctrl+1',
|
||||
run: _focusLeft,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'panel.focus.middle',
|
||||
command: 'panel.focus.middle',
|
||||
title: 'Focus Middle Panel',
|
||||
defaultBinding: 'ctrl+2',
|
||||
run: _focusMiddle,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'panel.focus.right',
|
||||
command: 'panel.focus.right',
|
||||
title: 'Focus Right Panel',
|
||||
defaultBinding: 'ctrl+3',
|
||||
run: _focusRight,
|
||||
),
|
||||
// Focus mode (D-052, D-054)
|
||||
CommandContribution(
|
||||
id: 'panel.focusMode',
|
||||
command: 'panel.focusMode',
|
||||
title: 'Toggle Focus Mode',
|
||||
defaultBinding: 'ctrl+.',
|
||||
run: _toggleFocusMode,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'panel.focusMode.exit',
|
||||
command: 'panel.focusMode.exit',
|
||||
title: 'Exit Focus Mode',
|
||||
defaultBinding: 'escape',
|
||||
run: _exitFocusMode,
|
||||
),
|
||||
// Editor split (D-049, D-054)
|
||||
CommandContribution(
|
||||
id: 'editor.open',
|
||||
command: 'editor.open',
|
||||
title: 'Open Editor',
|
||||
defaultBinding: 'ctrl+e',
|
||||
run: _openEditor,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'editor.close',
|
||||
command: 'editor.close',
|
||||
title: 'Close Editor',
|
||||
defaultBinding: 'ctrl+w',
|
||||
run: _closeEditor,
|
||||
),
|
||||
// Sidebar section switching (D-054): alt+1 through alt+5
|
||||
for (var i = 0; i < 5; i++)
|
||||
CommandContribution(
|
||||
id: 'sidebar.section.${i + 1}',
|
||||
command: 'sidebar.section.${i + 1}',
|
||||
title: 'Sidebar: Section ${i + 1}',
|
||||
defaultBinding: 'alt+${i + 1}',
|
||||
run: (args) => _switchSidebarSection(i),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
_preset = classicPreset();
|
||||
ctx.arrangement.registerSlotsInto(ctx.panels, _preset!);
|
||||
ctx.arrangement.applyPreset(_preset!);
|
||||
_restoreLayout(ctx);
|
||||
ctx.arrangement.addListener(() => _persistLayout(ctx));
|
||||
ctx.panels.addListener(() => _persistActiveTabs(ctx));
|
||||
}
|
||||
|
||||
void _restoreLayout(ClideExtensionContext ctx) {
|
||||
final s = ctx.settings;
|
||||
final sidebarCollapsed = s.get<bool>(_kSidebarCollapsed);
|
||||
if (sidebarCollapsed != null) {
|
||||
ctx.arrangement.setCollapsed(Slots.sidebar, sidebarCollapsed);
|
||||
}
|
||||
final contextCollapsed = s.get<bool>(_kContextCollapsed);
|
||||
if (contextCollapsed != null) {
|
||||
ctx.arrangement.setCollapsed(Slots.contextPanel, contextCollapsed);
|
||||
}
|
||||
final sidebarSize = s.get<double>(_kSidebarSize);
|
||||
if (sidebarSize != null) ctx.arrangement.setSize(Slots.sidebar, sidebarSize);
|
||||
final contextSize = s.get<double>(_kContextSize);
|
||||
if (contextSize != null) ctx.arrangement.setSize(Slots.contextPanel, contextSize);
|
||||
final editorRatio = s.get<double>(_kEditorRatio);
|
||||
if (editorRatio != null) ctx.arrangement.setEditorRatio(editorRatio);
|
||||
final activeLeft = s.get<String>(_kActiveLeft);
|
||||
if (activeLeft != null) ctx.panels.activateTab(Slots.sidebar, activeLeft);
|
||||
final activeRight = s.get<String>(_kActiveRight);
|
||||
if (activeRight != null) ctx.panels.activateTab(Slots.contextPanel, activeRight);
|
||||
}
|
||||
|
||||
void _persistLayout(ClideExtensionContext ctx) {
|
||||
if (ctx.settings.projectDir == null) return;
|
||||
final s = ctx.settings;
|
||||
final a = ctx.arrangement;
|
||||
s.set(_kSidebarCollapsed, a.isCollapsed(Slots.sidebar));
|
||||
s.set(_kContextCollapsed, a.isCollapsed(Slots.contextPanel));
|
||||
s.set(_kSidebarSize, a.sizeOf(Slots.sidebar));
|
||||
s.set(_kContextSize, a.sizeOf(Slots.contextPanel));
|
||||
s.set(_kEditorRatio, a.editorRatio);
|
||||
}
|
||||
|
||||
void _persistActiveTabs(ClideExtensionContext ctx) {
|
||||
if (ctx.settings.projectDir == null) return;
|
||||
final s = ctx.settings;
|
||||
final left = ctx.panels.activeTabIn(Slots.sidebar);
|
||||
if (left != null) s.set(_kActiveLeft, left);
|
||||
final right = ctx.panels.activeTabIn(Slots.contextPanel);
|
||||
if (right != null) s.set(_kActiveRight, right);
|
||||
}
|
||||
|
||||
static const _kSidebarCollapsed = 'project.layout.sidebar.collapsed';
|
||||
static const _kContextCollapsed = 'project.layout.context.collapsed';
|
||||
static const _kSidebarSize = 'project.layout.sidebar.size';
|
||||
static const _kContextSize = 'project.layout.context.size';
|
||||
static const _kEditorRatio = 'project.layout.editor.ratio';
|
||||
static const _kActiveLeft = 'project.layout.sidebar.activeTab';
|
||||
static const _kActiveRight = 'project.layout.context.activeTab';
|
||||
|
||||
Future<IpcResponse> _reset(List<String> args) async {
|
||||
final preset = _preset;
|
||||
final ctx = _ctx;
|
||||
if (preset == null || ctx == null) return _notActivated();
|
||||
ctx.arrangement.applyPreset(preset);
|
||||
return IpcResponse.ok(id: '', data: {'preset': preset.id});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _togglePalette(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
ctx.palette.toggle();
|
||||
return IpcResponse.ok(id: '', data: {'open': ctx.palette.isOpen});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _collapseSidebar(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
ctx.arrangement.toggleCollapsed(Slots.sidebar);
|
||||
final collapsed = ctx.arrangement.isCollapsed(Slots.sidebar);
|
||||
return IpcResponse.ok(id: '', data: {'collapsed': collapsed});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _collapseContext(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
ctx.arrangement.toggleCollapsed(Slots.contextPanel);
|
||||
final collapsed = ctx.arrangement.isCollapsed(Slots.contextPanel);
|
||||
return IpcResponse.ok(id: '', data: {'collapsed': collapsed});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _focusLeft(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
if (ctx.arrangement.isCollapsed(Slots.sidebar)) {
|
||||
ctx.arrangement.setCollapsed(Slots.sidebar, false);
|
||||
}
|
||||
final active = ctx.panels.activeTabIn(Slots.sidebar);
|
||||
if (active != null) {
|
||||
ctx.focus.setActive(slot: Slots.sidebar, contributionId: active);
|
||||
}
|
||||
return IpcResponse.ok(id: '', data: {'focused': 'sidebar'});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _focusMiddle(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
final active = ctx.panels.activeTabIn(Slots.workspace);
|
||||
if (active != null) {
|
||||
ctx.focus.setActive(slot: Slots.workspace, contributionId: active);
|
||||
}
|
||||
return IpcResponse.ok(id: '', data: {'focused': 'workspace'});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _focusRight(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
if (ctx.arrangement.isCollapsed(Slots.contextPanel)) {
|
||||
ctx.arrangement.setCollapsed(Slots.contextPanel, false);
|
||||
}
|
||||
final active = ctx.panels.activeTabIn(Slots.contextPanel);
|
||||
if (active != null) {
|
||||
ctx.focus.setActive(slot: Slots.contextPanel, contributionId: active);
|
||||
}
|
||||
return IpcResponse.ok(id: '', data: {'focused': 'context'});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _toggleFocusMode(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
final activeSlot = ctx.focus.activeSlot ?? Slots.workspace;
|
||||
ctx.arrangement.toggleFocusMode(activeSlot);
|
||||
return IpcResponse.ok(id: '', data: {'focusMode': ctx.arrangement.isInFocusMode});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _exitFocusMode(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
if (ctx.arrangement.isInFocusMode) {
|
||||
ctx.arrangement.exitFocusMode();
|
||||
return IpcResponse.ok(id: '', data: {'focusMode': false});
|
||||
}
|
||||
if (ctx.arrangement.editorOpen) {
|
||||
ctx.arrangement.closeEditor();
|
||||
return IpcResponse.ok(id: '', data: {'editorOpen': false});
|
||||
}
|
||||
if (ctx.palette.isOpen) {
|
||||
ctx.palette.toggle();
|
||||
return IpcResponse.ok(id: '', data: {'palette': false});
|
||||
}
|
||||
return IpcResponse.ok(id: '', data: {});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _openEditor(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
ctx.arrangement.openEditor();
|
||||
return IpcResponse.ok(id: '', data: {'editorOpen': true});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _closeEditor(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
if (ctx.arrangement.editorOpen) {
|
||||
ctx.arrangement.closeEditor();
|
||||
return IpcResponse.ok(id: '', data: {'editorOpen': false});
|
||||
}
|
||||
return IpcResponse.ok(id: '', data: {});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _switchSidebarSection(int index) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
if (ctx.arrangement.isCollapsed(Slots.sidebar)) {
|
||||
ctx.arrangement.setCollapsed(Slots.sidebar, false);
|
||||
}
|
||||
final tabs = ctx.panels.tabsFor(Slots.sidebar);
|
||||
if (index < tabs.length) {
|
||||
ctx.panels.activateTab(Slots.sidebar, tabs[index].id);
|
||||
return IpcResponse.ok(id: '', data: {'section': tabs[index].id});
|
||||
}
|
||||
return IpcResponse.ok(id: '', data: {});
|
||||
}
|
||||
|
||||
static IpcResponse _notActivated() => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'not activated',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/diff_controller.dart';
|
||||
export 'src/diff_view.dart';
|
||||
@@ -0,0 +1,82 @@
|
||||
/// State model for the diff workspace tab.
|
||||
///
|
||||
/// Holds the parsed diff data for a single file (or all files). Hydrates
|
||||
/// via `git.diff` IPC, subscribes to `git.changed` events to refresh.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class DiffController extends ChangeNotifier {
|
||||
DiffController({required this.ipc, required this.events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final EventBus events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
List<Map<String, Object?>> _diffs = const [];
|
||||
List<Map<String, Object?>> get diffs => _diffs;
|
||||
|
||||
bool _staged = false;
|
||||
bool get showStaged => _staged;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
bool _loading = false;
|
||||
bool get loading => _loading;
|
||||
|
||||
/// Load diffs. Optionally filter to [paths] and toggle [staged].
|
||||
Future<void> load({
|
||||
bool staged = false,
|
||||
List<String> paths = const [],
|
||||
}) async {
|
||||
_staged = staged;
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('git.diff', args: {
|
||||
'staged': staged,
|
||||
if (paths.isNotEmpty) 'paths': paths,
|
||||
});
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message ?? 'git.diff failed';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_error = null;
|
||||
_diffs = _castList(r.data['diffs']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleStaged() {
|
||||
unawaited(load(staged: !_staged));
|
||||
}
|
||||
|
||||
void _onEvent(DaemonEvent e) {
|
||||
if (e.subsystem != 'git') return;
|
||||
if (e.kind == 'git.changed') {
|
||||
unawaited(load(staged: _staged));
|
||||
}
|
||||
}
|
||||
|
||||
static List<Map<String, Object?>> _castList(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return [for (final e in raw) (e as Map).cast<String, Object?>()];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/// Workspace tab rendering unified diffs with hunk-level
|
||||
/// stage/unstage actions.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'diff_controller.dart';
|
||||
|
||||
class DiffView extends StatefulWidget {
|
||||
const DiffView({super.key});
|
||||
|
||||
@override
|
||||
State<DiffView> createState() => _DiffViewState();
|
||||
}
|
||||
|
||||
class _DiffViewState extends State<DiffView> {
|
||||
DiffController? _controller;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = DiffController(ipc: kernel.ipc, events: kernel.events);
|
||||
unawaited(_controller!.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'diff view',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DiffToolbar(controller: c),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
),
|
||||
),
|
||||
if (c.loading && c.diffs.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
),
|
||||
if (!c.loading && c.diffs.isEmpty && c.error == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
c.showStaged
|
||||
? 'No staged changes.'
|
||||
: 'No unstaged changes.',
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final diff in c.diffs)
|
||||
_FileDiff(diff: diff, controller: c),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiffToolbar extends StatelessWidget {
|
||||
const _DiffToolbar({required this.controller});
|
||||
final DiffController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
toggled: !controller.showStaged,
|
||||
label: 'show unstaged changes',
|
||||
child: GestureDetector(
|
||||
onTap: controller.showStaged ? controller.toggleStaged : null,
|
||||
child: ClideText(
|
||||
'Unstaged',
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.showStaged
|
||||
? tokens.globalTextMuted
|
||||
: tokens.globalForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Semantics(
|
||||
button: true,
|
||||
toggled: controller.showStaged,
|
||||
label: 'show staged changes',
|
||||
child: GestureDetector(
|
||||
onTap: controller.showStaged ? null : controller.toggleStaged,
|
||||
child: ClideText(
|
||||
'Staged',
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.showStaged
|
||||
? tokens.globalForeground
|
||||
: tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileDiff extends StatelessWidget {
|
||||
const _FileDiff({required this.diff, required this.controller});
|
||||
final Map<String, Object?> diff;
|
||||
final DiffController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final path = diff['path'] as String? ?? '';
|
||||
final isBinary = diff['binary'] as bool? ?? false;
|
||||
final isNew = diff['new'] as bool? ?? false;
|
||||
final isDeleted = diff['deleted'] as bool? ?? false;
|
||||
final isRenamed = diff['renamed'] as bool? ?? false;
|
||||
final additions = (diff['additions'] as num?)?.toInt() ?? 0;
|
||||
final removals = (diff['removals'] as num?)?.toInt() ?? 0;
|
||||
final hunks = (diff['hunks'] as List?) ?? const [];
|
||||
|
||||
final meta = <String>[];
|
||||
if (isNew) meta.add('new file');
|
||||
if (isDeleted) meta.add('deleted');
|
||||
if (isRenamed) {
|
||||
final oldPath = diff['oldPath'] as String?;
|
||||
if (oldPath != null) meta.add('renamed from $oldPath');
|
||||
}
|
||||
if (isBinary) meta.add('binary');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
color: tokens.panelHeader,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
path,
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.panelHeaderForeground,
|
||||
),
|
||||
),
|
||||
if (additions > 0)
|
||||
ClideText('+$additions ', fontSize: clideFontCaption,
|
||||
color: tokens.statusSuccess),
|
||||
if (removals > 0)
|
||||
ClideText('-$removals', fontSize: clideFontCaption,
|
||||
color: tokens.statusError),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (meta.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: ClideText(meta.join(' · '), fontSize: clideFontCaption, muted: true),
|
||||
),
|
||||
if (!isBinary)
|
||||
for (final hunk in hunks)
|
||||
_HunkView(
|
||||
hunk: (hunk as Map).cast<String, Object?>(),
|
||||
filePath: path,
|
||||
controller: controller,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HunkView extends StatelessWidget {
|
||||
const _HunkView({
|
||||
required this.hunk,
|
||||
required this.filePath,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final Map<String, Object?> hunk;
|
||||
final String filePath;
|
||||
final DiffController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final header = hunk['header'] as String? ?? '';
|
||||
final lines = (hunk['lines'] as List?) ?? const [];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: ClideText(
|
||||
header,
|
||||
fontSize: clideFontMono,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
),
|
||||
for (final lineObj in lines)
|
||||
_DiffLineRow(
|
||||
line: (lineObj as Map).cast<String, Object?>(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiffLineRow extends StatelessWidget {
|
||||
const _DiffLineRow({required this.line});
|
||||
final Map<String, Object?> line;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final kind = line['kind'] as String? ?? 'context';
|
||||
final text = line['text'] as String? ?? '';
|
||||
final oldLineNo = line['oldLineNo'] as num?;
|
||||
final newLineNo = line['newLineNo'] as num?;
|
||||
|
||||
final (Color bg, Color fg) = switch (kind) {
|
||||
'addition' => (
|
||||
tokens.statusSuccess.withValues(alpha: 0.15),
|
||||
tokens.statusSuccess,
|
||||
),
|
||||
'removal' => (
|
||||
tokens.statusError.withValues(alpha: 0.15),
|
||||
tokens.statusError,
|
||||
),
|
||||
_ => (
|
||||
const Color(0x00000000),
|
||||
tokens.globalForeground,
|
||||
),
|
||||
};
|
||||
|
||||
final prefix = switch (kind) {
|
||||
'addition' => '+',
|
||||
'removal' => '-',
|
||||
'header' => '',
|
||||
_ => ' ',
|
||||
};
|
||||
|
||||
return Container(
|
||||
color: bg,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: ClideText(
|
||||
oldLineNo != null ? '${oldLineNo.toInt()}' : '',
|
||||
fontSize: clideFontMono,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: ClideText(
|
||||
newLineNo != null ? '${newLineNo.toInt()}' : '',
|
||||
fontSize: clideFontMono,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ClideText(
|
||||
prefix,
|
||||
fontSize: clideFontMono,
|
||||
color: fg,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
text,
|
||||
fontSize: clideFontMono,
|
||||
color: fg,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:clide/builtin/diff/src/diff_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
class DiffExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.diff';
|
||||
@override
|
||||
String get title => 'Diff';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'diff.view',
|
||||
slot: Slots.workspace,
|
||||
title: 'Diff',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -70,
|
||||
build: (_) => const DiffView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,171 @@
|
||||
/// Flutter-side mirror of the daemon's active-editor state.
|
||||
///
|
||||
/// Listens to `editor.*` events over IPC and tracks: the active
|
||||
/// buffer's id/path/content/selection, and whether the buffer is
|
||||
/// dirty. The widget layer consumes this via [ListenableBuilder].
|
||||
///
|
||||
/// User edits flow the other way — the widget calls into the
|
||||
/// controller, which calls `editor.set-content` / `editor.save` on
|
||||
/// the daemon. The daemon is the source of truth; the widget is a
|
||||
/// reconciled view.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class EditorController extends ChangeNotifier {
|
||||
EditorController({required this.ipc, required EventBus events})
|
||||
: _events = events {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
// ignore: unused_field — kept for future subscription changes
|
||||
final EventBus _events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
String? _activeId;
|
||||
String? _activePath;
|
||||
String _content = '';
|
||||
Selection _selection = const Selection.collapsed(0);
|
||||
bool _dirty = false;
|
||||
String? _error;
|
||||
|
||||
bool _suppressNextRemoteEdit = false;
|
||||
int _pendingLocalEdits = 0;
|
||||
|
||||
String? get activeId => _activeId;
|
||||
String? get activePath => _activePath;
|
||||
String get content => _content;
|
||||
Selection get selection => _selection;
|
||||
bool get dirty => _dirty;
|
||||
String? get error => _error;
|
||||
|
||||
/// On first mount we don't know what (if anything) is already
|
||||
/// active. Ask the daemon.
|
||||
Future<void> hydrate() async {
|
||||
final r = await ipc.request('editor.active');
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
final active = r.data['active'];
|
||||
if (active is! Map) {
|
||||
_activeId = null;
|
||||
_activePath = null;
|
||||
_content = '';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
final id = active['id']! as String;
|
||||
await _loadBuffer(id);
|
||||
}
|
||||
|
||||
Future<void> _loadBuffer(String id) async {
|
||||
final r = await ipc.request('editor.read', args: {'id': id});
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_activeId = r.data['id']! as String;
|
||||
_activePath = r.data['path']! as String;
|
||||
_content = (r.data['content'] as String?) ?? '';
|
||||
final sel = r.data['selection'];
|
||||
_selection = sel is Map
|
||||
? Selection.fromJson(sel.cast<String, Object?>())
|
||||
: const Selection.collapsed(0);
|
||||
_dirty = (r.data['dirty'] as bool?) ?? false;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Called by the widget on every local text edit.
|
||||
void pushLocalEdit({
|
||||
required String newContent,
|
||||
required Selection newSelection,
|
||||
}) {
|
||||
final id = _activeId;
|
||||
if (id == null) return;
|
||||
|
||||
_content = newContent;
|
||||
_selection = newSelection;
|
||||
_dirty = true;
|
||||
notifyListeners();
|
||||
|
||||
// Mirror to daemon. Use editor.set-content for the first cut —
|
||||
// it's coarse but simple and avoids diff computation. Future
|
||||
// tuning: diff + editor.insert / editor.replace-selection for
|
||||
// large buffers, so event broadcasts stay small.
|
||||
_pendingLocalEdits++;
|
||||
_suppressNextRemoteEdit = true;
|
||||
ipc.request('editor.set-content', args: {
|
||||
'id': id,
|
||||
'text': newContent,
|
||||
'selection': newSelection.toJson(),
|
||||
}).whenComplete(() => _pendingLocalEdits--);
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
final id = _activeId;
|
||||
if (id == null) return;
|
||||
await ipc.request('editor.save', args: {'id': id});
|
||||
}
|
||||
|
||||
void _onEvent(DaemonEvent e) {
|
||||
if (e.subsystem != 'editor') return;
|
||||
switch (e.kind) {
|
||||
case 'editor.opened':
|
||||
case 'editor.active-changed':
|
||||
final id = e.data['id'] as String?;
|
||||
if (id == null) {
|
||||
_activeId = null;
|
||||
_activePath = null;
|
||||
_content = '';
|
||||
_selection = const Selection.collapsed(0);
|
||||
_dirty = false;
|
||||
notifyListeners();
|
||||
} else if (id != _activeId) {
|
||||
_loadBuffer(id);
|
||||
}
|
||||
case 'editor.edited':
|
||||
// Our own set-content echoes back as editor.edited. Skip one
|
||||
// bounce so we don't clobber the caret the user just moved.
|
||||
if (_suppressNextRemoteEdit) {
|
||||
_suppressNextRemoteEdit = false;
|
||||
return;
|
||||
}
|
||||
// Remote edit (another client, or the CLI inserting bytes).
|
||||
// Reload the authoritative buffer.
|
||||
final id = e.data['id'] as String?;
|
||||
if (id != null && id == _activeId && _pendingLocalEdits == 0) {
|
||||
_loadBuffer(id);
|
||||
}
|
||||
case 'editor.saved':
|
||||
if (e.data['id'] == _activeId) {
|
||||
_dirty = false;
|
||||
notifyListeners();
|
||||
}
|
||||
case 'editor.closed':
|
||||
if (e.data['id'] == _activeId) {
|
||||
_activeId = null;
|
||||
_activePath = null;
|
||||
_content = '';
|
||||
_dirty = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'editor_controller.dart';
|
||||
import 'syntax_text_controller.dart';
|
||||
|
||||
/// Tier-2 editor tab. One tab — the content reflects the daemon's
|
||||
/// active buffer. Multi-file tabs live in the workspace-slot plan but
|
||||
/// aren't in Tier 2's scope; opening a new file swaps this view's
|
||||
/// content.
|
||||
///
|
||||
/// Uses Flutter's low-level `EditableText` so we stay off Material
|
||||
/// per D-007. Owning more of the editor stack (line numbers, gutter,
|
||||
/// syntax highlighting) lands in later tiers; Tier 2 is plain text.
|
||||
class EditorView extends StatefulWidget {
|
||||
const EditorView({super.key});
|
||||
|
||||
@override
|
||||
State<EditorView> createState() => _EditorViewState();
|
||||
}
|
||||
|
||||
class _EditorViewState extends State<EditorView> {
|
||||
EditorController? _controller;
|
||||
final TreeSitterService _syntax = TreeSitterService();
|
||||
late final SyntaxTextController _text;
|
||||
late final FocusNode _focus;
|
||||
String? _lastRemoteContent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_text = SyntaxTextController(syntax: _syntax);
|
||||
_focus = FocusNode();
|
||||
_text.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = EditorController(ipc: kernel.ipc, events: kernel.events)
|
||||
..addListener(_onControllerChanged);
|
||||
unawaited(_controller!.hydrate());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.dispose();
|
||||
_focus.dispose();
|
||||
_controller?.removeListener(_onControllerChanged);
|
||||
_controller?.dispose();
|
||||
_syntax.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onControllerChanged() {
|
||||
final c = _controller!;
|
||||
_text.updatePath(c.activePath);
|
||||
if (c.content != _lastRemoteContent) {
|
||||
_lastRemoteContent = c.content;
|
||||
final sel = TextSelection(
|
||||
baseOffset: c.selection.start.clamp(0, c.content.length),
|
||||
extentOffset: c.selection.end.clamp(0, c.content.length),
|
||||
);
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.value = TextEditingValue(text: c.content, selection: sel);
|
||||
_text.addListener(_onTextChanged);
|
||||
}
|
||||
setState(() {}); // subtitle refresh
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
final c = _controller;
|
||||
if (c == null || c.activeId == null) return;
|
||||
final value = _text.value;
|
||||
if (value.text == c.content &&
|
||||
value.selection.baseOffset == c.selection.start &&
|
||||
value.selection.extentOffset == c.selection.end) {
|
||||
return;
|
||||
}
|
||||
_lastRemoteContent = value.text;
|
||||
c.pushLocalEdit(
|
||||
newContent: value.text,
|
||||
newSelection: Selection(
|
||||
start: value.selection.start < 0
|
||||
? value.text.length
|
||||
: value.selection.start,
|
||||
end: value.selection.end < 0
|
||||
? value.text.length
|
||||
: value.selection.end,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final isCmd = HardwareKeyboard.instance.isMetaPressed ||
|
||||
HardwareKeyboard.instance.isControlPressed;
|
||||
if (isCmd && event.logicalKey == LogicalKeyboardKey.keyS) {
|
||||
unawaited(_controller?.save());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
_text.tokens = tokens;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final title = c.activePath ?? 'editor';
|
||||
final subtitle = c.activeId == null
|
||||
? 'no buffer · use `clide open <path>` or pick a file in the tree'
|
||||
: '${c.activeId} · ${c.dirty ? 'modified' : 'saved'}'
|
||||
'${c.error == null ? '' : ' · ${c.error}'}';
|
||||
|
||||
return ClidePaneChrome(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
child: c.activeId == null
|
||||
? const Center(
|
||||
child: ClideText(
|
||||
'Open a file to begin editing.',
|
||||
muted: true,
|
||||
),
|
||||
)
|
||||
: Focus(
|
||||
onKeyEvent: _onKey,
|
||||
child: _TextBody(
|
||||
controller: _text,
|
||||
focus: _focus,
|
||||
background: tokens.panelBackground,
|
||||
foreground: tokens.globalForeground,
|
||||
accent: tokens.globalFocus,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TextBody extends StatelessWidget {
|
||||
const _TextBody({
|
||||
required this.controller,
|
||||
required this.focus,
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
required this.accent,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode focus;
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
final Color accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
label: 'editor text area',
|
||||
textField: true,
|
||||
multiline: true,
|
||||
child: ColoredBox(
|
||||
color: background,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: EditableText(
|
||||
controller: controller,
|
||||
focusNode: focus,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontSize: clideFontMono,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
),
|
||||
cursorColor: foreground,
|
||||
backgroundCursorColor: foreground.withAlpha(0x44),
|
||||
selectionColor: accent.withAlpha(0x55),
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textAlign: TextAlign.start,
|
||||
showCursor: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:clide/builtin/editor/src/editor_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
/// Tier-2 editor pane. Contributes a single workspace tab that
|
||||
/// renders the daemon's active buffer. Multi-file tabs live in the
|
||||
/// follow-up plan; today the pane is one-at-a-time.
|
||||
class EditorExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.editor';
|
||||
@override
|
||||
String get title => 'Editor';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'editor.active',
|
||||
slot: Slots.workspace,
|
||||
title: 'Editor',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: 80, // between Claude (90) and welcome (-100)
|
||||
build: (_) => const EditorView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SyntaxTextController extends TextEditingController {
|
||||
SyntaxTextController({required TreeSitterService syntax})
|
||||
: _syntax = syntax;
|
||||
|
||||
final TreeSitterService _syntax;
|
||||
|
||||
String? _highlightedPath;
|
||||
String? _highlightedText;
|
||||
List<SyntaxSpan> _spans = const [];
|
||||
SurfaceTokens? _tokens;
|
||||
bool _highlighting = false;
|
||||
|
||||
set tokens(SurfaceTokens value) => _tokens = value;
|
||||
|
||||
void updatePath(String? path) {
|
||||
if (path == _highlightedPath) return;
|
||||
_highlightedPath = path;
|
||||
_spans = const [];
|
||||
_highlightedText = null;
|
||||
_requestHighlight();
|
||||
}
|
||||
|
||||
void _requestHighlight() {
|
||||
final path = _highlightedPath;
|
||||
final source = text;
|
||||
if (path == null || source.isEmpty || _highlighting) return;
|
||||
if (grammarForPath(path) == null) return;
|
||||
if (source == _highlightedText) return;
|
||||
|
||||
_highlighting = true;
|
||||
_syntax.highlight(path, source).then((result) {
|
||||
_highlighting = false;
|
||||
if (text != source) {
|
||||
_requestHighlight();
|
||||
return;
|
||||
}
|
||||
_highlightedText = source;
|
||||
_spans = result.spans;
|
||||
notifyListeners();
|
||||
}, onError: (_) {
|
||||
_highlighting = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
set value(TextEditingValue newValue) {
|
||||
super.value = newValue;
|
||||
_requestHighlight();
|
||||
}
|
||||
|
||||
@override
|
||||
TextSpan buildTextSpan({
|
||||
required BuildContext context,
|
||||
TextStyle? style,
|
||||
required bool withComposing,
|
||||
}) {
|
||||
final tokens = _tokens;
|
||||
if (_spans.isEmpty || tokens == null || text.isEmpty) {
|
||||
return TextSpan(text: text, style: style);
|
||||
}
|
||||
|
||||
final source = text;
|
||||
final sourceBytes = utf8.encode(source);
|
||||
final children = <TextSpan>[];
|
||||
|
||||
// Convert byte offsets to character offsets.
|
||||
// Build a byte-to-char map only up to the max byte we need.
|
||||
final spans = _spans.where((s) => s.end <= sourceBytes.length).toList()
|
||||
..sort((a, b) => a.start != b.start ? a.start - b.start : a.end - b.end);
|
||||
|
||||
if (spans.isEmpty) {
|
||||
return TextSpan(text: text, style: style);
|
||||
}
|
||||
|
||||
int maxByte = 0;
|
||||
for (final s in spans) {
|
||||
if (s.end > maxByte) maxByte = s.end;
|
||||
}
|
||||
|
||||
// byte offset -> char offset lookup.
|
||||
final byteToChar = List<int>.filled(maxByte + 1, 0);
|
||||
int charIdx = 0;
|
||||
int byteIdx = 0;
|
||||
while (byteIdx <= maxByte && charIdx <= source.length) {
|
||||
byteToChar[byteIdx] = charIdx;
|
||||
if (charIdx < source.length) {
|
||||
final codeUnit = source.codeUnitAt(charIdx);
|
||||
// UTF-16 surrogate pair = 4 bytes in UTF-8.
|
||||
if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) {
|
||||
final bytesForPair = utf8.encode(source.substring(charIdx, charIdx + 2)).length;
|
||||
for (var b = 1; b < bytesForPair && byteIdx + b <= maxByte; b++) {
|
||||
byteToChar[byteIdx + b] = charIdx;
|
||||
}
|
||||
byteIdx += bytesForPair;
|
||||
charIdx += 2;
|
||||
} else {
|
||||
final bytesForChar = utf8.encode(source[charIdx]).length;
|
||||
for (var b = 1; b < bytesForChar && byteIdx + b <= maxByte; b++) {
|
||||
byteToChar[byteIdx + b] = charIdx;
|
||||
}
|
||||
byteIdx += bytesForChar;
|
||||
charIdx++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int charPos = 0;
|
||||
for (final span in spans) {
|
||||
if (span.start >= sourceBytes.length || span.end > sourceBytes.length) {
|
||||
continue;
|
||||
}
|
||||
final spanCharStart = byteToChar[span.start];
|
||||
final spanCharEnd = span.end <= maxByte
|
||||
? byteToChar[span.end]
|
||||
: source.length;
|
||||
|
||||
if (spanCharStart < charPos) continue;
|
||||
|
||||
// Gap before this span — plain text.
|
||||
if (spanCharStart > charPos) {
|
||||
children.add(TextSpan(
|
||||
text: source.substring(charPos, spanCharStart),
|
||||
style: style,
|
||||
));
|
||||
}
|
||||
|
||||
// The highlighted span.
|
||||
if (spanCharEnd > spanCharStart) {
|
||||
children.add(TextSpan(
|
||||
text: source.substring(spanCharStart, spanCharEnd),
|
||||
style: style?.copyWith(
|
||||
color: TreeSitterService.colorForRole(span.role, tokens),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
charPos = spanCharEnd;
|
||||
}
|
||||
|
||||
// Trailing plain text.
|
||||
if (charPos < source.length) {
|
||||
children.add(TextSpan(
|
||||
text: source.substring(charPos),
|
||||
style: style,
|
||||
));
|
||||
}
|
||||
|
||||
return TextSpan(style: style, children: children);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class ExtensionsUiExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.extensions-ui';
|
||||
@override
|
||||
String get title => 'Extensions UI';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:clide/builtin/files/src/file_tree_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
/// Workspace filesystem panel. Contributes a sidebar tab that renders
|
||||
/// the workspace file tree rooted at the git root, powered by the
|
||||
/// daemon's `files.*` subsystem (ls + watch with ignore-file
|
||||
/// filtering).
|
||||
class FilesExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.files';
|
||||
@override
|
||||
String get title => 'Files';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'files.tree',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Files',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -100,
|
||||
build: (_) => const FileTreeView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/// State model for the file-tree panel.
|
||||
///
|
||||
/// Owns a map of expanded directory → entries (lazy-loaded), the
|
||||
/// workspace root path, and an IPC subscription to `files.changed`
|
||||
/// events. Invalidation on events is coarse today — a change under
|
||||
/// `a/b/` invalidates every currently-expanded directory that could
|
||||
/// have been affected. Refinement (per-dir change tracking) is a
|
||||
/// clear win once the tree gets large.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class FileTreeController extends ChangeNotifier {
|
||||
FileTreeController({required this.ipc, required this.events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final EventBus events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
String? _rootPath;
|
||||
String? get rootPath => _rootPath;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
bool _watchSubscribed = false;
|
||||
|
||||
final Set<String> _expanded = {''}; // '' = workspace root
|
||||
bool isExpanded(String path) => _expanded.contains(path);
|
||||
|
||||
final Map<String, List<FileEntry>> _entries = {};
|
||||
List<FileEntry>? entriesFor(String path) => _entries[path];
|
||||
|
||||
/// Initial boot: resolve the workspace root, load the root dir,
|
||||
/// subscribe to `files.changed` events.
|
||||
Future<void> load() async {
|
||||
final rootResp = await ipc.request('files.root');
|
||||
if (!rootResp.ok) {
|
||||
_error = rootResp.error?.message ?? 'files.root failed';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_rootPath = rootResp.data['path'] as String?;
|
||||
|
||||
final watchResp = await ipc.request('files.watch');
|
||||
_watchSubscribed = watchResp.ok;
|
||||
|
||||
await _loadDir('');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggle(String path) async {
|
||||
if (_expanded.contains(path)) {
|
||||
_expanded.remove(path);
|
||||
notifyListeners();
|
||||
} else {
|
||||
_expanded.add(path);
|
||||
if (!_entries.containsKey(path)) {
|
||||
await _loadDir(path);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh(String path) async {
|
||||
await _loadDir(path);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadDir(String path) async {
|
||||
final r = await ipc.request('files.ls', args: {'path': path});
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message ?? 'files.ls($path) failed';
|
||||
return;
|
||||
}
|
||||
final raw = (r.data['entries'] as List?) ?? const [];
|
||||
_entries[path] = [
|
||||
for (final e in raw.whereType<Map>())
|
||||
FileEntry(
|
||||
name: e['name']! as String,
|
||||
path: e['path']! as String,
|
||||
isDirectory: e['isDirectory']! as bool,
|
||||
isSymlink: (e['isSymlink'] as bool?) ?? false,
|
||||
sizeBytes: (e['sizeBytes'] as num?)?.toInt(),
|
||||
modifiedMs: (e['modifiedMs'] as num?)?.toInt(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _onEvent(DaemonEvent e) {
|
||||
if (e.subsystem != 'files') return;
|
||||
if (e.kind != 'files.changed') return;
|
||||
// Coarse invalidation: reload the parent directory of the change,
|
||||
// plus the root if the change is at top-level. This keeps the
|
||||
// tree accurate without optimistic local mutation.
|
||||
final path = (e.data['path'] as String?) ?? '';
|
||||
final parent = _parentOf(path);
|
||||
if (_entries.containsKey(parent)) {
|
||||
unawaited(refresh(parent));
|
||||
}
|
||||
}
|
||||
|
||||
static String _parentOf(String path) {
|
||||
final slash = path.lastIndexOf('/');
|
||||
return slash < 0 ? '' : path.substring(0, slash);
|
||||
}
|
||||
|
||||
bool get watchSubscribed => _watchSubscribed;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'file_tree_controller.dart';
|
||||
|
||||
/// Sidebar panel rendering the workspace file tree.
|
||||
///
|
||||
/// Lazy-expands directories via `files.ls`, subscribes to
|
||||
/// `files.changed` events from the daemon, and refreshes the affected
|
||||
/// subtrees on change. Click-to-open is plumbed through `kernel.commands`
|
||||
/// — today the command doesn't exist yet (lands with Tier 2's editor);
|
||||
/// the view degrades gracefully to a no-op when the command isn't
|
||||
/// registered.
|
||||
class FileTreeView extends StatefulWidget {
|
||||
const FileTreeView({super.key});
|
||||
|
||||
@override
|
||||
State<FileTreeView> createState() => _FileTreeViewState();
|
||||
}
|
||||
|
||||
class _FileTreeViewState extends State<FileTreeView> {
|
||||
FileTreeController? _controller;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = FileTreeController(ipc: kernel.ipc, events: kernel.events);
|
||||
unawaited(_controller!.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
if (c.error != null && c.rootPath == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(c.error!, muted: true),
|
||||
);
|
||||
}
|
||||
final root = c.rootPath;
|
||||
if (root == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
);
|
||||
}
|
||||
final rootName = root.split(Platform.pathSeparator).last;
|
||||
return Semantics(
|
||||
label: 'file tree — $rootName',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_DirRow(
|
||||
name: rootName,
|
||||
path: '',
|
||||
controller: c,
|
||||
depth: 0,
|
||||
),
|
||||
if (c.isExpanded(''))
|
||||
_Children(path: '', controller: c, depth: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Children extends StatelessWidget {
|
||||
const _Children({
|
||||
required this.path,
|
||||
required this.controller,
|
||||
required this.depth,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
final int depth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entries = controller.entriesFor(path);
|
||||
if (entries == null) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final e in entries)
|
||||
if (e.isDirectory)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_DirRow(
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
controller: controller,
|
||||
depth: depth,
|
||||
),
|
||||
if (controller.isExpanded(e.path))
|
||||
_Children(path: e.path, controller: controller, depth: depth + 1),
|
||||
],
|
||||
)
|
||||
else
|
||||
_FileRow(
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
depth: depth,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DirRow extends StatelessWidget {
|
||||
const _DirRow({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.controller,
|
||||
required this.depth,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
final int depth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expanded = controller.isExpanded(path);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '${expanded ? 'Collapse' : 'Expand'} $name',
|
||||
onTap: () => controller.toggle(path),
|
||||
child: _Row(
|
||||
depth: depth,
|
||||
onTap: () => controller.toggle(path),
|
||||
leading: ClideIcon(
|
||||
const ChevronRightIcon(),
|
||||
size: 10,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
label: name,
|
||||
rotateLeading: expanded,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.depth,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final int depth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Open $name',
|
||||
onTap: () => _openFile(context, path),
|
||||
child: _Row(
|
||||
depth: depth,
|
||||
onTap: () => _openFile(context, path),
|
||||
label: name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openFile(BuildContext context, String path) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
// editor.open is a daemon-side IPC handler (lib/src/daemon/
|
||||
// editor_commands.dart), not a kernel command. Fire the request
|
||||
// and let the editor extension's controller pick up the
|
||||
// editor.active-changed / editor.opened event — no need to await
|
||||
// or handle the response here.
|
||||
unawaited(
|
||||
kernel.ipc.request('editor.open', args: {'path': path}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatefulWidget {
|
||||
const _Row({
|
||||
required this.depth,
|
||||
required this.onTap,
|
||||
required this.label,
|
||||
this.leading,
|
||||
this.rotateLeading = false,
|
||||
});
|
||||
|
||||
final int depth;
|
||||
final VoidCallback onTap;
|
||||
final String label;
|
||||
final Widget? leading;
|
||||
final bool rotateLeading;
|
||||
|
||||
@override
|
||||
State<_Row> createState() => _RowState();
|
||||
}
|
||||
|
||||
class _RowState extends State<_Row> {
|
||||
bool _hover = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final leftPadding = 8.0 + (widget.depth * 14.0);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
color: _hover ? tokens.sidebarItemHover : null,
|
||||
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.leading != null) ...[
|
||||
Transform.rotate(
|
||||
angle: widget.rotateLeading ? 1.5708 : 0, // 90° when expanded
|
||||
child: widget.leading,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
] else
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
widget.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/git_controller.dart';
|
||||
export 'src/git_panel_view.dart';
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:clide/builtin/git/src/git_panel_view.dart';
|
||||
import 'package:clide/builtin/git/src/git_status_item.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
class GitExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.git';
|
||||
@override
|
||||
String get title => 'Git';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.diff'];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'git.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Git',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -80,
|
||||
build: (_) => const GitPanelView(),
|
||||
),
|
||||
StatusItemContribution(
|
||||
id: 'git.branch',
|
||||
priority: 10,
|
||||
build: (_) => const GitStatusItem(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/// State model for the git sidebar panel.
|
||||
///
|
||||
/// Hydrates from `git.status` IPC on load, subscribes to `git.changed`
|
||||
/// events to auto-refresh. Exposes stage/unstage/discard/commit actions
|
||||
/// that call git.* IPC verbs and let the event-driven refresh handle
|
||||
/// state reconciliation.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class GitController extends ChangeNotifier {
|
||||
GitController({required this.ipc, required this.events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final EventBus events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
String? _branch;
|
||||
String? get branch => _branch;
|
||||
|
||||
String? _upstream;
|
||||
String? get upstream => _upstream;
|
||||
|
||||
int _ahead = 0;
|
||||
int get ahead => _ahead;
|
||||
|
||||
int _behind = 0;
|
||||
int get behind => _behind;
|
||||
|
||||
bool _clean = true;
|
||||
bool get isClean => _clean;
|
||||
|
||||
bool _hasConflicts = false;
|
||||
bool get hasConflicts => _hasConflicts;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
bool _loading = false;
|
||||
bool get loading => _loading;
|
||||
|
||||
List<Map<String, Object?>> _staged = const [];
|
||||
List<Map<String, Object?>> get staged => _staged;
|
||||
|
||||
List<Map<String, Object?>> _unstaged = const [];
|
||||
List<Map<String, Object?>> get unstaged => _unstaged;
|
||||
|
||||
List<Map<String, Object?>> _untracked = const [];
|
||||
List<Map<String, Object?>> get untracked => _untracked;
|
||||
|
||||
List<Map<String, Object?>> _conflicted = const [];
|
||||
List<Map<String, Object?>> get conflicted => _conflicted;
|
||||
|
||||
Future<void> load() async {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('git.status');
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message ?? 'git.status failed';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_applyStatus(r.data);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> stage(List<String> paths) async {
|
||||
final r = await ipc.request('git.stage', args: {'paths': paths});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<bool> stageAll() async {
|
||||
final r = await ipc.request('git.stage-all');
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<bool> unstage(List<String> paths) async {
|
||||
final r = await ipc.request('git.unstage', args: {'paths': paths});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<bool> discard(List<String> paths) async {
|
||||
final r = await ipc.request('git.discard', args: {'paths': paths});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<String?> commit(String message) async {
|
||||
final r = await ipc.request('git.commit', args: {'message': message});
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
return r.data['hash'] as String?;
|
||||
}
|
||||
|
||||
Future<bool> stash({String? message}) async {
|
||||
final r = await ipc.request('git.stash', args: {
|
||||
if (message != null) 'message': message,
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<bool> pull() async {
|
||||
final r = await ipc.request('git.pull');
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
}
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
Future<bool> push() async {
|
||||
final r = await ipc.request('git.push');
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
}
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
if (_error == null) return;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onEvent(DaemonEvent e) {
|
||||
if (e.subsystem != 'git') return;
|
||||
if (e.kind == 'git.changed') {
|
||||
unawaited(load());
|
||||
}
|
||||
}
|
||||
|
||||
void _applyStatus(Map<String, Object?> data) {
|
||||
_branch = data['branch'] as String?;
|
||||
_upstream = data['upstream'] as String?;
|
||||
_ahead = (data['ahead'] as num?)?.toInt() ?? 0;
|
||||
_behind = (data['behind'] as num?)?.toInt() ?? 0;
|
||||
_clean = data['clean'] as bool? ?? true;
|
||||
_hasConflicts = data['hasConflicts'] as bool? ?? false;
|
||||
_staged = _castList(data['staged']);
|
||||
_unstaged = _castList(data['unstaged']);
|
||||
_untracked = _castList(data['untracked']);
|
||||
_conflicted = _castList(data['conflicted']);
|
||||
_error = null;
|
||||
}
|
||||
|
||||
static List<Map<String, Object?>> _castList(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return [for (final e in raw) (e as Map).cast<String, Object?>()];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
/// Sidebar panel for git status — staged, unstaged, untracked,
|
||||
/// conflicted file groups with stage/unstage/discard actions and an
|
||||
/// inline commit message field.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'git_controller.dart';
|
||||
|
||||
class GitPanelView extends StatefulWidget {
|
||||
const GitPanelView({super.key});
|
||||
|
||||
@override
|
||||
State<GitPanelView> createState() => _GitPanelViewState();
|
||||
}
|
||||
|
||||
class _GitPanelViewState extends State<GitPanelView> {
|
||||
GitController? _controller;
|
||||
final TextEditingController _commitMsg = TextEditingController();
|
||||
final FocusNode _commitFocus = FocusNode();
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = GitController(ipc: kernel.ipc, events: kernel.events);
|
||||
unawaited(_controller!.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_commitMsg.dispose();
|
||||
_commitFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _confirmDiscard(BuildContext ctx, GitController c, String path) {
|
||||
final kernel = ClideKernel.of(ctx);
|
||||
kernel.dialog.show<String>(
|
||||
(dialogCtx, dismiss) => _DiscardConfirmDialog(
|
||||
path: path,
|
||||
onConfirm: () {
|
||||
unawaited(c.discard([path]));
|
||||
dismiss();
|
||||
},
|
||||
onCancel: () => dismiss(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'git panel',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_BranchHeader(controller: c),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: clideFontCaption,
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
if (c.loading && c.isClean)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
),
|
||||
if (!c.loading && c.isClean && c.error == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Nothing to commit, working tree clean.',
|
||||
muted: true),
|
||||
),
|
||||
if (c.conflicted.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Merge conflicts',
|
||||
entries: c.conflicted,
|
||||
actions: const [],
|
||||
),
|
||||
if (c.staged.isNotEmpty) ...[
|
||||
_FileGroup(
|
||||
label: 'Staged',
|
||||
entries: c.staged,
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Unstage all',
|
||||
onTap: () => unawaited(c.unstage(const [])),
|
||||
),
|
||||
],
|
||||
onUnstage: (path) => unawaited(c.unstage([path])),
|
||||
),
|
||||
_CommitInput(
|
||||
commitMsg: _commitMsg,
|
||||
commitFocus: _commitFocus,
|
||||
controller: c,
|
||||
),
|
||||
],
|
||||
if (c.unstaged.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Changes',
|
||||
entries: c.unstaged,
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () => unawaited(c.stageAll()),
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
onDiscard: (path) => _confirmDiscard(context, c, path),
|
||||
),
|
||||
if (c.untracked.isNotEmpty)
|
||||
_FileGroup(
|
||||
label: 'Untracked',
|
||||
entries: c.untracked,
|
||||
actions: [
|
||||
_GroupAction(
|
||||
label: 'Stage all',
|
||||
onTap: () {
|
||||
final paths = [
|
||||
for (final e in c.untracked) e['path'] as String,
|
||||
];
|
||||
unawaited(c.stage(paths));
|
||||
},
|
||||
),
|
||||
],
|
||||
onStage: (path) => unawaited(c.stage([path])),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BranchHeader extends StatelessWidget {
|
||||
const _BranchHeader({required this.controller});
|
||||
final GitController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final branch = controller.branch ?? '(detached)';
|
||||
final parts = <String>[branch];
|
||||
if (controller.ahead > 0) parts.add('↑${controller.ahead}');
|
||||
if (controller.behind > 0) parts.add('↓${controller.behind}');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
parts.join(' '),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
_SmallAction(
|
||||
label: 'Pull',
|
||||
semanticsLabel: 'git pull',
|
||||
onTap: () => unawaited(controller.pull()),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_SmallAction(
|
||||
label: 'Push',
|
||||
semanticsLabel: 'git push',
|
||||
onTap: () => unawaited(controller.push()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommitInput extends StatelessWidget {
|
||||
const _CommitInput({
|
||||
required this.commitMsg,
|
||||
required this.commitFocus,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final TextEditingController commitMsg;
|
||||
final FocusNode commitFocus;
|
||||
final GitController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Semantics(
|
||||
label: 'commit message',
|
||||
textField: true,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.globalBorder),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: EditableText(
|
||||
controller: commitMsg,
|
||||
focusNode: commitFocus,
|
||||
style: TextStyle(
|
||||
fontFamily: clideUiFamily,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalFocus,
|
||||
maxLines: 3,
|
||||
onSubmitted: (_) => _doCommit(),
|
||||
inputFormatters: const [],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClideButton(
|
||||
label: 'Commit',
|
||||
onPressed: _doCommit,
|
||||
semanticLabel: 'commit staged changes',
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _doCommit() {
|
||||
final msg = commitMsg.text.trim();
|
||||
if (msg.isEmpty) return;
|
||||
unawaited(controller.commit(msg).then((hash) {
|
||||
if (hash != null) commitMsg.clear();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupAction {
|
||||
const _GroupAction({required this.label, required this.onTap});
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
}
|
||||
|
||||
class _FileGroup extends StatelessWidget {
|
||||
const _FileGroup({
|
||||
required this.label,
|
||||
required this.entries,
|
||||
this.actions = const [],
|
||||
this.onStage,
|
||||
this.onUnstage,
|
||||
this.onDiscard,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final List<Map<String, Object?>> entries;
|
||||
final List<_GroupAction> actions;
|
||||
final void Function(String path)? onStage;
|
||||
final void Function(String path)? onUnstage;
|
||||
final void Function(String path)? onDiscard;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
'$label (${entries.length})',
|
||||
fontSize: clideFontCaption,
|
||||
muted: true,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
for (final a in actions) ...[
|
||||
_SmallAction(label: a.label, onTap: a.onTap),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final entry in entries)
|
||||
_GitFileRow(
|
||||
entry: entry,
|
||||
onStage: onStage,
|
||||
onUnstage: onUnstage,
|
||||
onDiscard: onDiscard,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GitFileRow extends StatefulWidget {
|
||||
const _GitFileRow({
|
||||
required this.entry,
|
||||
this.onStage,
|
||||
this.onUnstage,
|
||||
this.onDiscard,
|
||||
});
|
||||
|
||||
final Map<String, Object?> entry;
|
||||
final void Function(String path)? onStage;
|
||||
final void Function(String path)? onUnstage;
|
||||
final void Function(String path)? onDiscard;
|
||||
|
||||
@override
|
||||
State<_GitFileRow> createState() => _GitFileRowState();
|
||||
}
|
||||
|
||||
class _GitFileRowState extends State<_GitFileRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final path = widget.entry['path'] as String? ?? '';
|
||||
final name = path.split('/').last;
|
||||
final indexState = widget.entry['indexState'] as String?;
|
||||
final workTreeState = widget.entry['workTreeState'] as String?;
|
||||
final state = indexState ?? workTreeState ?? '';
|
||||
final stateLabel = _stateLabel(state);
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(kernel.ipc.request('editor.open', args: {'path': path}));
|
||||
},
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: '$name $stateLabel',
|
||||
child: Container(
|
||||
color: _hover ? tokens.sidebarItemHover : null,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 20, right: 8, top: 2, bottom: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(
|
||||
_stateIndicator(state),
|
||||
fontSize: clideFontCaption,
|
||||
color: _stateColor(state, tokens),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
if (_hover) ...[
|
||||
if (widget.onStage != null)
|
||||
_SmallAction(
|
||||
label: '+',
|
||||
semanticsLabel: 'stage $name',
|
||||
onTap: () => widget.onStage!(path),
|
||||
),
|
||||
if (widget.onUnstage != null)
|
||||
_SmallAction(
|
||||
label: '-',
|
||||
semanticsLabel: 'unstage $name',
|
||||
onTap: () => widget.onUnstage!(path),
|
||||
),
|
||||
if (widget.onDiscard != null)
|
||||
_SmallAction(
|
||||
label: 'x',
|
||||
semanticsLabel: 'discard changes to $name',
|
||||
onTap: () => widget.onDiscard!(path),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _stateIndicator(String state) {
|
||||
return switch (state) {
|
||||
'added' => 'A',
|
||||
'modified' => 'M',
|
||||
'deleted' => 'D',
|
||||
'renamed' => 'R',
|
||||
'copied' => 'C',
|
||||
'untracked' => '?',
|
||||
_ => ' ',
|
||||
};
|
||||
}
|
||||
|
||||
static String _stateLabel(String state) {
|
||||
return switch (state) {
|
||||
'added' => 'added',
|
||||
'modified' => 'modified',
|
||||
'deleted' => 'deleted',
|
||||
'renamed' => 'renamed',
|
||||
'copied' => 'copied',
|
||||
'untracked' => 'untracked',
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
static Color _stateColor(String state, SurfaceTokens tokens) {
|
||||
return switch (state) {
|
||||
'added' || 'untracked' => tokens.statusSuccess,
|
||||
'modified' || 'renamed' || 'copied' => tokens.statusInfo,
|
||||
'deleted' => tokens.statusError,
|
||||
_ => tokens.sidebarForeground,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallAction extends StatelessWidget {
|
||||
const _SmallAction({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.semanticsLabel,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? semanticsLabel;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: semanticsLabel ?? label,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideText(
|
||||
label,
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiscardConfirmDialog extends StatelessWidget {
|
||||
const _DiscardConfirmDialog({
|
||||
required this.path,
|
||||
required this.onConfirm,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final VoidCallback onConfirm;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final name = path.split('/').last;
|
||||
return Container(
|
||||
width: 360,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: Border.all(color: tokens.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(
|
||||
'Discard changes?',
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(
|
||||
'Unstaged changes to $name will be permanently lost.',
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.statusError,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(
|
||||
label: 'Cancel',
|
||||
variant: ClideButtonVariant.subtle,
|
||||
onPressed: onCancel,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(
|
||||
label: 'Discard',
|
||||
onPressed: onConfirm,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class GitStatusItem extends StatefulWidget {
|
||||
const GitStatusItem({super.key});
|
||||
|
||||
@override
|
||||
State<GitStatusItem> createState() => _GitStatusItemState();
|
||||
}
|
||||
|
||||
class _GitStatusItemState extends State<GitStatusItem> {
|
||||
String? _branch;
|
||||
int _ahead = 0;
|
||||
int _behind = 0;
|
||||
StreamSubscription<DaemonEvent>? _sub;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_sub != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_sub = kernel.events.on<DaemonEvent>().listen(_onEvent);
|
||||
unawaited(_load(kernel.ipc));
|
||||
}
|
||||
|
||||
Future<void> _load(DaemonClient ipc) async {
|
||||
final r = await ipc.request('git.status');
|
||||
if (!r.ok || !mounted) return;
|
||||
setState(() {
|
||||
_branch = r.data['branch'] as String?;
|
||||
_ahead = (r.data['ahead'] as num?)?.toInt() ?? 0;
|
||||
_behind = (r.data['behind'] as num?)?.toInt() ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
void _onEvent(DaemonEvent e) {
|
||||
if (e.subsystem != 'git' || e.kind != 'git.changed') return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(_load(kernel.ipc));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _openBranchPicker() {
|
||||
final kernel = ClideKernel.of(context);
|
||||
kernel.dialog.show<String>(
|
||||
(ctx, dismiss) => _BranchPicker(
|
||||
ipc: kernel.ipc,
|
||||
currentBranch: _branch,
|
||||
onDismiss: dismiss,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (_branch == null) return const SizedBox.shrink();
|
||||
final parts = <String>[_branch!];
|
||||
if (_ahead > 0) parts.add('↑$_ahead');
|
||||
if (_behind > 0) parts.add('↓$_behind');
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'switch branch — $_branch',
|
||||
child: GestureDetector(
|
||||
onTap: _openBranchPicker,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideIcon(
|
||||
const GitBranchIcon(),
|
||||
size: 12,
|
||||
color: tokens.statusBarForeground,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ClideText(
|
||||
parts.join(' '),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.statusBarForeground,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BranchPicker extends StatefulWidget {
|
||||
const _BranchPicker({
|
||||
required this.ipc,
|
||||
required this.currentBranch,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final DaemonClient ipc;
|
||||
final String? currentBranch;
|
||||
final void Function([String?]) onDismiss;
|
||||
|
||||
@override
|
||||
State<_BranchPicker> createState() => _BranchPickerState();
|
||||
}
|
||||
|
||||
class _BranchPickerState extends State<_BranchPicker> {
|
||||
List<Map<String, Object?>> _branches = const [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final r = await widget.ipc.request('git.branches');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
if (r.ok) {
|
||||
_branches = [
|
||||
for (final b in (r.data['branches'] as List? ?? const []))
|
||||
(b as Map).cast<String, Object?>(),
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkout(String branch) async {
|
||||
await widget.ipc.request('git.checkout', args: {'branch': branch});
|
||||
widget.onDismiss();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
width: 320,
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.dropdownBackground,
|
||||
border: Border.all(color: tokens.dropdownBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
'Switch branch',
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.globalTextMuted,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _branches.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final b = _branches[i];
|
||||
final name = b['name'] as String? ?? '';
|
||||
final current = b['current'] as bool? ?? false;
|
||||
return _BranchRow(
|
||||
name: name,
|
||||
current: current,
|
||||
onTap: current ? null : () => unawaited(_checkout(name)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BranchRow extends StatefulWidget {
|
||||
const _BranchRow({
|
||||
required this.name,
|
||||
required this.current,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final bool current;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
State<_BranchRow> createState() => _BranchRowState();
|
||||
}
|
||||
|
||||
class _BranchRowState extends State<_BranchRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return MouseRegion(
|
||||
cursor:
|
||||
widget.onTap != null ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
color: _hover ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.current)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideIcon(
|
||||
const CheckIcon(),
|
||||
size: 12,
|
||||
color: tokens.statusSuccess,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
widget.name,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontSize: clideFontMono,
|
||||
color: widget.current
|
||||
? tokens.globalForeground
|
||||
: tokens.listItemForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class GrammarsCoreExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.grammars.core';
|
||||
@override
|
||||
String get title => 'Core grammars';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.editor'];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:clide/builtin/graph/src/graph_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
class GraphExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.graph';
|
||||
@override
|
||||
String get title => 'Graph';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.pql'];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'graph.view',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Graph',
|
||||
titleKey: 'tab.graph',
|
||||
i18nNamespace: id,
|
||||
priority: -80,
|
||||
build: (_) => const GraphView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class GraphView extends StatefulWidget {
|
||||
const GraphView({super.key});
|
||||
|
||||
@override
|
||||
State<GraphView> createState() => _GraphViewState();
|
||||
}
|
||||
|
||||
class _GraphViewState extends State<GraphView> {
|
||||
List<_GraphNode> _nodes = [];
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_loading || _nodes.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('pql.exec', args: {
|
||||
'argv': ['search', '--connections', '--limit', '50'],
|
||||
});
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
_error = resp.error?.message ?? 'failed to load graph';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final raw = resp.data['stdout'] as String? ?? '[]';
|
||||
try {
|
||||
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
|
||||
setState(() {
|
||||
_nodes = list.map(_GraphNode.fromJson).toList();
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'parse error: $e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (_loading) {
|
||||
return const Center(child: ClideText('Loading graph...', muted: true));
|
||||
}
|
||||
if (_error != null) {
|
||||
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
}
|
||||
if (_nodes.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('No linked files found.\nAdd wikilinks to your markdown files.', muted: true),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: _nodes.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final n = _nodes[i];
|
||||
return _NodeRow(node: n, tokens: tokens);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GraphNode {
|
||||
const _GraphNode({required this.path, this.inbound = 0, this.outbound = 0});
|
||||
final String path;
|
||||
final int inbound;
|
||||
final int outbound;
|
||||
|
||||
factory _GraphNode.fromJson(Map<String, dynamic> json) => _GraphNode(
|
||||
path: json['path'] as String? ?? json['relative_path'] as String? ?? '',
|
||||
inbound: (json['inbound_count'] as num?)?.toInt() ?? 0,
|
||||
outbound: (json['outbound_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _NodeRow extends StatefulWidget {
|
||||
const _NodeRow({required this.node, required this.tokens});
|
||||
final _GraphNode node;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
State<_NodeRow> createState() => _NodeRowState();
|
||||
}
|
||||
|
||||
class _NodeRowState extends State<_NodeRow> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Container(
|
||||
color: _hovered ? widget.tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(widget.node.path, fontSize: 13)),
|
||||
ClideText('${widget.node.inbound}in ${widget.node.outbound}out', color: widget.tokens.globalTextMuted, fontSize: 11),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/status_item.dart';
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:clide/builtin/ipc_status/src/status_item.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
class IpcStatusExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.ipc-status';
|
||||
@override
|
||||
String get title => 'Daemon connection';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
|
||||
DaemonClient? _ipc;
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ipc = ctx.ipc;
|
||||
}
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions {
|
||||
final ipc = _ipc;
|
||||
if (ipc == null) return const [];
|
||||
return [
|
||||
StatusItemContribution(
|
||||
id: 'ipc-status.indicator',
|
||||
priority: 100, // right-side
|
||||
listenable: ipc,
|
||||
build: (_) => IpcStatusItem(ipc: ipc),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/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: clideFontCaption, color: color),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class KeybindingsUiExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.keybindings-ui';
|
||||
@override
|
||||
String get title => 'Keybindings UI';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:clide/builtin/markdown/src/markdown_viewer.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
class MarkdownExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.markdown';
|
||||
@override
|
||||
String get title => 'Markdown';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.editor'];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'markdown.viewer',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Viewer',
|
||||
titleKey: 'tab.viewer',
|
||||
i18nNamespace: id,
|
||||
priority: -100,
|
||||
build: (_) => const MarkdownViewer(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class MarkdownViewer extends StatefulWidget {
|
||||
const MarkdownViewer({super.key});
|
||||
|
||||
@override
|
||||
State<MarkdownViewer> createState() => _MarkdownViewerState();
|
||||
}
|
||||
|
||||
class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
String? _path;
|
||||
String? _content;
|
||||
String? _error;
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_eventSub != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_eventSub = kernel.events.on<DaemonEvent>().listen((e) {
|
||||
if (e.kind == 'editor.buffer_activated') {
|
||||
final path = e.data['path'] as String?;
|
||||
if (path != null && path.endsWith('.md')) {
|
||||
_loadFile(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
final activeTab = kernel.panels.activeTabIn(Slots.workspace);
|
||||
if (activeTab == 'editor.active') {
|
||||
unawaited(_loadActiveBuffer());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadActiveBuffer() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('editor.active');
|
||||
if (!mounted || !resp.ok) return;
|
||||
final path = resp.data['path'] as String?;
|
||||
if (path != null && path.endsWith('.md')) {
|
||||
await _loadFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFile(String path) async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('files.read', args: {'path': path});
|
||||
if (!mounted) return;
|
||||
if (resp.ok) {
|
||||
setState(() {
|
||||
_path = path;
|
||||
_content = resp.data['content'] as String? ?? '';
|
||||
_error = null;
|
||||
});
|
||||
} else {
|
||||
setState(() => _error = resp.error?.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null) {
|
||||
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
}
|
||||
if (_content == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Open a .md file to preview it here.', muted: true),
|
||||
);
|
||||
}
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ClidePaneChrome(
|
||||
title: _path ?? 'viewer',
|
||||
subtitle: '${_content!.split('\n').length} lines',
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
_content!,
|
||||
style: TextStyle(color: tokens.globalForeground, fontSize: 13, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export 'src/backlinks_controller.dart';
|
||||
export 'src/backlinks_view.dart';
|
||||
export 'src/extension.dart';
|
||||
export 'src/pql_controller.dart';
|
||||
export 'src/pql_panel_view.dart';
|
||||
@@ -0,0 +1,73 @@
|
||||
/// Tracks the active file and fetches its backlinks + outlinks
|
||||
/// from pql. Subscribes to editor.active-changed to auto-refresh.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class BacklinksController extends ChangeNotifier {
|
||||
BacklinksController({required this.ipc, required this.events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final EventBus events;
|
||||
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
|
||||
String? _activePath;
|
||||
String? get activePath => _activePath;
|
||||
|
||||
List<Map<String, Object?>> _backlinks = const [];
|
||||
List<Map<String, Object?>> get backlinks => _backlinks;
|
||||
|
||||
List<Map<String, Object?>> _outlinks = const [];
|
||||
List<Map<String, Object?>> get outlinks => _outlinks;
|
||||
|
||||
bool _loading = false;
|
||||
bool get loading => _loading;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
Future<void> loadForPath(String path) async {
|
||||
_activePath = path;
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final bl = await ipc.request('pql.backlinks', args: {'path': path});
|
||||
final ol = await ipc.request('pql.outlinks', args: {'path': path});
|
||||
|
||||
_loading = false;
|
||||
_backlinks = bl.ok ? _castList(bl.data['links']) : const [];
|
||||
_outlinks = ol.ok ? _castList(ol.data['links']) : const [];
|
||||
if (!bl.ok && !ol.ok) {
|
||||
_error = bl.error?.message ?? 'backlinks failed';
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onEvent(DaemonEvent e) {
|
||||
if (e.subsystem != 'editor') return;
|
||||
if (e.kind != 'editor.active-changed') return;
|
||||
final path = e.data['path'] as String?;
|
||||
if (path != null && path != _activePath) {
|
||||
unawaited(loadForPath(path));
|
||||
}
|
||||
}
|
||||
|
||||
static List<Map<String, Object?>> _castList(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return [for (final e in raw) (e as Map).cast<String, Object?>()];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/// Context panel showing backlinks and outlinks for the active file.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'backlinks_controller.dart';
|
||||
|
||||
class BacklinksView extends StatefulWidget {
|
||||
const BacklinksView({super.key});
|
||||
|
||||
@override
|
||||
State<BacklinksView> createState() => _BacklinksViewState();
|
||||
}
|
||||
|
||||
class _BacklinksViewState extends State<BacklinksView> {
|
||||
BacklinksController? _controller;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = BacklinksController(ipc: kernel.ipc, events: kernel.events);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (c.activePath == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
'Open a file to see its links.',
|
||||
muted: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Semantics(
|
||||
label: 'backlinks for ${c.activePath}',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.activePath!.split('/').last,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: clideFontCaption,
|
||||
),
|
||||
),
|
||||
if (c.loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
),
|
||||
_LinkGroup(
|
||||
label: 'Backlinks',
|
||||
links: c.backlinks,
|
||||
pathKey: 'source',
|
||||
),
|
||||
_LinkGroup(
|
||||
label: 'Outlinks',
|
||||
links: c.outlinks,
|
||||
pathKey: 'target',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkGroup extends StatelessWidget {
|
||||
const _LinkGroup({
|
||||
required this.label,
|
||||
required this.links,
|
||||
required this.pathKey,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final List<Map<String, Object?>> links;
|
||||
final String pathKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
||||
child: ClideText(
|
||||
'$label (${links.length})',
|
||||
fontSize: clideFontCaption,
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
if (links.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
child: ClideText('None', fontSize: clideFontCaption, muted: true),
|
||||
),
|
||||
for (final link in links)
|
||||
_LinkRow(link: link, pathKey: pathKey),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkRow extends StatefulWidget {
|
||||
const _LinkRow({required this.link, required this.pathKey});
|
||||
final Map<String, Object?> link;
|
||||
final String pathKey;
|
||||
|
||||
@override
|
||||
State<_LinkRow> createState() => _LinkRowState();
|
||||
}
|
||||
|
||||
class _LinkRowState extends State<_LinkRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final target = widget.link[widget.pathKey] as String? ?? '';
|
||||
final alias = widget.link['alias'] as String?;
|
||||
final display = alias ?? target;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
if (!target.startsWith('http')) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(
|
||||
kernel.ipc.request('editor.open', args: {'path': target}));
|
||||
}
|
||||
},
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: target,
|
||||
child: Container(
|
||||
color: _hover ? tokens.sidebarItemHover : null,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
child: ClideText(
|
||||
display,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: target.startsWith('http')
|
||||
? tokens.statusInfo
|
||||
: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:clide/builtin/pql/src/backlinks_view.dart';
|
||||
import 'package:clide/builtin/pql/src/pql_panel_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
class PqlExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.pql';
|
||||
@override
|
||||
String get title => 'pql';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'pql.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'pql',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -60,
|
||||
build: (_) => const PqlPanelView(),
|
||||
),
|
||||
TabContribution(
|
||||
id: 'pql.backlinks',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Links',
|
||||
titleKey: 'tab.links',
|
||||
i18nNamespace: id,
|
||||
priority: -80,
|
||||
build: (_) => const BacklinksView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/// State model for the pql sidebar panel.
|
||||
///
|
||||
/// Manages schema cache, query execution, file listing, and
|
||||
/// decision/ticket views. All data comes through pql.* IPC verbs.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum PqlView { files, query, decisions, tickets }
|
||||
|
||||
class PqlController extends ChangeNotifier {
|
||||
PqlController({required this.ipc});
|
||||
|
||||
final DaemonClient ipc;
|
||||
|
||||
PqlView _view = PqlView.files;
|
||||
PqlView get view => _view;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
bool _loading = false;
|
||||
bool get loading => _loading;
|
||||
|
||||
List<Map<String, Object?>> _results = const [];
|
||||
List<Map<String, Object?>> get results => _results;
|
||||
|
||||
Map<String, Object?> _planStatus = const {};
|
||||
Map<String, Object?> get planStatus => _planStatus;
|
||||
|
||||
void switchView(PqlView v) {
|
||||
if (_view == v) return;
|
||||
_view = v;
|
||||
_results = const [];
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
switch (v) {
|
||||
case PqlView.files:
|
||||
unawaited(loadFiles());
|
||||
case PqlView.decisions:
|
||||
unawaited(loadDecisions());
|
||||
case PqlView.tickets:
|
||||
unawaited(loadTickets());
|
||||
case PqlView.query:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadFiles({String? glob}) async {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('pql.files', args: {
|
||||
if (glob != null) 'glob': glob,
|
||||
'limit': 200,
|
||||
});
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_error = null;
|
||||
_results = _castList(r.data['files']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> runQuery(String dsl) async {
|
||||
if (dsl.trim().isEmpty) return;
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('pql.query', args: {
|
||||
'query': dsl,
|
||||
'limit': 200,
|
||||
});
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
_results = const [];
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_results = _castList(r.data['results']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadDecisions() async {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
await ipc.request('pql.decisions.sync');
|
||||
final r = await ipc.request('pql.decisions.list');
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_error = null;
|
||||
_results = _castList(r.data['decisions']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadTickets() async {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final r = await ipc.request('pql.tickets.board');
|
||||
|
||||
_loading = false;
|
||||
if (!r.ok) {
|
||||
_error = r.error?.message;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_error = null;
|
||||
_results = _castList(r.data['columns']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadPlanStatus() async {
|
||||
final r = await ipc.request('pql.plan.status');
|
||||
if (r.ok) {
|
||||
_planStatus = r.data;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
if (_error == null) return;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static List<Map<String, Object?>> _castList(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return [for (final e in raw) (e as Map).cast<String, Object?>()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
/// Sidebar panel for pql — file listing, DSL query input,
|
||||
/// decisions list, and ticket board views.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'pql_controller.dart';
|
||||
|
||||
class PqlPanelView extends StatefulWidget {
|
||||
const PqlPanelView({super.key});
|
||||
|
||||
@override
|
||||
State<PqlPanelView> createState() => _PqlPanelViewState();
|
||||
}
|
||||
|
||||
class _PqlPanelViewState extends State<PqlPanelView> {
|
||||
PqlController? _controller;
|
||||
final TextEditingController _queryInput = TextEditingController();
|
||||
final FocusNode _queryFocus = FocusNode();
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = PqlController(ipc: kernel.ipc);
|
||||
unawaited(_controller!.loadFiles());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_queryInput.dispose();
|
||||
_queryFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'pql panel',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ViewTabs(controller: c),
|
||||
if (c.view == PqlView.query)
|
||||
_QueryInput(
|
||||
input: _queryInput,
|
||||
focus: _queryFocus,
|
||||
controller: c,
|
||||
),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 4),
|
||||
child: ClideText(
|
||||
c.error!,
|
||||
color: tokens.statusError,
|
||||
fontSize: clideFontCaption,
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
if (c.loading && c.results.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Loading…', muted: true),
|
||||
),
|
||||
if (!c.loading && c.results.isEmpty && c.error == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('No results.', muted: true),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (c.view == PqlView.files)
|
||||
for (final f in c.results) _FileRow(entry: f),
|
||||
if (c.view == PqlView.query)
|
||||
for (final r in c.results) _QueryResultRow(entry: r),
|
||||
if (c.view == PqlView.decisions)
|
||||
for (final d in c.results) _DecisionRow(entry: d),
|
||||
if (c.view == PqlView.tickets)
|
||||
for (final col in c.results) _TicketColumn(column: col),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ViewTabs extends StatelessWidget {
|
||||
const _ViewTabs({required this.controller});
|
||||
final PqlController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final v in PqlView.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
toggled: controller.view == v,
|
||||
label: v.name,
|
||||
child: GestureDetector(
|
||||
onTap: () => controller.switchView(v),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideText(
|
||||
_tabLabel(v),
|
||||
fontSize: clideFontCaption,
|
||||
color: controller.view == v
|
||||
? tokens.globalForeground
|
||||
: tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _tabLabel(PqlView v) => switch (v) {
|
||||
PqlView.files => 'Files',
|
||||
PqlView.query => 'Query',
|
||||
PqlView.decisions => 'Decisions',
|
||||
PqlView.tickets => 'Tickets',
|
||||
};
|
||||
}
|
||||
|
||||
class _QueryInput extends StatelessWidget {
|
||||
const _QueryInput({
|
||||
required this.input,
|
||||
required this.focus,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final TextEditingController input;
|
||||
final FocusNode focus;
|
||||
final PqlController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Semantics(
|
||||
label: 'pql query',
|
||||
textField: true,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.globalBorder),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: EditableText(
|
||||
controller: input,
|
||||
focusNode: focus,
|
||||
style: TextStyle(
|
||||
fontFamily: clideMonoFamily,
|
||||
fontSize: clideFontMono,
|
||||
color: tokens.globalForeground,
|
||||
),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalFocus,
|
||||
maxLines: 1,
|
||||
onSubmitted: (_) =>
|
||||
unawaited(controller.runQuery(input.text)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileRow extends StatefulWidget {
|
||||
const _FileRow({required this.entry});
|
||||
final Map<String, Object?> entry;
|
||||
|
||||
@override
|
||||
State<_FileRow> createState() => _FileRowState();
|
||||
}
|
||||
|
||||
class _FileRowState extends State<_FileRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final path = widget.entry['path'] as String? ?? '';
|
||||
final name = widget.entry['name'] as String? ?? path.split('/').last;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
final kernel = ClideKernel.of(context);
|
||||
unawaited(
|
||||
kernel.ipc.request('editor.open', args: {'path': path}));
|
||||
},
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Open $name',
|
||||
child: Container(
|
||||
color: _hover ? tokens.sidebarItemHover : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||
child: ClideText(
|
||||
path,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QueryResultRow extends StatelessWidget {
|
||||
const _QueryResultRow({required this.entry});
|
||||
final Map<String, Object?> entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final name = entry['name'] as String? ?? entry['path'] as String? ?? '';
|
||||
final values = entry.entries
|
||||
.where((e) => e.key != 'name' && e.key != 'path')
|
||||
.map((e) => '${e.key}: ${e.value}')
|
||||
.join(' · ');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(name, color: tokens.sidebarForeground),
|
||||
if (values.isNotEmpty)
|
||||
ClideText(values, fontSize: clideFontCaption, muted: true, maxLines: 2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DecisionRow extends StatelessWidget {
|
||||
const _DecisionRow({required this.entry});
|
||||
final Map<String, Object?> entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final id = entry['id'] as String? ?? '';
|
||||
final title = entry['title'] as String? ?? '';
|
||||
final type = entry['type'] as String? ?? '';
|
||||
final domain = entry['domain'] as String? ?? '';
|
||||
|
||||
final Color idColor = switch (type) {
|
||||
'confirmed' => tokens.statusSuccess,
|
||||
'question' => tokens.statusWarning,
|
||||
'rejected' => tokens.statusError,
|
||||
_ => tokens.sidebarForeground,
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: ClideText(id, fontSize: clideFontMono, color: idColor,
|
||||
fontFamily: clideMonoFamily),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
ClideText(domain, fontSize: clideFontCaption, muted: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TicketColumn extends StatelessWidget {
|
||||
const _TicketColumn({required this.column});
|
||||
final Map<String, Object?> column;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final status = column['status'] as String? ?? '';
|
||||
final tickets = (column['tickets'] as List?) ?? const [];
|
||||
if (tickets.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 2),
|
||||
child: ClideText(
|
||||
'$status (${tickets.length})',
|
||||
fontSize: clideFontCaption,
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
for (final t in tickets)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: ClideText(
|
||||
(t as Map)['id'] as String? ?? '',
|
||||
fontSize: clideFontMono,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: tokens.statusInfo,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
t['title'] as String? ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/problems_controller.dart';
|
||||
export 'src/problems_view.dart';
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:clide/builtin/problems/src/problems_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
class ProblemsExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.problems';
|
||||
@override
|
||||
String get title => 'Problems';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const ['builtin.pql'];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'problems.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Problems',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -50,
|
||||
build: (_) => const ProblemsView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/// State model for the problems panel.
|
||||
///
|
||||
/// Aggregates diagnostic information from pql.doctor and
|
||||
/// pql.decisions.validate (via pql.decisions.sync which reports
|
||||
/// broken refs). Refreshes on demand.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class Problem {
|
||||
const Problem({required this.source, required this.message, this.hint});
|
||||
final String source;
|
||||
final String message;
|
||||
final String? hint;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'source': source,
|
||||
'message': message,
|
||||
if (hint != null) 'hint': hint,
|
||||
};
|
||||
}
|
||||
|
||||
class ProblemsController extends ChangeNotifier {
|
||||
ProblemsController({required this.ipc});
|
||||
|
||||
final DaemonClient ipc;
|
||||
|
||||
List<Problem> _problems = const [];
|
||||
List<Problem> get problems => _problems;
|
||||
|
||||
bool _loading = false;
|
||||
bool get loading => _loading;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
|
||||
Future<void> refresh() async {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
|
||||
final found = <Problem>[];
|
||||
|
||||
final doctor = await ipc.request('pql.doctor');
|
||||
if (doctor.ok) {
|
||||
final db = (doctor.data['db'] as Map?)?.cast<String, Object?>();
|
||||
if (db != null && db['exists'] == false) {
|
||||
found.add(const Problem(
|
||||
source: 'pql',
|
||||
message: 'pql index database not found',
|
||||
hint: 'Run pql to build the index.',
|
||||
));
|
||||
}
|
||||
final skill = (doctor.data['skill'] as Map?)?.cast<String, Object?>();
|
||||
if (skill != null) {
|
||||
final project =
|
||||
(skill['project'] as Map?)?.cast<String, Object?>();
|
||||
if (project != null) {
|
||||
final state = project['state'] as String?;
|
||||
if (state == 'stale') {
|
||||
found.add(const Problem(
|
||||
source: 'pql',
|
||||
message: 'pql skill is stale — newer version available',
|
||||
hint: 'Run: pql skill install',
|
||||
));
|
||||
} else if (state == 'missing') {
|
||||
found.add(const Problem(
|
||||
source: 'pql',
|
||||
message: 'pql skill not installed',
|
||||
hint: 'Run: pql init --with-skill=yes',
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
found.add(Problem(
|
||||
source: 'pql',
|
||||
message: 'pql doctor failed',
|
||||
hint: doctor.error?.message,
|
||||
));
|
||||
}
|
||||
|
||||
final sync = await ipc.request('pql.decisions.sync');
|
||||
if (sync.ok) {
|
||||
final broken = (sync.data['broken'] as num?)?.toInt() ?? 0;
|
||||
if (broken > 0) {
|
||||
found.add(Problem(
|
||||
source: 'decisions',
|
||||
message: '$broken broken cross-reference(s) in decisions/',
|
||||
hint: 'Run: pql decisions validate',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
_loading = false;
|
||||
_error = null;
|
||||
_problems = found;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/// Sidebar panel showing project diagnostics from pql.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'problems_controller.dart';
|
||||
|
||||
class ProblemsView extends StatefulWidget {
|
||||
const ProblemsView({super.key});
|
||||
|
||||
@override
|
||||
State<ProblemsView> createState() => _ProblemsViewState();
|
||||
}
|
||||
|
||||
class _ProblemsViewState extends State<ProblemsView> {
|
||||
ProblemsController? _controller;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = ProblemsController(ipc: kernel.ipc);
|
||||
unawaited(_controller!.refresh());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'problems panel',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
'Problems (${c.problems.length})',
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'refresh problems',
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(c.refresh()),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideText(
|
||||
'Refresh',
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (c.loading && c.problems.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('Scanning…', muted: true),
|
||||
),
|
||||
if (!c.loading && c.problems.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText(
|
||||
'No problems found.',
|
||||
muted: true,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final p in c.problems) _ProblemRow(problem: p),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProblemRow extends StatelessWidget {
|
||||
const _ProblemRow({required this.problem});
|
||||
final Problem problem;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideText(
|
||||
problem.source,
|
||||
fontSize: clideFontMono,
|
||||
color: tokens.statusWarning,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
problem.message,
|
||||
color: tokens.sidebarForeground,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (problem.hint != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 44, top: 2),
|
||||
child: ClideText(
|
||||
problem.hint!,
|
||||
fontSize: clideFontMono,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class SettingsUiExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.settings-ui';
|
||||
@override
|
||||
String get title => 'Settings UI';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:clide/builtin/terminal/src/terminal_pane.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
/// General-purpose terminal pane. Spawns `$SHELL` under a daemon-owned
|
||||
/// PTY; no Claude-specific behaviour. For the Claude pane with session
|
||||
/// persistence + primary-per-repo semantics see `builtin.claude` (+
|
||||
/// D-041).
|
||||
class TerminalExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.terminal';
|
||||
@override
|
||||
String get title => 'Terminal';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'terminal.pane',
|
||||
slot: Slots.workspace,
|
||||
title: 'Terminal',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: 100,
|
||||
build: (_) => const TerminalPane(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
/// General-purpose terminal pane. Spawns the user's `$SHELL` under the
|
||||
/// daemon's PTY (via `pane.spawn`), feeds the `pane.output` event
|
||||
/// stream into an xterm.dart Terminal, and routes user input back
|
||||
/// through `pane.write`.
|
||||
///
|
||||
/// Deliberately knows nothing about Claude — that's `builtin.claude`'s
|
||||
/// job. The shared widget layer (ClidePtyView, ClidePaneChrome) keeps
|
||||
/// the two extensions visually consistent without coupling them.
|
||||
class TerminalPane extends StatefulWidget {
|
||||
const TerminalPane({super.key});
|
||||
|
||||
@override
|
||||
State<TerminalPane> createState() => _TerminalPaneState();
|
||||
}
|
||||
|
||||
class _TerminalPaneState extends State<TerminalPane> {
|
||||
static const _maxLines = 2000;
|
||||
|
||||
late final Terminal _terminal;
|
||||
StreamSubscription<DaemonEvent>? _eventSub;
|
||||
String? _paneId;
|
||||
String? _error;
|
||||
int _pid = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_terminal = Terminal(maxLines: _maxLines);
|
||||
// Route user input back through IPC once a pane id is known.
|
||||
_terminal.onOutput = _onTerminalOutput;
|
||||
_terminal.onResize = _onTerminalResize;
|
||||
// Spawn asynchronously after the first build so we have access to
|
||||
// the kernel via InheritedWidget lookup.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _spawn());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
final id = _paneId;
|
||||
_paneId = null;
|
||||
if (id != null) {
|
||||
// Fire-and-forget. Daemon-side pane.close is idempotent.
|
||||
unawaited(_kernelIpc()?.request('pane.close', args: {'id': id}));
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _spawn() async {
|
||||
if (!mounted) return;
|
||||
final ipc = _kernelIpc();
|
||||
if (ipc == null || !ipc.isConnected) {
|
||||
setState(() => _error = 'Daemon not connected. Start `clide --daemon`.');
|
||||
return;
|
||||
}
|
||||
|
||||
final shell = Platform.environment['SHELL'] ?? '/bin/bash';
|
||||
final cwd = Directory.current.path;
|
||||
|
||||
final response = await ipc.request('pane.spawn', args: {
|
||||
'argv': [shell, '-l'],
|
||||
'kind': PaneKind.terminal.wire,
|
||||
'cwd': cwd,
|
||||
'cols': _terminal.viewWidth,
|
||||
'rows': _terminal.viewHeight,
|
||||
});
|
||||
if (!mounted) return;
|
||||
if (!response.ok) {
|
||||
setState(() => _error = response.error?.message ?? 'spawn failed');
|
||||
return;
|
||||
}
|
||||
|
||||
_paneId = response.data['id'] as String?;
|
||||
_pid = (response.data['pid'] as num?)?.toInt() ?? 0;
|
||||
_subscribeToPaneEvents();
|
||||
setState(() {}); // refresh subtitle with PID
|
||||
}
|
||||
|
||||
void _subscribeToPaneEvents() {
|
||||
final kernel = _kernel();
|
||||
if (kernel == null) return;
|
||||
_eventSub = kernel.events.on<DaemonEvent>().listen((event) {
|
||||
if (event.subsystem != 'pane') return;
|
||||
if (event.data['id'] != _paneId) return;
|
||||
switch (event.kind) {
|
||||
case 'pane.output':
|
||||
final b64 = event.data['bytes_b64'];
|
||||
if (b64 is String) {
|
||||
final bytes = base64Decode(b64);
|
||||
_terminal.write(utf8.decode(bytes, allowMalformed: true));
|
||||
}
|
||||
case 'pane.exit':
|
||||
setState(() => _error = 'Shell exited.');
|
||||
case 'pane.closed':
|
||||
// Daemon-side gone; reset state so the user can retry.
|
||||
_paneId = null;
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onTerminalOutput(String text) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_kernelIpc()?.request('pane.write', args: {'id': id, 'text': text});
|
||||
}
|
||||
|
||||
void _onTerminalResize(int cols, int rows, int pixelWidth, int pixelHeight) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_kernelIpc()?.request('pane.resize', args: {
|
||||
'id': id,
|
||||
'cols': cols,
|
||||
'rows': rows,
|
||||
});
|
||||
}
|
||||
|
||||
DaemonClient? _kernelIpc() => _kernel()?.ipc;
|
||||
|
||||
KernelServices? _kernel() {
|
||||
try {
|
||||
return ClideKernel.of(context);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtitle = _error != null
|
||||
? _error!
|
||||
: (_paneId == null ? 'spawning shell…' : 'pid $_pid · ${_paneId!}');
|
||||
|
||||
return ClidePaneChrome(
|
||||
title: 'terminal',
|
||||
subtitle: subtitle,
|
||||
child: _error != null
|
||||
? _ErrorBody(message: _error!)
|
||||
: ClidePtyView(
|
||||
terminal: _terminal,
|
||||
label: 'terminal — $subtitle',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBody extends StatelessWidget {
|
||||
const _ErrorBody({required this.message});
|
||||
final String message;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ClideText('Terminal unavailable'),
|
||||
const SizedBox(height: 4),
|
||||
ClideText(message, muted: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cast the Uint8List base64 source to a typed form consumers can
|
||||
/// inspect in tests. Exposed via the library's barrel only because it
|
||||
/// helps the extension test probe the terminal state without pulling
|
||||
/// in the xterm.dart model directly.
|
||||
typedef TerminalBytes = Uint8List;
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/theme_picker/src/picker_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
|
||||
class ThemePickerExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.theme-picker';
|
||||
@override
|
||||
String get title => 'Theme picker';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
|
||||
ClideExtensionContext? _ctx;
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
CommandContribution(
|
||||
id: 'theme.pick',
|
||||
command: 'theme.pick',
|
||||
title: 'Theme: Pick…',
|
||||
defaultBinding: 'ctrl+k',
|
||||
run: _pick,
|
||||
),
|
||||
];
|
||||
|
||||
Future<IpcResponse> _pick(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'theme-picker not activated',
|
||||
),
|
||||
);
|
||||
}
|
||||
final selected = await ctx.dialog.show<String>(
|
||||
(context, dismiss) => ThemePickerView(
|
||||
controller: ctx.theme,
|
||||
onDismiss: dismiss,
|
||||
),
|
||||
);
|
||||
return IpcResponse.ok(id: '', data: {
|
||||
'selected': selected ?? ctx.theme.currentName,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ThemePickerView extends StatefulWidget {
|
||||
const ThemePickerView({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final ThemeController controller;
|
||||
final void Function([String? selected]) onDismiss;
|
||||
|
||||
static const ns = 'builtin.theme-picker';
|
||||
|
||||
@override
|
||||
State<ThemePickerView> createState() => _ThemePickerViewState();
|
||||
}
|
||||
|
||||
class _ThemePickerViewState extends State<ThemePickerView> {
|
||||
String? _hovered;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final themes = widget.controller.available;
|
||||
final currentName = widget.controller.currentName;
|
||||
final i = kernel.i18n;
|
||||
|
||||
return Semantics(
|
||||
container: true,
|
||||
label: i.string('modal.title',
|
||||
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
explicitChildNodes: true,
|
||||
child: ClideSurface(
|
||||
width: 420,
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: tokens.modalSurfaceBorder,
|
||||
padding: const EdgeInsets.all(16),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideText(
|
||||
i.string('modal.title',
|
||||
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideDivider(),
|
||||
const SizedBox(height: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 360),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final t in themes)
|
||||
_ThemeRow(
|
||||
name: t.name,
|
||||
displayName: t.displayName,
|
||||
selected: t.name == currentName,
|
||||
hovered: _hovered == t.name,
|
||||
hint: i.string('row.select.hint',
|
||||
namespace: ThemePickerView.ns,
|
||||
placeholder: 'Activate this theme'),
|
||||
onEnter: () => setState(() => _hovered = t.name),
|
||||
onExit: () => setState(() => _hovered = null),
|
||||
onTap: () {
|
||||
widget.controller.select(t.name);
|
||||
widget.onDismiss(t.name);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(
|
||||
label: i.string('modal.cancel',
|
||||
namespace: ThemePickerView.ns, placeholder: 'Cancel'),
|
||||
semanticHint: i.string('modal.cancel.hint',
|
||||
namespace: ThemePickerView.ns,
|
||||
placeholder:
|
||||
'Close the theme picker without changing the current theme'),
|
||||
onPressed: () => widget.onDismiss(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeRow extends StatelessWidget {
|
||||
const _ThemeRow({
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.selected,
|
||||
required this.hovered,
|
||||
required this.hint,
|
||||
required this.onEnter,
|
||||
required this.onExit,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String displayName;
|
||||
final bool selected;
|
||||
final bool hovered;
|
||||
final String hint;
|
||||
final VoidCallback onEnter;
|
||||
final VoidCallback onExit;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final bg = selected
|
||||
? tokens.listItemSelectedBackground
|
||||
: (hovered
|
||||
? tokens.listItemHoverBackground
|
||||
: tokens.listItemBackground);
|
||||
final fg = selected
|
||||
? tokens.listItemSelectedForeground
|
||||
: tokens.listItemForeground;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: displayName,
|
||||
hint: hint,
|
||||
onTap: onTap,
|
||||
excludeSemantics: true,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => onEnter(),
|
||||
onExit: (_) => onExit(),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
color: bg,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
if (selected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideIcon(const CheckIcon(), size: 12, color: fg),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: ClideText(displayName, color: fg),
|
||||
),
|
||||
ClideText(name, color: tokens.globalTextMuted, fontSize: clideFontCaption),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/picker_view.dart';
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:clide/builtin/tickets/src/tickets_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
class TicketsExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.tickets';
|
||||
@override
|
||||
String get title => 'Tickets';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'tickets.panel',
|
||||
slot: Slots.sidebar,
|
||||
title: 'Tickets',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -10,
|
||||
build: (_) => const TicketsView(),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TicketsView extends StatefulWidget {
|
||||
const TicketsView({super.key});
|
||||
|
||||
@override
|
||||
State<TicketsView> createState() => _TicketsViewState();
|
||||
}
|
||||
|
||||
class _TicketsViewState extends State<TicketsView> {
|
||||
List<_TicketEntry> _tickets = [];
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_loading || _tickets.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request('pql.exec', args: {
|
||||
'argv': ['ticket', 'list'],
|
||||
});
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
_error = resp.error?.message ?? 'failed to load tickets';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final raw = resp.data['stdout'] as String? ?? '[]';
|
||||
try {
|
||||
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
|
||||
setState(() {
|
||||
_tickets = list.map(_TicketEntry.fromJson).toList();
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'parse error: $e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (_loading) {
|
||||
return const Center(child: ClideText('Loading tickets...', muted: true));
|
||||
}
|
||||
if (_error != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(_error!, muted: true),
|
||||
);
|
||||
}
|
||||
if (_tickets.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: ClideText('No tickets found.\nRun `pql ticket new` to create one.', muted: true),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: _tickets.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final t = _tickets[i];
|
||||
return _TicketRow(entry: t, tokens: tokens);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TicketEntry {
|
||||
const _TicketEntry({required this.id, required this.title, this.status, this.priority});
|
||||
final String id;
|
||||
final String title;
|
||||
final String? status;
|
||||
final String? priority;
|
||||
|
||||
factory _TicketEntry.fromJson(Map<String, dynamic> json) => _TicketEntry(
|
||||
id: json['id'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
status: json['status'] as String?,
|
||||
priority: json['priority'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class _TicketRow extends StatefulWidget {
|
||||
const _TicketRow({required this.entry, required this.tokens});
|
||||
final _TicketEntry entry;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
State<_TicketRow> createState() => _TicketRowState();
|
||||
}
|
||||
|
||||
class _TicketRowState extends State<_TicketRow> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusColor = switch (widget.entry.status) {
|
||||
'done' => widget.tokens.statusSuccess,
|
||||
'in_progress' => widget.tokens.statusInfo,
|
||||
'cancelled' => widget.tokens.statusError,
|
||||
_ => widget.tokens.globalTextMuted,
|
||||
};
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Container(
|
||||
color: _hovered ? widget.tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(widget.entry.id, color: widget.tokens.globalTextMuted, fontSize: 12),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: statusColor, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: ClideText(widget.entry.title, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:clide/extension/extension.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
class TodosExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.todos';
|
||||
@override
|
||||
String get title => 'TODOs';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/welcome/src/welcome_view.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
class WelcomeExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.welcome';
|
||||
@override
|
||||
String get title => 'Welcome';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
id: 'welcome.view',
|
||||
slot: Slots.workspace,
|
||||
title: 'Welcome',
|
||||
titleKey: 'tab.title',
|
||||
i18nNamespace: id,
|
||||
priority: -100, // anchor at the far left of the workspace tabs
|
||||
build: (_) => const WelcomeView(),
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'workspace.open-project',
|
||||
command: 'workspace.open-project',
|
||||
title: 'Workspace: Open project…',
|
||||
run: (_) async => IpcResponse.ok(
|
||||
id: '',
|
||||
data: const {'note': 'project picker lands in a later tier'},
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class WelcomeView extends StatelessWidget {
|
||||
const WelcomeView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Header(tokens: tokens),
|
||||
const SizedBox(height: 56),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _StartColumn(tokens: tokens, kernel: kernel)),
|
||||
const SizedBox(width: 56),
|
||||
Expanded(child: _RecentColumn(tokens: tokens, kernel: kernel)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 64,
|
||||
right: 64,
|
||||
bottom: 24,
|
||||
child: _StatusLine(tokens: tokens, kernel: kernel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({required this.tokens});
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset('assets/logo/clide-logo-192.png', width: 72, height: 72),
|
||||
const SizedBox(width: 24),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('clide', fontSize: 52, fontWeight: FontWeight.w300, color: tokens.globalForeground),
|
||||
ClideText('Flutter desktop IDE for Claude Code', muted: true, fontSize: 16),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StartColumn extends StatelessWidget {
|
||||
const _StartColumn({required this.tokens, required this.kernel});
|
||||
final SurfaceTokens tokens;
|
||||
final KernelServices kernel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('START', fontSize: 12, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 20),
|
||||
_ActionRow(
|
||||
icon: PhosphorIcons.folder,
|
||||
label: 'Open folder…',
|
||||
shortcut: '⌘O',
|
||||
tokens: tokens,
|
||||
onTap: () => _openFolder(context),
|
||||
),
|
||||
_ActionRow(
|
||||
icon: PhosphorIcons.gitBranch,
|
||||
label: 'Clone from git…',
|
||||
shortcut: '⌘G',
|
||||
tokens: tokens,
|
||||
onTap: () {},
|
||||
),
|
||||
_ActionRow(
|
||||
icon: PhosphorIcons.chatCircle,
|
||||
label: 'Start a Claude session',
|
||||
shortcut: '⌘C',
|
||||
tokens: tokens,
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _openFolder(BuildContext context) {
|
||||
kernel.dialog.show<String>((ctx, dismiss) {
|
||||
return _OpenProjectDialog(
|
||||
onOpen: (path) async {
|
||||
final ok = await kernel.project.open(path);
|
||||
if (ok) {
|
||||
kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
dismiss(path);
|
||||
}
|
||||
},
|
||||
onCancel: () => dismiss(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionRow extends StatefulWidget {
|
||||
const _ActionRow({required this.icon, required this.label, this.shortcut, required this.tokens, required this.onTap});
|
||||
final ClideIconPainter icon;
|
||||
final String label;
|
||||
final String? shortcut;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_ActionRow> createState() => _ActionRowState();
|
||||
}
|
||||
|
||||
class _ActionRowState extends State<_ActionRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _hover ? widget.tokens.listItemHoverBackground : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(widget.icon, size: 18, color: widget.tokens.globalTextMuted),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: ClideText(widget.label, fontSize: 15, color: widget.tokens.globalForeground)),
|
||||
if (widget.shortcut != null)
|
||||
ClideText(widget.shortcut!, fontSize: 13, color: widget.tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentColumn extends StatelessWidget {
|
||||
const _RecentColumn({required this.tokens, required this.kernel});
|
||||
final SurfaceTokens tokens;
|
||||
final KernelServices kernel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.project,
|
||||
builder: (ctx, _) {
|
||||
final recents = kernel.project.recents;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('RECENT', fontSize: 12, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 20),
|
||||
if (recents.isEmpty)
|
||||
const ClideText('No recent projects.', muted: true, fontSize: 14)
|
||||
else
|
||||
for (final r in recents)
|
||||
_RecentRow(project: r, tokens: tokens, onTap: () => _openRecent(r.path)),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _openRecent(String path) {
|
||||
kernel.project.open(path).then((ok) {
|
||||
if (ok) kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentRow extends StatefulWidget {
|
||||
const _RecentRow({required this.project, required this.tokens, required this.onTap});
|
||||
final RecentProject project;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_RecentRow> createState() => _RecentRowState();
|
||||
}
|
||||
|
||||
class _RecentRowState extends State<_RecentRow> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _hover ? widget.tokens.listItemHoverBackground : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(widget.project.name, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
ClideText(widget.project.relativePath, muted: true, fontSize: 13, fontFamily: clideMonoFamily),
|
||||
if (widget.project.branch != null) ...[
|
||||
ClideText(' · ', muted: true, fontSize: 13),
|
||||
ClideIcon(PhosphorIcons.gitBranch, size: 11, color: widget.tokens.globalTextMuted),
|
||||
const SizedBox(width: 3),
|
||||
ClideText(widget.project.branch!, muted: true, fontSize: 13, fontFamily: clideMonoFamily),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ClideText(widget.project.timeAgo, muted: true, fontSize: 13),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusLine extends StatelessWidget {
|
||||
const _StatusLine({required this.tokens, required this.kernel});
|
||||
final SurfaceTokens tokens;
|
||||
final KernelServices kernel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.ipc,
|
||||
builder: (ctx, _) {
|
||||
final connected = kernel.ipc.isConnected;
|
||||
final themeName = kernel.theme.currentName;
|
||||
return Row(
|
||||
children: [
|
||||
ClideText('clide 2.0.0-dev', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
ClideText(
|
||||
connected ? 'daemon connected' : 'daemon disconnected',
|
||||
fontSize: 12,
|
||||
fontFamily: clideMonoFamily,
|
||||
color: connected ? tokens.statusSuccess : tokens.statusError,
|
||||
),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
ClideText('theme: ', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
ClideText(themeName, fontSize: 12, fontFamily: clideMonoFamily, color: tokens.globalFocus),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenProjectDialog extends StatefulWidget {
|
||||
const _OpenProjectDialog({required this.onOpen, required this.onCancel});
|
||||
final Future<void> Function(String path) onOpen;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@override
|
||||
State<_OpenProjectDialog> createState() => _OpenProjectDialogState();
|
||||
}
|
||||
|
||||
class _OpenProjectDialogState extends State<_OpenProjectDialog> {
|
||||
final _controller = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
String? _error;
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final path = _controller.text.trim();
|
||||
if (path.isEmpty) return;
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
await widget.onOpen(path);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = 'Not a git repository');
|
||||
}
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
width: 420,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: Border.all(color: tokens.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ClideText('Open project', fontSize: 16, fontWeight: FontWeight.w600),
|
||||
const SizedBox(height: 4),
|
||||
const ClideText('Enter the path to a git repository.', muted: true, fontSize: 13),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: tokens.globalBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(color: tokens.globalForeground, fontSize: 14, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback),
|
||||
cursorColor: tokens.globalForeground,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: (_) => unawaited(_submit()),
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
ClideText(_error!, color: tokens.statusError, fontSize: 12),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClideButton(label: 'Cancel', onPressed: widget.onCancel),
|
||||
const SizedBox(width: 8),
|
||||
ClideButton(label: _loading ? 'Opening…' : 'Open', onPressed: _loading ? null : _submit),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,11 @@
|
||||
/// clide extension contract.
|
||||
///
|
||||
/// ClideExtension = shipping unit. ContributionPoint = atom contributed
|
||||
/// into a kernel slot or service. One manifest may contribute N atoms
|
||||
/// across multiple slots.
|
||||
library;
|
||||
|
||||
export 'src/contribution.dart';
|
||||
export 'src/extension.dart';
|
||||
export 'src/host.dart';
|
||||
export 'src/manifest.dart';
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// One atom contributed by a [ClideExtension]. Extensions declare N of
|
||||
/// these in a manifest; the kernel and slot hosts render them.
|
||||
///
|
||||
/// Adding a new contribution type: add a case to this sealed hierarchy,
|
||||
/// extend the host dispatch in the default-layout extension, and bump
|
||||
/// the extension manifest schema version.
|
||||
sealed class ContributionPoint {
|
||||
const ContributionPoint({required this.id});
|
||||
|
||||
/// Stable id for this contribution, unique within its extension.
|
||||
final String id;
|
||||
|
||||
/// The slot this contribution targets, or `null` for non-slot
|
||||
/// contributions (commands, events, grammars).
|
||||
SlotId? get slot => null;
|
||||
}
|
||||
|
||||
/// A tab in a slot that hosts tabs (sidebar / workspace / context).
|
||||
class TabContribution extends ContributionPoint {
|
||||
const TabContribution({
|
||||
required super.id,
|
||||
required this.slot,
|
||||
required this.title,
|
||||
required this.build,
|
||||
this.icon,
|
||||
this.priority = 0,
|
||||
this.fileGlobs = const [],
|
||||
this.listenable,
|
||||
this.titleKey,
|
||||
this.i18nNamespace,
|
||||
});
|
||||
|
||||
@override
|
||||
final SlotId slot;
|
||||
final String title;
|
||||
final WidgetBuilder build;
|
||||
final Object? icon;
|
||||
final int priority;
|
||||
final List<String> fileGlobs;
|
||||
final Listenable? listenable;
|
||||
|
||||
/// When set, the slot host resolves the display title via
|
||||
/// `i18n.string(titleKey, namespace: i18nNamespace, placeholder: title)`.
|
||||
/// [title] stays as the English fallback (also used in tests/logs).
|
||||
final String? titleKey;
|
||||
|
||||
/// The i18n namespace to look up [titleKey] in. Extensions usually
|
||||
/// pass their own `id`. Required when [titleKey] is set.
|
||||
final String? i18nNamespace;
|
||||
}
|
||||
|
||||
/// A status-bar item. Order is determined by [priority] within each
|
||||
/// alignment group; negative priorities float left, positive right.
|
||||
class StatusItemContribution extends ContributionPoint {
|
||||
const StatusItemContribution({
|
||||
required super.id,
|
||||
required this.build,
|
||||
this.priority = 0,
|
||||
this.listenable,
|
||||
});
|
||||
|
||||
@override
|
||||
SlotId get slot => Slots.statusbar;
|
||||
final WidgetBuilder build;
|
||||
final int priority;
|
||||
final Listenable? listenable;
|
||||
}
|
||||
|
||||
/// A button in the main toolbar.
|
||||
class ToolbarButtonContribution extends ContributionPoint {
|
||||
const ToolbarButtonContribution({
|
||||
required super.id,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.icon,
|
||||
this.tooltip,
|
||||
this.priority = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
SlotId get slot => Slots.toolbar;
|
||||
final String label;
|
||||
final Object? icon;
|
||||
final String? tooltip;
|
||||
final int priority;
|
||||
final VoidCallback onPressed;
|
||||
}
|
||||
|
||||
/// A command extensions register with [CommandRegistry]. Surfaced by the
|
||||
/// command palette, the keybinding resolver, and `clide` CLI subcommands.
|
||||
class CommandContribution extends ContributionPoint {
|
||||
const CommandContribution({
|
||||
required super.id,
|
||||
required this.command,
|
||||
required this.run,
|
||||
this.title,
|
||||
this.defaultBinding,
|
||||
});
|
||||
|
||||
final String command; // e.g. "git.commit"
|
||||
final String? title; // "Git: Commit staged"
|
||||
final String? defaultBinding; // e.g. "ctrl+shift+g"
|
||||
final Future<IpcResponse> Function(List<String> args) run;
|
||||
}
|
||||
|
||||
/// Registers an item in the OS tray / menu-bar.
|
||||
class TrayItemContribution extends ContributionPoint {
|
||||
const TrayItemContribution({
|
||||
required super.id,
|
||||
required this.label,
|
||||
required this.onSelected,
|
||||
this.priority = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
SlotId get slot => Slots.tray;
|
||||
final String label;
|
||||
final int priority;
|
||||
final VoidCallback onSelected;
|
||||
}
|
||||
|
||||
/// A named layout arrangement. One "classic" preset ships with
|
||||
/// `builtin.default-layout`; other presets can be contributed.
|
||||
class LayoutPresetContribution extends ContributionPoint {
|
||||
const LayoutPresetContribution({
|
||||
required super.id,
|
||||
required this.displayName,
|
||||
required this.slots,
|
||||
});
|
||||
|
||||
final String displayName;
|
||||
final List<LayoutSlot> slots;
|
||||
}
|
||||
|
||||
/// One slot in a [LayoutPresetContribution]. Describes where the slot
|
||||
/// appears and its initial size/visibility.
|
||||
class LayoutSlot {
|
||||
const LayoutSlot({
|
||||
required this.slot,
|
||||
required this.position,
|
||||
this.defaultSize,
|
||||
this.minSize,
|
||||
this.maxSize,
|
||||
this.visible = true,
|
||||
});
|
||||
|
||||
final SlotId slot;
|
||||
final SlotPosition position;
|
||||
final double? defaultSize;
|
||||
final double? minSize;
|
||||
final double? maxSize;
|
||||
final bool visible;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/src/clipboard.dart';
|
||||
import 'package:clide/kernel/src/commands/palette.dart';
|
||||
import 'package:clide/kernel/src/commands/registry.dart';
|
||||
import 'package:clide/kernel/src/dialog.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/files.dart';
|
||||
import 'package:clide/kernel/src/focus.dart';
|
||||
import 'package:clide/kernel/src/i18n/i18n.dart';
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/kernel/src/net.dart';
|
||||
import 'package:clide/kernel/src/notify.dart';
|
||||
import 'package:clide/kernel/src/os.dart';
|
||||
import 'package:clide/kernel/src/panels/arrangement.dart';
|
||||
import 'package:clide/kernel/src/panels/registry.dart';
|
||||
import 'package:clide/kernel/src/project.dart';
|
||||
import 'package:clide/kernel/src/secrets.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/tray.dart';
|
||||
|
||||
/// One shipping unit. Built-in extensions compile in as Dart subclasses;
|
||||
/// third-party extensions run as Lua scripts wrapped by a `LuaExtension`
|
||||
/// adapter (Tier 6).
|
||||
abstract class ClideExtension {
|
||||
String get id;
|
||||
String get title;
|
||||
String get version;
|
||||
|
||||
/// IDs of other extensions that must be activated before this one.
|
||||
/// Missing deps → this extension is skipped at load with a warning.
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
/// The atoms this extension contributes.
|
||||
List<ContributionPoint> get contributions;
|
||||
|
||||
/// Called once after dependencies activate.
|
||||
Future<void> activate(ClideExtensionContext ctx) async {}
|
||||
|
||||
/// Called when the extension is disabled or the app shuts down.
|
||||
Future<void> deactivate() async {}
|
||||
}
|
||||
|
||||
/// Handed to every [ClideExtension.activate]. Lists every kernel service
|
||||
/// an extension may reach. The extension manager constructs a concrete
|
||||
/// instance with refs; tests can pass fakes.
|
||||
///
|
||||
/// The interface deliberately lists services individually rather than
|
||||
/// exposing a `KernelServices` aggregate — doing so would create an
|
||||
/// import cycle between the kernel facade and this file.
|
||||
abstract class ClideExtensionContext {
|
||||
String get id;
|
||||
|
||||
Logger get log;
|
||||
EventBus get events;
|
||||
SettingsStore get settings;
|
||||
ThemeController get theme;
|
||||
I18n get i18n;
|
||||
PanelRegistry get panels;
|
||||
LayoutArrangement get arrangement;
|
||||
CommandRegistry get commands;
|
||||
PaletteController get palette;
|
||||
ClideClipboard get clipboard;
|
||||
FileServices get files;
|
||||
Notifications get notify;
|
||||
DialogRouter get dialog;
|
||||
TrayRegistry get tray;
|
||||
SecretsVault get secrets;
|
||||
OsBridge get os;
|
||||
NetworkStatus get net;
|
||||
FocusTracker get focus;
|
||||
ProjectManager get project;
|
||||
DaemonClient get ipc;
|
||||
}
|
||||
|
||||
/// Sugar for i18n lookups scoped to this extension's namespace.
|
||||
extension ClideExtensionContextI18n on ClideExtensionContext {
|
||||
/// `ctx.t('welcome.title', placeholder: 'clide')` →
|
||||
/// `i18n.string('welcome.title', namespace: id, placeholder: 'clide')`.
|
||||
String t(String key, {String? placeholder}) =>
|
||||
i18n.string(key, namespace: id, placeholder: placeholder);
|
||||
|
||||
/// [t] with interpolation replacers.
|
||||
String tr(
|
||||
String key, {
|
||||
String? placeholder,
|
||||
List<I18nReplacer> replacers = const [],
|
||||
}) =>
|
||||
i18n.interpolated(
|
||||
key,
|
||||
namespace: id,
|
||||
placeholder: placeholder,
|
||||
replacers: replacers,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/extension/src/manifest.dart';
|
||||
|
||||
/// Scans the third-party extensions root for `manifest.yaml` files.
|
||||
///
|
||||
/// Built-ins are registered by the app at boot; this scanner handles
|
||||
/// installed third-party extensions. Tier 0 returns an empty list
|
||||
/// until the Lua adapter lands — every call is safe to make anyway.
|
||||
class ExtensionScanner {
|
||||
const ExtensionScanner();
|
||||
|
||||
/// Typical install root: `~/.clide/extensions/<id>/manifest.yaml`.
|
||||
/// Override for tests.
|
||||
Directory defaultRoot() {
|
||||
final home = Platform.environment['HOME'] ?? '/tmp';
|
||||
return Directory('$home/.clide/extensions');
|
||||
}
|
||||
|
||||
Future<List<ExtensionManifest>> discover({Directory? root}) async {
|
||||
final dir = root ?? defaultRoot();
|
||||
if (!await dir.exists()) return const [];
|
||||
final out = <ExtensionManifest>[];
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is! Directory) continue;
|
||||
final m = File('${entity.path}/manifest.yaml');
|
||||
if (!await m.exists()) continue;
|
||||
try {
|
||||
out.add(await ExtensionManifest.fromFile(m));
|
||||
} on FormatException catch (_) {
|
||||
// skip malformed manifests; the extensions-ui will surface them
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
/// A parsed third-party extension manifest.
|
||||
///
|
||||
/// Built-in extensions don't need a manifest file — they compile in as
|
||||
/// Dart subclasses of [ClideExtension]. Third-party extensions ship a
|
||||
/// `manifest.yaml` under `~/.clide/extensions/<id>/` alongside their
|
||||
/// Lua entrypoint; this class parses and validates that file.
|
||||
class ExtensionManifest {
|
||||
const ExtensionManifest({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.version,
|
||||
required this.dependsOn,
|
||||
required this.entry,
|
||||
required this.schemaVersion,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String version;
|
||||
final List<String> dependsOn;
|
||||
final String entry; // relative path to lua entrypoint
|
||||
final int schemaVersion;
|
||||
|
||||
factory ExtensionManifest.fromYamlString(String text) {
|
||||
final doc = loadYaml(text);
|
||||
if (doc is! Map) {
|
||||
throw const FormatException('manifest root is not a map');
|
||||
}
|
||||
final id = doc['id'];
|
||||
if (id is! String || id.isEmpty) {
|
||||
throw const FormatException('manifest missing `id`');
|
||||
}
|
||||
final title = (doc['title'] as String?) ?? id;
|
||||
final version = (doc['version'] as String?) ?? '0.0.0';
|
||||
final entry = (doc['entry'] as String?) ?? 'extension.lua';
|
||||
final schemaVersion = (doc['schema_version'] as int?) ?? 1;
|
||||
final depsYaml = doc['depends_on'];
|
||||
final deps = <String>[];
|
||||
if (depsYaml is YamlList) {
|
||||
for (final d in depsYaml) {
|
||||
if (d is String) deps.add(d);
|
||||
}
|
||||
}
|
||||
return ExtensionManifest(
|
||||
id: id,
|
||||
title: title,
|
||||
version: version,
|
||||
dependsOn: deps,
|
||||
entry: entry,
|
||||
schemaVersion: schemaVersion,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<ExtensionManifest> fromFile(File f) async =>
|
||||
ExtensionManifest.fromYamlString(await f.readAsString());
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/// clide kernel — registries, stores, and shared singleton services
|
||||
/// consumed by every extension.
|
||||
///
|
||||
/// Admission rule: the kernel owns anything whose second concurrent user
|
||||
/// would create incoherent state or divergent UX. External-interfacing
|
||||
/// work generally belongs to extensions (git, pql, Linear);
|
||||
/// external-interfacing *singletons* (OS clipboard, tray, keychain)
|
||||
/// belong here.
|
||||
///
|
||||
/// Exports are added as each subsystem lands. See plan:
|
||||
/// /home/jeroenschweitzer/.claude/plans/i-want-to-discuss-cozy-zebra.md
|
||||
library;
|
||||
|
||||
export 'src/events/bus.dart';
|
||||
export 'src/events/types.dart';
|
||||
export 'src/ipc/client.dart';
|
||||
export 'src/log.dart';
|
||||
export 'src/settings.dart';
|
||||
export 'src/facade.dart';
|
||||
export 'src/clipboard.dart';
|
||||
export 'src/commands/keybindings.dart';
|
||||
export 'src/commands/palette.dart';
|
||||
export 'src/commands/registry.dart';
|
||||
export 'src/dialog.dart';
|
||||
export 'src/extensions_manager.dart';
|
||||
export 'src/files.dart';
|
||||
export 'src/focus.dart';
|
||||
export 'src/i18n/catalog_loader.dart';
|
||||
export 'src/i18n/fallback_chain.dart';
|
||||
export 'src/i18n/i18n.dart';
|
||||
export 'src/net.dart';
|
||||
export 'src/notify.dart';
|
||||
export 'src/os.dart';
|
||||
export 'src/panels/arrangement.dart';
|
||||
export 'src/project.dart';
|
||||
export 'src/secrets.dart';
|
||||
export 'src/tray.dart';
|
||||
export 'src/panels/drag_resize.dart';
|
||||
export 'src/panels/layout_preset.dart';
|
||||
export 'src/panels/registry.dart';
|
||||
export 'src/panels/slot_id.dart';
|
||||
export 'src/theme/contrast.dart';
|
||||
export 'src/theme/controller.dart';
|
||||
export 'src/theme/loader.dart';
|
||||
export 'src/theme/palette.dart';
|
||||
export 'src/theme/resolver.dart';
|
||||
export 'src/theme/semantic.dart';
|
||||
export 'src/theme/tokens.dart';
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' as flutter_services;
|
||||
|
||||
/// Typed, per-content-kind clipboard with a plaintext fallback.
|
||||
///
|
||||
/// Extensions write typed values (`write<GitHunk>(hunk)`) and read in
|
||||
/// the same type (`readAs<GitHunk>()`). Anything with a `toPlain`
|
||||
/// callback also syncs to the OS clipboard so external apps see
|
||||
/// reasonable text. The history ring keeps the last [historyLimit]
|
||||
/// entries per type for quick recall.
|
||||
class ClideClipboard {
|
||||
ClideClipboard({this.historyLimit = 16});
|
||||
|
||||
final int historyLimit;
|
||||
final Map<Type, List<Object>> _history = {};
|
||||
|
||||
Future<void> write<T extends Object>(
|
||||
T value, {
|
||||
String Function(T)? toPlain,
|
||||
}) async {
|
||||
final bucket = _history.putIfAbsent(T, () => <Object>[]);
|
||||
bucket.insert(0, value);
|
||||
if (bucket.length > historyLimit) bucket.removeLast();
|
||||
if (toPlain != null) {
|
||||
await flutter_services.Clipboard.setData(
|
||||
flutter_services.ClipboardData(text: toPlain(value)));
|
||||
}
|
||||
}
|
||||
|
||||
T? readAs<T extends Object>() {
|
||||
final bucket = _history[T];
|
||||
if (bucket == null || bucket.isEmpty) return null;
|
||||
return bucket.first as T;
|
||||
}
|
||||
|
||||
List<T> historyOf<T extends Object>() {
|
||||
final bucket = _history[T];
|
||||
if (bucket == null) return const [];
|
||||
return bucket.cast<T>().toList(growable: false);
|
||||
}
|
||||
|
||||
Future<String?> readPlain() async {
|
||||
final d = await flutter_services.Clipboard.getData('text/plain');
|
||||
return d?.text;
|
||||
}
|
||||
|
||||
Future<void> writePlain(String text) async {
|
||||
await flutter_services.Clipboard.setData(
|
||||
flutter_services.ClipboardData(text: text));
|
||||
final bucket = _history.putIfAbsent(String, () => <Object>[]);
|
||||
bucket.insert(0, text);
|
||||
if (bucket.length > historyLimit) bucket.removeLast();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void clear() => _history.clear();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Key combo: modifiers + primary key. Canonicalized on construction
|
||||
/// (modifiers sorted, lowercased) so equality works for lookup keys.
|
||||
@immutable
|
||||
class Keybinding {
|
||||
Keybinding({required Set<String> modifiers, required String key})
|
||||
: modifiers = _canonModifiers(modifiers),
|
||||
key = key.toLowerCase();
|
||||
|
||||
final List<String> modifiers;
|
||||
final String key;
|
||||
|
||||
static List<String> _canonModifiers(Set<String> m) {
|
||||
final normalized = m.map((s) => s.toLowerCase()).toSet().toList()..sort();
|
||||
return List.unmodifiable(normalized);
|
||||
}
|
||||
|
||||
/// Parse "ctrl+shift+g", "cmd+k", "alt+f4".
|
||||
static Keybinding parse(String spec) {
|
||||
if (spec.trim().isEmpty) {
|
||||
throw ArgumentError('empty keybinding');
|
||||
}
|
||||
final parts = spec.split('+').map((s) => s.trim()).toList();
|
||||
final key = parts.removeLast();
|
||||
if (key.isEmpty) {
|
||||
throw ArgumentError('keybinding is missing a key: "$spec"');
|
||||
}
|
||||
return Keybinding(modifiers: parts.toSet(), key: key);
|
||||
}
|
||||
|
||||
String get canonical {
|
||||
if (modifiers.isEmpty) return key;
|
||||
return '${modifiers.join('+')}+$key';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Keybinding &&
|
||||
other.key == key &&
|
||||
listEquals(other.modifiers, modifiers);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
|
||||
|
||||
@override
|
||||
String toString() => 'Keybinding($canonical)';
|
||||
}
|
||||
|
||||
class KeybindingResolver {
|
||||
final Map<Keybinding, String> _bindings = {};
|
||||
|
||||
void bind(Keybinding b, String commandId) {
|
||||
_bindings[b] = commandId;
|
||||
}
|
||||
|
||||
void unbind(Keybinding b) {
|
||||
_bindings.remove(b);
|
||||
}
|
||||
|
||||
String? commandFor(Keybinding b) => _bindings[b];
|
||||
|
||||
Iterable<MapEntry<Keybinding, String>> get entries => _bindings.entries;
|
||||
|
||||
/// Map a Flutter [KeyEvent] to a [Keybinding] suitable for lookup.
|
||||
static Keybinding? fromKeyEvent(KeyEvent event, HardwareKeyboard keyboard) {
|
||||
if (event is! KeyDownEvent) return null;
|
||||
final label = event.logicalKey.keyLabel;
|
||||
if (label.isEmpty) return null;
|
||||
final mods = <String>{};
|
||||
if (keyboard.isControlPressed) mods.add('ctrl');
|
||||
if (keyboard.isShiftPressed) mods.add('shift');
|
||||
if (keyboard.isAltPressed) mods.add('alt');
|
||||
if (keyboard.isMetaPressed) mods.add('cmd');
|
||||
return Keybinding(modifiers: mods, key: label);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/src/commands/registry.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class PaletteController extends ChangeNotifier {
|
||||
PaletteController(this._registry);
|
||||
|
||||
final CommandRegistry _registry;
|
||||
|
||||
bool _open = false;
|
||||
String _filter = '';
|
||||
|
||||
bool get isOpen => _open;
|
||||
String get filter => _filter;
|
||||
|
||||
void open() {
|
||||
if (_open) return;
|
||||
_open = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (!_open) return;
|
||||
_open = false;
|
||||
_filter = '';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggle() => _open ? close() : open();
|
||||
|
||||
void setFilter(String f) {
|
||||
if (_filter == f) return;
|
||||
_filter = f;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<CommandContribution> filtered() {
|
||||
if (_filter.isEmpty) return _registry.all.toList();
|
||||
final q = _filter.toLowerCase();
|
||||
return _registry.all.where((c) {
|
||||
final haystack = (c.title ?? c.command).toLowerCase();
|
||||
return haystack.contains(q);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> invoke(String command) async {
|
||||
close();
|
||||
await _registry.execute(command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class CommandRegistry extends ChangeNotifier {
|
||||
final Map<String, CommandContribution> _byCommand = {};
|
||||
|
||||
void register(CommandContribution cmd) {
|
||||
_byCommand[cmd.command] = cmd;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void unregister(String command) {
|
||||
if (_byCommand.remove(command) != null) notifyListeners();
|
||||
}
|
||||
|
||||
Iterable<CommandContribution> get all => _byCommand.values;
|
||||
CommandContribution? get(String command) => _byCommand[command];
|
||||
|
||||
Future<IpcResponse> execute(
|
||||
String command, {
|
||||
List<String> args = const [],
|
||||
}) async {
|
||||
final c = _byCommand[command];
|
||||
if (c == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: 'no such command: $command',
|
||||
),
|
||||
);
|
||||
}
|
||||
return c.run(args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef DialogBuilder<T> = Widget Function(
|
||||
BuildContext context,
|
||||
void Function([T? result]) dismiss,
|
||||
);
|
||||
|
||||
/// Single-at-a-time modal router.
|
||||
///
|
||||
/// Extensions call [show] with a builder; the root widget (installed by
|
||||
/// [DialogHost]) listens and renders the current dialog over a dimmed
|
||||
/// backdrop. Only one dialog is active at a time — a second [show] call
|
||||
/// while one is open awaits until the first dismisses.
|
||||
class DialogRouter extends ChangeNotifier {
|
||||
DialogBuilder<Object?>? _current;
|
||||
Completer<Object?>? _completer;
|
||||
final List<_Queued> _queue = [];
|
||||
|
||||
DialogBuilder<Object?>? get current => _current;
|
||||
bool get isOpen => _current != null;
|
||||
|
||||
Future<T?> show<T extends Object>(DialogBuilder<T> builder) {
|
||||
final completer = Completer<T?>();
|
||||
final wrapped = _wrap<T>(builder);
|
||||
if (_current == null) {
|
||||
_current = wrapped;
|
||||
_completer = Completer<Object?>();
|
||||
// forward our generic completer to the typed one
|
||||
_completer!.future.then((v) {
|
||||
if (!completer.isCompleted) completer.complete(v as T?);
|
||||
});
|
||||
notifyListeners();
|
||||
} else {
|
||||
_queue.add(_Queued(wrapped, completer));
|
||||
}
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void dismiss([Object? result]) {
|
||||
if (_current == null) return;
|
||||
final c = _completer;
|
||||
_current = null;
|
||||
_completer = null;
|
||||
if (c != null && !c.isCompleted) c.complete(result);
|
||||
if (_queue.isNotEmpty) {
|
||||
final next = _queue.removeAt(0);
|
||||
_current = next.builder;
|
||||
_completer = Completer<Object?>();
|
||||
_completer!.future.then((v) {
|
||||
if (!next.completer.isCompleted) next.completer.complete(v);
|
||||
});
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
DialogBuilder<Object?> _wrap<T>(DialogBuilder<T> builder) {
|
||||
return (ctx, dismiss) => builder(ctx, ([T? v]) => dismiss(v));
|
||||
}
|
||||
}
|
||||
|
||||
class _Queued {
|
||||
_Queued(this.builder, this.completer);
|
||||
final DialogBuilder<Object?> builder;
|
||||
// ignore: strict_raw_type
|
||||
final Completer completer;
|
||||
}
|
||||
|
||||
/// Hosts the current dialog from [DialogRouter]. Place high in the tree
|
||||
/// (inside the WidgetsApp) so dialogs overlay every other surface.
|
||||
class DialogHost extends StatelessWidget {
|
||||
const DialogHost({
|
||||
super.key,
|
||||
required this.router,
|
||||
required this.child,
|
||||
this.backdropColor = const Color(0xC0000000),
|
||||
});
|
||||
|
||||
final DialogRouter router;
|
||||
final Widget child;
|
||||
final Color backdropColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
child,
|
||||
ListenableBuilder(
|
||||
listenable: router,
|
||||
builder: (ctx, _) {
|
||||
final b = router.current;
|
||||
if (b == null) return const SizedBox.shrink();
|
||||
return Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: backdropColor,
|
||||
child: Center(
|
||||
child: b(ctx, router.dismiss),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
|
||||
class EventBus {
|
||||
EventBus();
|
||||
|
||||
final StreamController<ClideEventEnvelope> _controller =
|
||||
StreamController<ClideEventEnvelope>.broadcast();
|
||||
|
||||
Stream<ClideEventEnvelope> get stream => _controller.stream;
|
||||
|
||||
Stream<T> on<T extends ClideEvent>() =>
|
||||
_controller.stream.where((e) => e.event is T).map((e) => e.event as T);
|
||||
|
||||
void emit(ClideEvent event) {
|
||||
if (_controller.isClosed) return;
|
||||
_controller.add(ClideEventEnvelope(event, DateTime.now().toUtc()));
|
||||
}
|
||||
|
||||
Future<void> dispose() => _controller.close();
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
abstract class ClideEvent {
|
||||
const ClideEvent();
|
||||
|
||||
String get subsystem;
|
||||
String get kind;
|
||||
|
||||
Map<String, Object?> payload() => const {};
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ClideEventEnvelope {
|
||||
const ClideEventEnvelope(this.event, this.timestamp);
|
||||
|
||||
final ClideEvent event;
|
||||
final DateTime timestamp;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'v': 1,
|
||||
'subsystem': event.subsystem,
|
||||
'kind': event.kind,
|
||||
'ts': timestamp.toIso8601String(),
|
||||
'data': event.payload(),
|
||||
};
|
||||
}
|
||||
|
||||
class DaemonConnectionChanged extends ClideEvent {
|
||||
const DaemonConnectionChanged({required this.connected});
|
||||
final bool connected;
|
||||
@override
|
||||
String get subsystem => 'ipc';
|
||||
@override
|
||||
String get kind => 'connection-changed';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'connected': connected};
|
||||
}
|
||||
|
||||
class ThemeChanged extends ClideEvent {
|
||||
const ThemeChanged({required this.themeName});
|
||||
final String themeName;
|
||||
@override
|
||||
String get subsystem => 'theme';
|
||||
@override
|
||||
String get kind => 'changed';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'theme': themeName};
|
||||
}
|
||||
|
||||
class ProjectOpened extends ClideEvent {
|
||||
const ProjectOpened({required this.path});
|
||||
final String path;
|
||||
@override
|
||||
String get subsystem => 'project';
|
||||
@override
|
||||
String get kind => 'opened';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'path': path};
|
||||
}
|
||||
|
||||
class ProjectClosed extends ClideEvent {
|
||||
const ProjectClosed();
|
||||
@override
|
||||
String get subsystem => 'project';
|
||||
@override
|
||||
String get kind => 'closed';
|
||||
}
|
||||
|
||||
class ExtensionActivated extends ClideEvent {
|
||||
const ExtensionActivated({required this.id});
|
||||
final String id;
|
||||
@override
|
||||
String get subsystem => 'extensions';
|
||||
@override
|
||||
String get kind => 'activated';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'id': id};
|
||||
}
|
||||
|
||||
class ExtensionDeactivated extends ClideEvent {
|
||||
const ExtensionDeactivated({required this.id});
|
||||
final String id;
|
||||
@override
|
||||
String get subsystem => 'extensions';
|
||||
@override
|
||||
String get kind => 'deactivated';
|
||||
@override
|
||||
Map<String, Object?> payload() => {'id': id};
|
||||
}
|
||||
|
||||
/// Forwarded from the daemon. Feature extensions subscribe to this and
|
||||
/// narrow by subsystem+kind, or register a converter that emits a typed
|
||||
/// `ClideEvent` subclass into the bus.
|
||||
class DaemonEvent extends ClideEvent {
|
||||
const DaemonEvent({
|
||||
required this.subsystem,
|
||||
required this.kind,
|
||||
required this.data,
|
||||
required this.ts,
|
||||
});
|
||||
|
||||
@override
|
||||
final String subsystem;
|
||||
@override
|
||||
final String kind;
|
||||
final Map<String, Object?> data;
|
||||
final DateTime ts;
|
||||
|
||||
@override
|
||||
Map<String, Object?> payload() => {'ts': ts.toIso8601String(), ...data};
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/extension/src/extension.dart';
|
||||
import 'package:clide/kernel/src/clipboard.dart';
|
||||
import 'package:clide/kernel/src/commands/keybindings.dart';
|
||||
import 'package:clide/kernel/src/commands/palette.dart';
|
||||
import 'package:clide/kernel/src/commands/registry.dart';
|
||||
import 'package:clide/kernel/src/dialog.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/files.dart';
|
||||
import 'package:clide/kernel/src/focus.dart';
|
||||
import 'package:clide/kernel/src/i18n/i18n.dart';
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/kernel/src/net.dart';
|
||||
import 'package:clide/kernel/src/notify.dart';
|
||||
import 'package:clide/kernel/src/os.dart';
|
||||
import 'package:clide/kernel/src/panels/arrangement.dart';
|
||||
import 'package:clide/kernel/src/panels/registry.dart';
|
||||
import 'package:clide/kernel/src/project.dart';
|
||||
import 'package:clide/kernel/src/secrets.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/tray.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class ExtensionManager extends ChangeNotifier {
|
||||
ExtensionManager({
|
||||
required this.log,
|
||||
required this.events,
|
||||
required this.settings,
|
||||
required this.theme,
|
||||
required this.i18n,
|
||||
required this.panels,
|
||||
required this.arrangement,
|
||||
required this.commands,
|
||||
required this.palette,
|
||||
required this.keybindings,
|
||||
required this.clipboard,
|
||||
required this.files,
|
||||
required this.notify,
|
||||
required this.dialog,
|
||||
required this.tray,
|
||||
required this.secrets,
|
||||
required this.os,
|
||||
required this.net,
|
||||
required this.focus,
|
||||
required this.project,
|
||||
required this.ipc,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
final EventBus events;
|
||||
final SettingsStore settings;
|
||||
final ThemeController theme;
|
||||
final I18n i18n;
|
||||
final PanelRegistry panels;
|
||||
final LayoutArrangement arrangement;
|
||||
final CommandRegistry commands;
|
||||
final PaletteController palette;
|
||||
final KeybindingResolver keybindings;
|
||||
final ClideClipboard clipboard;
|
||||
final FileServices files;
|
||||
final Notifications notify;
|
||||
final DialogRouter dialog;
|
||||
final TrayRegistry tray;
|
||||
final SecretsVault secrets;
|
||||
final OsBridge os;
|
||||
final NetworkStatus net;
|
||||
final FocusTracker focus;
|
||||
final ProjectManager project;
|
||||
final DaemonClient ipc;
|
||||
|
||||
final Map<String, ClideExtension> _known = {};
|
||||
final Set<String> _activated = {};
|
||||
|
||||
void register(ClideExtension ext) {
|
||||
if (_known.containsKey(ext.id)) {
|
||||
log.warn('extensions', 'duplicate registration: ${ext.id}');
|
||||
return;
|
||||
}
|
||||
_known[ext.id] = ext;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Iterable<ClideExtension> get all => _known.values;
|
||||
bool isActivated(String id) => _activated.contains(id);
|
||||
|
||||
bool isEnabled(String id) {
|
||||
final v = settings.get<bool>('app.extensions.$id.enabled');
|
||||
return v ?? true;
|
||||
}
|
||||
|
||||
Future<void> setEnabled(String id, bool enabled) async {
|
||||
await settings.set<bool>('app.extensions.$id.enabled', enabled);
|
||||
if (enabled && !isActivated(id)) {
|
||||
await activate(id);
|
||||
} else if (!enabled && isActivated(id)) {
|
||||
await deactivate(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate every enabled extension in dependency order. Missing
|
||||
/// deps warn and skip.
|
||||
Future<void> activateAll() async {
|
||||
final order = _topoSort();
|
||||
for (final id in order) {
|
||||
if (!isEnabled(id)) continue;
|
||||
await activate(id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> activate(String id) async {
|
||||
if (_activated.contains(id)) return;
|
||||
final ext = _known[id];
|
||||
if (ext == null) {
|
||||
log.warn('extensions', 'unknown extension: $id');
|
||||
return;
|
||||
}
|
||||
for (final dep in ext.dependsOn) {
|
||||
if (!_activated.contains(dep)) {
|
||||
log.warn(
|
||||
'extensions', 'skipping ${ext.id}: dependency not activated: $dep');
|
||||
return;
|
||||
}
|
||||
}
|
||||
final ctx = _ExtensionContext(manager: this, id: ext.id);
|
||||
try {
|
||||
await ext.activate(ctx);
|
||||
for (final c in ext.contributions) {
|
||||
_applyContribution(c);
|
||||
}
|
||||
_activated.add(id);
|
||||
events.emit(ExtensionActivated(id: id));
|
||||
notifyListeners();
|
||||
log.info('extensions', 'activated $id');
|
||||
} catch (e, st) {
|
||||
log.error('extensions', 'activate failed for $id',
|
||||
error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deactivate(String id) async {
|
||||
if (!_activated.contains(id)) return;
|
||||
final ext = _known[id];
|
||||
if (ext == null) return;
|
||||
try {
|
||||
await ext.deactivate();
|
||||
for (final c in ext.contributions) {
|
||||
_removeContribution(c);
|
||||
}
|
||||
_activated.remove(id);
|
||||
events.emit(ExtensionDeactivated(id: id));
|
||||
notifyListeners();
|
||||
log.info('extensions', 'deactivated $id');
|
||||
} catch (e, st) {
|
||||
log.error('extensions', 'deactivate failed for $id',
|
||||
error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyContribution(ContributionPoint c) {
|
||||
switch (c) {
|
||||
case TabContribution _:
|
||||
case StatusItemContribution _:
|
||||
case ToolbarButtonContribution _:
|
||||
panels.contribute(c);
|
||||
case CommandContribution cmd:
|
||||
commands.register(cmd);
|
||||
final binding = cmd.defaultBinding;
|
||||
if (binding != null) {
|
||||
keybindings.bind(Keybinding.parse(binding), cmd.command);
|
||||
}
|
||||
case TrayItemContribution t:
|
||||
tray.add(t);
|
||||
case LayoutPresetContribution _:
|
||||
// Presets are consumed by the default-layout extension in its
|
||||
// own activate(); nothing for the kernel to do here.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _removeContribution(ContributionPoint c) {
|
||||
switch (c) {
|
||||
case TabContribution _:
|
||||
case StatusItemContribution _:
|
||||
case ToolbarButtonContribution _:
|
||||
panels.uncontribute(c.id);
|
||||
case CommandContribution cmd:
|
||||
commands.unregister(cmd.command);
|
||||
final binding = cmd.defaultBinding;
|
||||
if (binding != null) {
|
||||
keybindings.unbind(Keybinding.parse(binding));
|
||||
}
|
||||
case TrayItemContribution t:
|
||||
tray.remove(t.id);
|
||||
case LayoutPresetContribution _:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _topoSort() {
|
||||
final order = <String>[];
|
||||
final seen = <String>{};
|
||||
final visiting = <String>{};
|
||||
|
||||
void visit(String id) {
|
||||
if (seen.contains(id)) return;
|
||||
if (visiting.contains(id)) {
|
||||
log.warn('extensions', 'dependency cycle touching $id');
|
||||
return;
|
||||
}
|
||||
final ext = _known[id];
|
||||
if (ext == null) return;
|
||||
visiting.add(id);
|
||||
for (final dep in ext.dependsOn) {
|
||||
visit(dep);
|
||||
}
|
||||
visiting.remove(id);
|
||||
seen.add(id);
|
||||
order.add(id);
|
||||
}
|
||||
|
||||
for (final id in _known.keys) {
|
||||
visit(id);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
class _ExtensionContext implements ClideExtensionContext {
|
||||
_ExtensionContext({required this.manager, required this.id});
|
||||
final ExtensionManager manager;
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
Logger get log => manager.log;
|
||||
@override
|
||||
EventBus get events => manager.events;
|
||||
@override
|
||||
SettingsStore get settings => manager.settings;
|
||||
@override
|
||||
ThemeController get theme => manager.theme;
|
||||
@override
|
||||
I18n get i18n => manager.i18n;
|
||||
@override
|
||||
PanelRegistry get panels => manager.panels;
|
||||
@override
|
||||
LayoutArrangement get arrangement => manager.arrangement;
|
||||
@override
|
||||
CommandRegistry get commands => manager.commands;
|
||||
@override
|
||||
PaletteController get palette => manager.palette;
|
||||
@override
|
||||
ClideClipboard get clipboard => manager.clipboard;
|
||||
@override
|
||||
FileServices get files => manager.files;
|
||||
@override
|
||||
Notifications get notify => manager.notify;
|
||||
@override
|
||||
DialogRouter get dialog => manager.dialog;
|
||||
@override
|
||||
TrayRegistry get tray => manager.tray;
|
||||
@override
|
||||
SecretsVault get secrets => manager.secrets;
|
||||
@override
|
||||
OsBridge get os => manager.os;
|
||||
@override
|
||||
NetworkStatus get net => manager.net;
|
||||
@override
|
||||
FocusTracker get focus => manager.focus;
|
||||
@override
|
||||
ProjectManager get project => manager.project;
|
||||
@override
|
||||
DaemonClient get ipc => manager.ipc;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/src/clipboard.dart';
|
||||
import 'package:clide/kernel/src/commands/keybindings.dart';
|
||||
import 'package:clide/kernel/src/commands/palette.dart';
|
||||
import 'package:clide/kernel/src/commands/registry.dart';
|
||||
import 'package:clide/kernel/src/dialog.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/extensions_manager.dart';
|
||||
import 'package:clide/kernel/src/files.dart';
|
||||
import 'package:clide/kernel/src/focus.dart';
|
||||
import 'package:clide/kernel/src/i18n/catalog_loader.dart';
|
||||
import 'package:clide/kernel/src/i18n/i18n.dart';
|
||||
import 'package:clide/kernel/src/ipc/client.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/kernel/src/net.dart';
|
||||
import 'package:clide/kernel/src/notify.dart';
|
||||
import 'package:clide/kernel/src/os.dart';
|
||||
import 'package:clide/kernel/src/panels/arrangement.dart';
|
||||
import 'package:clide/kernel/src/panels/registry.dart';
|
||||
import 'package:clide/kernel/src/project.dart';
|
||||
import 'package:clide/kernel/src/secrets.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/loader.dart';
|
||||
import 'package:clide/kernel/src/tray.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Aggregated kernel services. Feature code that runs outside a
|
||||
/// BuildContext (extensions, background tasks) holds a [KernelServices]
|
||||
/// ref directly; widget code reaches them via [ClideKernel.of].
|
||||
class KernelServices {
|
||||
KernelServices({
|
||||
required this.log,
|
||||
required this.settings,
|
||||
required this.events,
|
||||
required this.ipc,
|
||||
required this.theme,
|
||||
required this.i18n,
|
||||
required this.panels,
|
||||
required this.arrangement,
|
||||
required this.commands,
|
||||
required this.palette,
|
||||
required this.keybindings,
|
||||
required this.clipboard,
|
||||
required this.files,
|
||||
required this.notify,
|
||||
required this.dialog,
|
||||
required this.tray,
|
||||
required this.secrets,
|
||||
required this.os,
|
||||
required this.net,
|
||||
required this.focus,
|
||||
required this.project,
|
||||
required this.extensions,
|
||||
});
|
||||
|
||||
final Logger log;
|
||||
final SettingsStore settings;
|
||||
final EventBus events;
|
||||
final DaemonClient ipc;
|
||||
final ThemeController theme;
|
||||
final I18n i18n;
|
||||
final PanelRegistry panels;
|
||||
final LayoutArrangement arrangement;
|
||||
final CommandRegistry commands;
|
||||
final PaletteController palette;
|
||||
final KeybindingResolver keybindings;
|
||||
final ClideClipboard clipboard;
|
||||
final FileServices files;
|
||||
final Notifications notify;
|
||||
final DialogRouter dialog;
|
||||
final TrayRegistry tray;
|
||||
final SecretsVault secrets;
|
||||
final OsBridge os;
|
||||
final NetworkStatus net;
|
||||
final FocusTracker focus;
|
||||
final ProjectManager project;
|
||||
final ExtensionManager extensions;
|
||||
|
||||
static Future<KernelServices> boot({
|
||||
required Directory appDir,
|
||||
required List<ThemeDefinition> bundledThemes,
|
||||
required CatalogLoader i18nLoader,
|
||||
List<String> preloadNamespaces = const [],
|
||||
Locale defaultLocale = const Locale('en', 'US'),
|
||||
Locale? initialLocale,
|
||||
List<Locale> availableLocales = const [Locale('en', 'US')],
|
||||
String? socketPath,
|
||||
DaemonClient Function(Logger, EventBus)? daemonClientFactory,
|
||||
bool autoStartDaemonClient = true,
|
||||
}) async {
|
||||
final log = Logger();
|
||||
final events = EventBus();
|
||||
|
||||
final settings = SettingsStore(appDir: appDir);
|
||||
await settings.load();
|
||||
|
||||
final i18n = I18n(
|
||||
loader: i18nLoader,
|
||||
log: log,
|
||||
defaultLocale: defaultLocale,
|
||||
initialLocale: initialLocale,
|
||||
availableLocales: availableLocales,
|
||||
);
|
||||
for (final ns in preloadNamespaces) {
|
||||
await i18n.ensureNamespaceLoaded(ns);
|
||||
}
|
||||
|
||||
final theme = ThemeController(bundled: bundledThemes);
|
||||
final panels = PanelRegistry();
|
||||
final arrangement = LayoutArrangement();
|
||||
final commands = CommandRegistry();
|
||||
final keybindings = KeybindingResolver();
|
||||
final palette = PaletteController(commands);
|
||||
final clipboard = ClideClipboard();
|
||||
final files = FileServices(events);
|
||||
final notify = Notifications();
|
||||
final dialog = DialogRouter();
|
||||
final tray = TrayRegistry();
|
||||
final secrets = SecretsVault();
|
||||
final os = OsBridge(log: log, events: events);
|
||||
final net = NetworkStatus();
|
||||
final focus = FocusTracker();
|
||||
final project = ProjectManager(
|
||||
log: log,
|
||||
events: events,
|
||||
settings: settings,
|
||||
);
|
||||
final ipc = daemonClientFactory != null
|
||||
? daemonClientFactory(log, events)
|
||||
: DaemonClient(
|
||||
socketPath: socketPath ?? defaultSocketPath(),
|
||||
log: log,
|
||||
events: events,
|
||||
);
|
||||
final extensions = ExtensionManager(
|
||||
log: log,
|
||||
events: events,
|
||||
settings: settings,
|
||||
theme: theme,
|
||||
i18n: i18n,
|
||||
panels: panels,
|
||||
arrangement: arrangement,
|
||||
commands: commands,
|
||||
palette: palette,
|
||||
keybindings: keybindings,
|
||||
clipboard: clipboard,
|
||||
files: files,
|
||||
notify: notify,
|
||||
dialog: dialog,
|
||||
tray: tray,
|
||||
secrets: secrets,
|
||||
os: os,
|
||||
net: net,
|
||||
focus: focus,
|
||||
project: project,
|
||||
ipc: ipc,
|
||||
);
|
||||
|
||||
if (autoStartDaemonClient) {
|
||||
unawaited(ipc.start());
|
||||
}
|
||||
|
||||
return KernelServices(
|
||||
log: log,
|
||||
settings: settings,
|
||||
events: events,
|
||||
ipc: ipc,
|
||||
theme: theme,
|
||||
i18n: i18n,
|
||||
panels: panels,
|
||||
arrangement: arrangement,
|
||||
commands: commands,
|
||||
palette: palette,
|
||||
keybindings: keybindings,
|
||||
clipboard: clipboard,
|
||||
files: files,
|
||||
notify: notify,
|
||||
dialog: dialog,
|
||||
tray: tray,
|
||||
secrets: secrets,
|
||||
os: os,
|
||||
net: net,
|
||||
focus: focus,
|
||||
project: project,
|
||||
extensions: extensions,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await ipc.stop();
|
||||
ipc.dispose();
|
||||
settings.dispose();
|
||||
theme.dispose();
|
||||
panels.dispose();
|
||||
arrangement.dispose();
|
||||
commands.dispose();
|
||||
palette.dispose();
|
||||
i18n.dispose();
|
||||
notify.dispose();
|
||||
dialog.dispose();
|
||||
tray.dispose();
|
||||
net.dispose();
|
||||
focus.dispose();
|
||||
project.dispose();
|
||||
extensions.dispose();
|
||||
await log.dispose();
|
||||
await events.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class ClideKernel extends InheritedWidget {
|
||||
const ClideKernel({
|
||||
super.key,
|
||||
required this.services,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
final KernelServices services;
|
||||
|
||||
static KernelServices of(BuildContext context) {
|
||||
final w = context.dependOnInheritedWidgetOfExactType<ClideKernel>();
|
||||
if (w == null) {
|
||||
throw FlutterError(
|
||||
'ClideKernel.of() called with a context that is not a descendant of a ClideKernel.');
|
||||
}
|
||||
return w.services;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ClideKernel oldWidget) =>
|
||||
services != oldWidget.services;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class FilesDropped extends ClideEvent {
|
||||
const FilesDropped({required this.paths, required this.slot});
|
||||
|
||||
final List<String> paths;
|
||||
final SlotId slot;
|
||||
|
||||
@override
|
||||
String get subsystem => 'files';
|
||||
@override
|
||||
String get kind => 'dropped';
|
||||
@override
|
||||
Map<String, Object?> payload() => {
|
||||
'paths': paths,
|
||||
'slot': slot.value,
|
||||
};
|
||||
}
|
||||
|
||||
/// Tier-0 stub for file pickers and drop targets.
|
||||
///
|
||||
/// Flutter desktop has no native picker API without a dep; rather than
|
||||
/// add one now, pickOpen/pickSave/pickDirectory throw UnimplementedError
|
||||
/// and the drop target is a no-op until we wire it through the
|
||||
/// platform channel. This lets the rest of the kernel compile and makes
|
||||
/// the service surface real.
|
||||
class FileServices {
|
||||
FileServices(this._events);
|
||||
final EventBus _events;
|
||||
|
||||
Future<List<String>> pickOpen({
|
||||
List<String> extensions = const [],
|
||||
bool multiple = false,
|
||||
}) async {
|
||||
throw UnimplementedError('pickOpen — wired in a later tier');
|
||||
}
|
||||
|
||||
Future<String?> pickSave({
|
||||
String? defaultName,
|
||||
List<String> extensions = const [],
|
||||
}) async {
|
||||
throw UnimplementedError('pickSave — wired in a later tier');
|
||||
}
|
||||
|
||||
Future<String?> pickDirectory() async {
|
||||
throw UnimplementedError('pickDirectory — wired in a later tier');
|
||||
}
|
||||
|
||||
/// Invoked by the platform drop-target wiring when files land on a
|
||||
/// slot. Emits a [FilesDropped] event; the slot-owning extension
|
||||
/// subscribes.
|
||||
@visibleForTesting
|
||||
void notifyDropped({required List<String> paths, required SlotId slot}) {
|
||||
_events.emit(FilesDropped(paths: paths, slot: slot));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:clide/kernel/src/panels/slot_id.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Tracks the currently focused contribution (tab id + slot). Backs
|
||||
/// `clide active`; extensions that need "which tab does the user care
|
||||
/// about right now?" read from here instead of poking Flutter's
|
||||
/// FocusScope directly.
|
||||
class FocusTracker extends ChangeNotifier {
|
||||
SlotId? _slot;
|
||||
String? _contributionId;
|
||||
|
||||
SlotId? get activeSlot => _slot;
|
||||
String? get activeContributionId => _contributionId;
|
||||
|
||||
void setActive({required SlotId slot, required String contributionId}) {
|
||||
if (_slot == slot && _contributionId == contributionId) return;
|
||||
_slot = slot;
|
||||
_contributionId = contributionId;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
if (_slot == null && _contributionId == null) return;
|
||||
_slot = null;
|
||||
_contributionId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tab.title": { "translation": "Claude" },
|
||||
"status.attaching": { "translation": "attaching…" },
|
||||
"status.no-tmux": { "translation": "no-tmux · fresh every launch" },
|
||||
"status.exited": { "translation": "session exited" },
|
||||
"status.primary-exited": { "translation": "session exited — restart clide to retry" }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"command.reset": { "translation": "Layout: Reset to Classic" },
|
||||
"preset.classic": { "translation": "Classic" }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tab.title": { "translation": "Editor" },
|
||||
"empty": { "translation": "Open a file to begin editing." },
|
||||
"subtitle.no-buffer": { "translation": "no buffer · use `clide open <path>` or pick a file in the tree" }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tab.title": { "translation": "Files" },
|
||||
"loading": { "translation": "Loading…" },
|
||||
"empty": { "translation": "No visible files" }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"connected": { "translation": "connected" },
|
||||
"connected.hint": { "translation": "clide daemon is reachable over the local socket" },
|
||||
"disconnected": { "translation": "disconnected" },
|
||||
"disconnected.hint": { "translation": "clide daemon is not running — start it with `clide --daemon`" }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tab.title": { "translation": "Terminal" },
|
||||
"subtitle.spawning": { "translation": "spawning shell…" },
|
||||
"subtitle.exited": { "translation": "Shell exited." },
|
||||
"error.unavailable": { "translation": "Terminal unavailable" },
|
||||
"error.daemon": { "translation": "Daemon not connected. Start `clide --daemon`." }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"command.pick": { "translation": "Theme: Pick…" },
|
||||
"modal.title": { "translation": "Select theme" },
|
||||
"modal.cancel": { "translation": "Cancel" },
|
||||
"modal.cancel.hint": { "translation": "Close the theme picker without changing the current theme" },
|
||||
"row.select.hint": { "translation": "Activate this theme" }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": { "translation": "clide" },
|
||||
"subtitle": { "translation": "Flutter desktop IDE for Claude Code" },
|
||||
"open-project": { "translation": "Open project" },
|
||||
"open-project.hint": { "translation": "Pick a git repository to open as the workspace" },
|
||||
"tab.title": { "translation": "Welcome" }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:clide/kernel/src/i18n/fallback_chain.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Loads catalog JSON for a given `(namespace, locale)` pair.
|
||||
///
|
||||
/// The file format (mirrors fframe verbatim):
|
||||
/// `{namespace}_{lang}_{country}.json` — or `{namespace}_{lang}.json`
|
||||
/// Content: `{ "key": { "translation": "...", ...extras }, ... }`.
|
||||
///
|
||||
/// Two reader shapes:
|
||||
/// * Asset bundle (built-in catalogs shipped under `lib/kernel/src/i18n/catalog/`).
|
||||
/// * Filesystem (third-party extensions under `~/.clide/extensions/<id>/`).
|
||||
///
|
||||
/// Missing files return an empty map — not an error. The fallback chain
|
||||
/// walker handles "nothing for this locale" by trying the next one.
|
||||
abstract class CatalogLoader {
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale);
|
||||
}
|
||||
|
||||
class AssetCatalogLoader implements CatalogLoader {
|
||||
AssetCatalogLoader({required this.bundle, this.rootDir = _defaultRoot});
|
||||
|
||||
final AssetBundle bundle;
|
||||
final String rootDir;
|
||||
|
||||
static const String _defaultRoot = 'lib/kernel/src/i18n/catalog';
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
|
||||
final suffix = FallbackChain.filenameSuffix(locale);
|
||||
final path = '$rootDir/${namespace}_$suffix.json';
|
||||
try {
|
||||
final text = await bundle.loadString(path);
|
||||
if (text.trim().isEmpty) return const {};
|
||||
final obj = jsonDecode(text);
|
||||
if (obj is Map) return obj.cast<String, Object?>();
|
||||
return const {};
|
||||
} on FlutterError {
|
||||
// Asset missing. Return empty map; fallback chain handles the miss.
|
||||
return const {};
|
||||
} on FormatException {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FileCatalogLoader implements CatalogLoader {
|
||||
const FileCatalogLoader({required this.rootDir});
|
||||
|
||||
final Directory rootDir;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
|
||||
final suffix = FallbackChain.filenameSuffix(locale);
|
||||
final f = File('${rootDir.path}/${namespace}_$suffix.json');
|
||||
if (!await f.exists()) return const {};
|
||||
try {
|
||||
final text = await f.readAsString();
|
||||
if (text.trim().isEmpty) return const {};
|
||||
final obj = jsonDecode(text);
|
||||
if (obj is Map) return obj.cast<String, Object?>();
|
||||
} on FormatException {
|
||||
// malformed — return empty; caller will fall back.
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Preloaded-in-memory loader for tests and synthesized catalogs.
|
||||
class InMemoryCatalogLoader implements CatalogLoader {
|
||||
InMemoryCatalogLoader(this._map);
|
||||
|
||||
final Map<String, Map<Locale, Map<String, Object?>>> _map;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
|
||||
final byNs = _map[namespace];
|
||||
if (byNs == null) return const {};
|
||||
// match by canonical comparison so Locale("en") == registered Locale("en")
|
||||
for (final entry in byNs.entries) {
|
||||
if (_eq(entry.key, locale)) return entry.value;
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
||||
static bool _eq(Locale a, Locale b) =>
|
||||
a.languageCode == b.languageCode && a.countryCode == b.countryCode;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user