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:
2026-04-23 00:37:20 +02:00
co-authored by Claude Opus 4.6
parent a526c5b9b7
commit 46329700d5
394 changed files with 978 additions and 1090 deletions
+528
View File
@@ -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(),
);
},
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -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 [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+228
View File
@@ -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),
),
),
);
}
}
+76
View File
@@ -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 [];
}
+1
View File
@@ -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)),
],
),
),
);
}
}
+27
View File
@@ -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',
),
);
}
+3
View File
@@ -0,0 +1,3 @@
export 'src/extension.dart';
export 'src/diff_controller.dart';
export 'src/diff_view.dart';
+82
View File
@@ -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();
}
}
+338
View File
@@ -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,
),
),
],
),
);
}
}
+27
View File
@@ -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(),
),
];
}
+1
View File
@@ -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();
}
}
+203
View File
@@ -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,
),
),
),
);
}
}
+30
View File
@@ -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 [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+31
View File
@@ -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();
}
}
+271
View File
@@ -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,
),
),
],
),
),
),
);
}
}
+3
View File
@@ -0,0 +1,3 @@
export 'src/extension.dart';
export 'src/git_controller.dart';
export 'src/git_panel_view.dart';
+33
View File
@@ -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(),
),
];
}
+169
View File
@@ -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();
}
}
+541
View File
@@ -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,
),
],
),
],
),
);
}
}
+257
View File
@@ -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 [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+27
View File
@@ -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(),
),
];
}
+122
View File
@@ -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),
],
),
),
);
}
}
+2
View File
@@ -0,0 +1,2 @@
export 'src/extension.dart';
export 'src/status_item.dart';
+33
View File
@@ -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 [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+27
View File
@@ -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),
),
),
);
}
}
+5
View File
@@ -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();
}
}
+195
View File
@@ -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,
),
),
),
),
);
}
}
+37
View File
@@ -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(),
),
];
}
+147
View File
@@ -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?>()];
}
}
+376
View File
@@ -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,
),
),
],
),
),
],
);
}
}
+3
View File
@@ -0,0 +1,3 @@
export 'src/extension.dart';
export 'src/problems_controller.dart';
export 'src/problems_view.dart';
+27
View File
@@ -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();
}
}
+160
View File
@@ -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,
),
),
],
),
);
}
}
+1
View File
@@ -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 [];
}
+31
View File
@@ -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(),
),
];
}
+185
View File
@@ -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;
+1
View File
@@ -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';
+27
View File
@@ -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(),
),
];
}
+140
View File
@@ -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)),
],
),
),
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -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 [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+35
View File
@@ -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'},
),
),
];
}
+384
View File
@@ -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),
],
),
],
),
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+11
View File
@@ -0,0 +1,11 @@
/// clide extension contract.
///
/// ClideExtension = shipping unit. ContributionPoint = atom contributed
/// into a kernel slot or service. One manifest may contribute N atoms
/// across multiple slots.
library;
export 'src/contribution.dart';
export 'src/extension.dart';
export 'src/host.dart';
export 'src/manifest.dart';
+157
View File
@@ -0,0 +1,157 @@
import 'package:clide/clide.dart';
import 'package:clide/kernel/src/panels/slot_id.dart';
import 'package:flutter/widgets.dart';
/// One atom contributed by a [ClideExtension]. Extensions declare N of
/// these in a manifest; the kernel and slot hosts render them.
///
/// Adding a new contribution type: add a case to this sealed hierarchy,
/// extend the host dispatch in the default-layout extension, and bump
/// the extension manifest schema version.
sealed class ContributionPoint {
const ContributionPoint({required this.id});
/// Stable id for this contribution, unique within its extension.
final String id;
/// The slot this contribution targets, or `null` for non-slot
/// contributions (commands, events, grammars).
SlotId? get slot => null;
}
/// A tab in a slot that hosts tabs (sidebar / workspace / context).
class TabContribution extends ContributionPoint {
const TabContribution({
required super.id,
required this.slot,
required this.title,
required this.build,
this.icon,
this.priority = 0,
this.fileGlobs = const [],
this.listenable,
this.titleKey,
this.i18nNamespace,
});
@override
final SlotId slot;
final String title;
final WidgetBuilder build;
final Object? icon;
final int priority;
final List<String> fileGlobs;
final Listenable? listenable;
/// When set, the slot host resolves the display title via
/// `i18n.string(titleKey, namespace: i18nNamespace, placeholder: title)`.
/// [title] stays as the English fallback (also used in tests/logs).
final String? titleKey;
/// The i18n namespace to look up [titleKey] in. Extensions usually
/// pass their own `id`. Required when [titleKey] is set.
final String? i18nNamespace;
}
/// A status-bar item. Order is determined by [priority] within each
/// alignment group; negative priorities float left, positive right.
class StatusItemContribution extends ContributionPoint {
const StatusItemContribution({
required super.id,
required this.build,
this.priority = 0,
this.listenable,
});
@override
SlotId get slot => Slots.statusbar;
final WidgetBuilder build;
final int priority;
final Listenable? listenable;
}
/// A button in the main toolbar.
class ToolbarButtonContribution extends ContributionPoint {
const ToolbarButtonContribution({
required super.id,
required this.label,
required this.onPressed,
this.icon,
this.tooltip,
this.priority = 0,
});
@override
SlotId get slot => Slots.toolbar;
final String label;
final Object? icon;
final String? tooltip;
final int priority;
final VoidCallback onPressed;
}
/// A command extensions register with [CommandRegistry]. Surfaced by the
/// command palette, the keybinding resolver, and `clide` CLI subcommands.
class CommandContribution extends ContributionPoint {
const CommandContribution({
required super.id,
required this.command,
required this.run,
this.title,
this.defaultBinding,
});
final String command; // e.g. "git.commit"
final String? title; // "Git: Commit staged"
final String? defaultBinding; // e.g. "ctrl+shift+g"
final Future<IpcResponse> Function(List<String> args) run;
}
/// Registers an item in the OS tray / menu-bar.
class TrayItemContribution extends ContributionPoint {
const TrayItemContribution({
required super.id,
required this.label,
required this.onSelected,
this.priority = 0,
});
@override
SlotId get slot => Slots.tray;
final String label;
final int priority;
final VoidCallback onSelected;
}
/// A named layout arrangement. One "classic" preset ships with
/// `builtin.default-layout`; other presets can be contributed.
class LayoutPresetContribution extends ContributionPoint {
const LayoutPresetContribution({
required super.id,
required this.displayName,
required this.slots,
});
final String displayName;
final List<LayoutSlot> slots;
}
/// One slot in a [LayoutPresetContribution]. Describes where the slot
/// appears and its initial size/visibility.
class LayoutSlot {
const LayoutSlot({
required this.slot,
required this.position,
this.defaultSize,
this.minSize,
this.maxSize,
this.visible = true,
});
final SlotId slot;
final SlotPosition position;
final double? defaultSize;
final double? minSize;
final double? maxSize;
final bool visible;
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:clide/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,
);
}
+36
View File
@@ -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;
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'dart:io';
import 'package:yaml/yaml.dart';
/// A parsed third-party extension manifest.
///
/// Built-in extensions don't need a manifest file — they compile in as
/// Dart subclasses of [ClideExtension]. Third-party extensions ship a
/// `manifest.yaml` under `~/.clide/extensions/<id>/` alongside their
/// Lua entrypoint; this class parses and validates that file.
class ExtensionManifest {
const ExtensionManifest({
required this.id,
required this.title,
required this.version,
required this.dependsOn,
required this.entry,
required this.schemaVersion,
});
final String id;
final String title;
final String version;
final List<String> dependsOn;
final String entry; // relative path to lua entrypoint
final int schemaVersion;
factory ExtensionManifest.fromYamlString(String text) {
final doc = loadYaml(text);
if (doc is! Map) {
throw const FormatException('manifest root is not a map');
}
final id = doc['id'];
if (id is! String || id.isEmpty) {
throw const FormatException('manifest missing `id`');
}
final title = (doc['title'] as String?) ?? id;
final version = (doc['version'] as String?) ?? '0.0.0';
final entry = (doc['entry'] as String?) ?? 'extension.lua';
final schemaVersion = (doc['schema_version'] as int?) ?? 1;
final depsYaml = doc['depends_on'];
final deps = <String>[];
if (depsYaml is YamlList) {
for (final d in depsYaml) {
if (d is String) deps.add(d);
}
}
return ExtensionManifest(
id: id,
title: title,
version: version,
dependsOn: deps,
entry: entry,
schemaVersion: schemaVersion,
);
}
static Future<ExtensionManifest> fromFile(File f) async =>
ExtensionManifest.fromYamlString(await f.readAsString());
}
+48
View File
@@ -0,0 +1,48 @@
/// clide kernel — registries, stores, and shared singleton services
/// consumed by every extension.
///
/// Admission rule: the kernel owns anything whose second concurrent user
/// would create incoherent state or divergent UX. External-interfacing
/// work generally belongs to extensions (git, pql, Linear);
/// external-interfacing *singletons* (OS clipboard, tray, keychain)
/// belong here.
///
/// Exports are added as each subsystem lands. See plan:
/// /home/jeroenschweitzer/.claude/plans/i-want-to-discuss-cozy-zebra.md
library;
export 'src/events/bus.dart';
export 'src/events/types.dart';
export 'src/ipc/client.dart';
export 'src/log.dart';
export 'src/settings.dart';
export 'src/facade.dart';
export 'src/clipboard.dart';
export 'src/commands/keybindings.dart';
export 'src/commands/palette.dart';
export 'src/commands/registry.dart';
export 'src/dialog.dart';
export 'src/extensions_manager.dart';
export 'src/files.dart';
export 'src/focus.dart';
export 'src/i18n/catalog_loader.dart';
export 'src/i18n/fallback_chain.dart';
export 'src/i18n/i18n.dart';
export 'src/net.dart';
export 'src/notify.dart';
export 'src/os.dart';
export 'src/panels/arrangement.dart';
export 'src/project.dart';
export 'src/secrets.dart';
export 'src/tray.dart';
export 'src/panels/drag_resize.dart';
export 'src/panels/layout_preset.dart';
export 'src/panels/registry.dart';
export 'src/panels/slot_id.dart';
export 'src/theme/contrast.dart';
export 'src/theme/controller.dart';
export 'src/theme/loader.dart';
export 'src/theme/palette.dart';
export 'src/theme/resolver.dart';
export 'src/theme/semantic.dart';
export 'src/theme/tokens.dart';
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' as flutter_services;
/// Typed, per-content-kind clipboard with a plaintext fallback.
///
/// Extensions write typed values (`write<GitHunk>(hunk)`) and read in
/// the same type (`readAs<GitHunk>()`). Anything with a `toPlain`
/// callback also syncs to the OS clipboard so external apps see
/// reasonable text. The history ring keeps the last [historyLimit]
/// entries per type for quick recall.
class ClideClipboard {
ClideClipboard({this.historyLimit = 16});
final int historyLimit;
final Map<Type, List<Object>> _history = {};
Future<void> write<T extends Object>(
T value, {
String Function(T)? toPlain,
}) async {
final bucket = _history.putIfAbsent(T, () => <Object>[]);
bucket.insert(0, value);
if (bucket.length > historyLimit) bucket.removeLast();
if (toPlain != null) {
await flutter_services.Clipboard.setData(
flutter_services.ClipboardData(text: toPlain(value)));
}
}
T? readAs<T extends Object>() {
final bucket = _history[T];
if (bucket == null || bucket.isEmpty) return null;
return bucket.first as T;
}
List<T> historyOf<T extends Object>() {
final bucket = _history[T];
if (bucket == null) return const [];
return bucket.cast<T>().toList(growable: false);
}
Future<String?> readPlain() async {
final d = await flutter_services.Clipboard.getData('text/plain');
return d?.text;
}
Future<void> writePlain(String text) async {
await flutter_services.Clipboard.setData(
flutter_services.ClipboardData(text: text));
final bucket = _history.putIfAbsent(String, () => <Object>[]);
bucket.insert(0, text);
if (bucket.length > historyLimit) bucket.removeLast();
}
@visibleForTesting
void clear() => _history.clear();
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Key combo: modifiers + primary key. Canonicalized on construction
/// (modifiers sorted, lowercased) so equality works for lookup keys.
@immutable
class Keybinding {
Keybinding({required Set<String> modifiers, required String key})
: modifiers = _canonModifiers(modifiers),
key = key.toLowerCase();
final List<String> modifiers;
final String key;
static List<String> _canonModifiers(Set<String> m) {
final normalized = m.map((s) => s.toLowerCase()).toSet().toList()..sort();
return List.unmodifiable(normalized);
}
/// Parse "ctrl+shift+g", "cmd+k", "alt+f4".
static Keybinding parse(String spec) {
if (spec.trim().isEmpty) {
throw ArgumentError('empty keybinding');
}
final parts = spec.split('+').map((s) => s.trim()).toList();
final key = parts.removeLast();
if (key.isEmpty) {
throw ArgumentError('keybinding is missing a key: "$spec"');
}
return Keybinding(modifiers: parts.toSet(), key: key);
}
String get canonical {
if (modifiers.isEmpty) return key;
return '${modifiers.join('+')}+$key';
}
@override
bool operator ==(Object other) =>
other is Keybinding &&
other.key == key &&
listEquals(other.modifiers, modifiers);
@override
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
@override
String toString() => 'Keybinding($canonical)';
}
class KeybindingResolver {
final Map<Keybinding, String> _bindings = {};
void bind(Keybinding b, String commandId) {
_bindings[b] = commandId;
}
void unbind(Keybinding b) {
_bindings.remove(b);
}
String? commandFor(Keybinding b) => _bindings[b];
Iterable<MapEntry<Keybinding, String>> get entries => _bindings.entries;
/// Map a Flutter [KeyEvent] to a [Keybinding] suitable for lookup.
static Keybinding? fromKeyEvent(KeyEvent event, HardwareKeyboard keyboard) {
if (event is! KeyDownEvent) return null;
final label = event.logicalKey.keyLabel;
if (label.isEmpty) return null;
final mods = <String>{};
if (keyboard.isControlPressed) mods.add('ctrl');
if (keyboard.isShiftPressed) mods.add('shift');
if (keyboard.isAltPressed) mods.add('alt');
if (keyboard.isMetaPressed) mods.add('cmd');
return Keybinding(modifiers: mods, key: label);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:clide/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);
}
}
+37
View File
@@ -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);
}
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
typedef DialogBuilder<T> = Widget Function(
BuildContext context,
void Function([T? result]) dismiss,
);
/// Single-at-a-time modal router.
///
/// Extensions call [show] with a builder; the root widget (installed by
/// [DialogHost]) listens and renders the current dialog over a dimmed
/// backdrop. Only one dialog is active at a time — a second [show] call
/// while one is open awaits until the first dismisses.
class DialogRouter extends ChangeNotifier {
DialogBuilder<Object?>? _current;
Completer<Object?>? _completer;
final List<_Queued> _queue = [];
DialogBuilder<Object?>? get current => _current;
bool get isOpen => _current != null;
Future<T?> show<T extends Object>(DialogBuilder<T> builder) {
final completer = Completer<T?>();
final wrapped = _wrap<T>(builder);
if (_current == null) {
_current = wrapped;
_completer = Completer<Object?>();
// forward our generic completer to the typed one
_completer!.future.then((v) {
if (!completer.isCompleted) completer.complete(v as T?);
});
notifyListeners();
} else {
_queue.add(_Queued(wrapped, completer));
}
return completer.future;
}
void dismiss([Object? result]) {
if (_current == null) return;
final c = _completer;
_current = null;
_completer = null;
if (c != null && !c.isCompleted) c.complete(result);
if (_queue.isNotEmpty) {
final next = _queue.removeAt(0);
_current = next.builder;
_completer = Completer<Object?>();
_completer!.future.then((v) {
if (!next.completer.isCompleted) next.completer.complete(v);
});
}
notifyListeners();
}
DialogBuilder<Object?> _wrap<T>(DialogBuilder<T> builder) {
return (ctx, dismiss) => builder(ctx, ([T? v]) => dismiss(v));
}
}
class _Queued {
_Queued(this.builder, this.completer);
final DialogBuilder<Object?> builder;
// ignore: strict_raw_type
final Completer completer;
}
/// Hosts the current dialog from [DialogRouter]. Place high in the tree
/// (inside the WidgetsApp) so dialogs overlay every other surface.
class DialogHost extends StatelessWidget {
const DialogHost({
super.key,
required this.router,
required this.child,
this.backdropColor = const Color(0xC0000000),
});
final DialogRouter router;
final Widget child;
final Color backdropColor;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
child,
ListenableBuilder(
listenable: router,
builder: (ctx, _) {
final b = router.current;
if (b == null) return const SizedBox.shrink();
return Positioned.fill(
child: ColoredBox(
color: backdropColor,
child: Center(
child: b(ctx, router.dismiss),
),
),
);
},
),
],
);
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'dart:async';
import 'package:clide/kernel/src/events/types.dart';
class EventBus {
EventBus();
final StreamController<ClideEventEnvelope> _controller =
StreamController<ClideEventEnvelope>.broadcast();
Stream<ClideEventEnvelope> get stream => _controller.stream;
Stream<T> on<T extends ClideEvent>() =>
_controller.stream.where((e) => e.event is T).map((e) => e.event as T);
void emit(ClideEvent event) {
if (_controller.isClosed) return;
_controller.add(ClideEventEnvelope(event, DateTime.now().toUtc()));
}
Future<void> dispose() => _controller.close();
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/foundation.dart';
@immutable
abstract class ClideEvent {
const ClideEvent();
String get subsystem;
String get kind;
Map<String, Object?> payload() => const {};
}
@immutable
class ClideEventEnvelope {
const ClideEventEnvelope(this.event, this.timestamp);
final ClideEvent event;
final DateTime timestamp;
Map<String, Object?> toJson() => {
'v': 1,
'subsystem': event.subsystem,
'kind': event.kind,
'ts': timestamp.toIso8601String(),
'data': event.payload(),
};
}
class DaemonConnectionChanged extends ClideEvent {
const DaemonConnectionChanged({required this.connected});
final bool connected;
@override
String get subsystem => 'ipc';
@override
String get kind => 'connection-changed';
@override
Map<String, Object?> payload() => {'connected': connected};
}
class ThemeChanged extends ClideEvent {
const ThemeChanged({required this.themeName});
final String themeName;
@override
String get subsystem => 'theme';
@override
String get kind => 'changed';
@override
Map<String, Object?> payload() => {'theme': themeName};
}
class ProjectOpened extends ClideEvent {
const ProjectOpened({required this.path});
final String path;
@override
String get subsystem => 'project';
@override
String get kind => 'opened';
@override
Map<String, Object?> payload() => {'path': path};
}
class ProjectClosed extends ClideEvent {
const ProjectClosed();
@override
String get subsystem => 'project';
@override
String get kind => 'closed';
}
class ExtensionActivated extends ClideEvent {
const ExtensionActivated({required this.id});
final String id;
@override
String get subsystem => 'extensions';
@override
String get kind => 'activated';
@override
Map<String, Object?> payload() => {'id': id};
}
class ExtensionDeactivated extends ClideEvent {
const ExtensionDeactivated({required this.id});
final String id;
@override
String get subsystem => 'extensions';
@override
String get kind => 'deactivated';
@override
Map<String, Object?> payload() => {'id': id};
}
/// Forwarded from the daemon. Feature extensions subscribe to this and
/// narrow by subsystem+kind, or register a converter that emits a typed
/// `ClideEvent` subclass into the bus.
class DaemonEvent extends ClideEvent {
const DaemonEvent({
required this.subsystem,
required this.kind,
required this.data,
required this.ts,
});
@override
final String subsystem;
@override
final String kind;
final Map<String, Object?> data;
final DateTime ts;
@override
Map<String, Object?> payload() => {'ts': ts.toIso8601String(), ...data};
}
+279
View File
@@ -0,0 +1,279 @@
import 'dart:async';
import 'package:clide/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;
}
+236
View File
@@ -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;
}
+61
View File
@@ -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));
}
}
+28
View File
@@ -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" }
}
+93
View File
@@ -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