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
+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';