tickets reader: adopt retained ReaderNav + static tab
Bring the tickets detail in line with markdown/decisions (D-81). The controller loads on 'load' (the channel the retained ReaderNav emits), the extension reveals the static tickets.detail tab on selection instead of the per-click uncontribute/contribute churn (the T-188 anti-pattern), and the view grabs nav.current on mount and wraps in ClidePaneChrome with a ReaderActionBar — pin toggle left, back/forward + jump-to-pin right, no edit pencil (tickets are pql records, not files). The controller drops its now-unused panels dependency. Also adds the tickets builtin's first tests — the sidebar list (load/sections/filter/select/empty/error/refresh) and the detail reader (load, nav, pin, parents/decisions/status) — covering a pre-existing gap exposed by bringing these files under test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,17 +20,14 @@ class TicketsExtension extends ClideExtension {
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
// Ensure the retained nav exists so it records selections + emits
|
||||
// loads whether or not the detail view is mounted (T-199/D-81).
|
||||
ctx.readerNav.navFor(id, dataKey: 'id');
|
||||
// Reveal the static detail tab on selection — no per-click
|
||||
// uncontribute/contribute churn (the T-188 anti-pattern). The nav
|
||||
// owns load + history.
|
||||
_sub = ctx.messages.subscribe(publisher: id, channel: 'selection').listen((msg) {
|
||||
final selectedId = msg.data['id'] as String?;
|
||||
if (selectedId == null) return;
|
||||
ctx.panels.uncontribute('tickets.detail');
|
||||
ctx.panels.contribute(TabContribution(
|
||||
id: 'tickets.detail',
|
||||
slot: Slots.contextPanel,
|
||||
title: 'Ticket',
|
||||
icon: PhosphorIcons.ticket,
|
||||
build: (_) => TicketDetailView(initialId: selectedId),
|
||||
));
|
||||
if (msg.data['id'] is! String) return;
|
||||
ctx.arrangement.setVisible(Slots.contextPanel, true);
|
||||
ctx.arrangement.setCollapsed(Slots.contextPanel, false);
|
||||
ctx.panels.activateTab(Slots.contextPanel, 'tickets.detail');
|
||||
|
||||
@@ -21,13 +21,14 @@ class TicketDetail {
|
||||
}
|
||||
|
||||
class TicketDetailController extends ChangeNotifier {
|
||||
TicketDetailController({required this.ipc, required this.messages, required this.panels}) {
|
||||
_sub = messages.subscribe(publisher: 'builtin.tickets', channel: 'selection').listen(_onSelection);
|
||||
TicketDetailController({required this.ipc, required this.messages}) {
|
||||
// Load on 'load' — the single channel the retained ReaderNav emits
|
||||
// (T-199). The extension reveals the tab; the nav owns history.
|
||||
_sub = messages.subscribe(publisher: 'builtin.tickets', channel: 'load').listen(_onLoad);
|
||||
}
|
||||
|
||||
final DaemonClient ipc;
|
||||
final MessageBus messages;
|
||||
final PanelRegistry panels;
|
||||
StreamSubscription<Message>? _sub;
|
||||
|
||||
TicketDetail? _detail;
|
||||
@@ -36,12 +37,9 @@ class TicketDetailController extends ChangeNotifier {
|
||||
bool _loading = false;
|
||||
bool get loading => _loading;
|
||||
|
||||
void _onSelection(Message msg) {
|
||||
void _onLoad(Message msg) {
|
||||
final id = msg.data['id'] as String?;
|
||||
if (id != null) {
|
||||
panels.activateTab(Slots.contextPanel, 'tickets.detail');
|
||||
unawaited(load(id));
|
||||
}
|
||||
if (id != null) unawaited(load(id));
|
||||
}
|
||||
|
||||
Future<void> load(String id) async {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:clide/builtin/shared/reader_chrome.dart';
|
||||
import 'package:clide/builtin/tickets/src/ticket_colors.dart';
|
||||
import 'package:clide/builtin/tickets/src/ticket_detail_controller.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
@@ -14,24 +15,37 @@ class TicketDetailView extends StatefulWidget {
|
||||
|
||||
class _TicketDetailViewState extends State<TicketDetailView> {
|
||||
TicketDetailController? _controller;
|
||||
ReaderNav? _nav;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_controller = TicketDetailController(ipc: kernel.ipc, messages: kernel.messages, panels: kernel.panels);
|
||||
if (widget.initialId != null) {
|
||||
_controller!.load(widget.initialId!);
|
||||
}
|
||||
_controller = TicketDetailController(ipc: kernel.ipc, messages: kernel.messages);
|
||||
_nav = kernel.readerNav.navFor('builtin.tickets', dataKey: 'id')..addListener(_onNavChanged);
|
||||
// Grab the entry the retained nav already holds (a selection that
|
||||
// revealed this tab before we mounted), else the initialId.
|
||||
final current = _nav!.current ?? widget.initialId;
|
||||
if (current != null) _controller!.load(current);
|
||||
}
|
||||
|
||||
void _onNavChanged() {
|
||||
if (mounted) setState(() {}); // refresh action-bar button state
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nav?.removeListener(_onNavChanged);
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onBack() => _nav?.back();
|
||||
void _onForward() => _nav?.forward();
|
||||
void _onPin() => _nav?.togglePin();
|
||||
void _onJumpToPin() => _nav?.jumpToPin();
|
||||
|
||||
void _navigateToRecord(BuildContext context, String id) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (id.startsWith('T-')) {
|
||||
@@ -56,37 +70,53 @@ class _TicketDetailViewState extends State<TicketDetailView> {
|
||||
final isDark = ClideTheme.of(ctx).dark;
|
||||
final typeColors = TicketTypeColors.forTheme(dark: isDark);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_TicketHeader(detail: d, tokens: tokens, typeColors: typeColors),
|
||||
const SizedBox(height: 12),
|
||||
_StatusControls(detail: d, tokens: tokens, controller: c),
|
||||
if (d.description != null && d.description!.isNotEmpty) ...[
|
||||
return ClidePaneChrome(
|
||||
title: d.id,
|
||||
subtitle: d.title,
|
||||
trailing: [
|
||||
ReaderActionBar(
|
||||
canGoBack: _nav?.canGoBack ?? false,
|
||||
canGoForward: _nav?.canGoForward ?? false,
|
||||
hasPinned: _nav?.hasPinned ?? false,
|
||||
onBack: (_nav?.canGoBack ?? false) ? _onBack : null,
|
||||
onForward: (_nav?.canGoForward ?? false) ? _onForward : null,
|
||||
onPin: _onPin,
|
||||
onJumpToPin: (_nav?.hasPinned ?? false) ? _onJumpToPin : null,
|
||||
onEdit: null, // tickets are pql records, not files
|
||||
),
|
||||
],
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_TicketHeader(detail: d, tokens: tokens, typeColors: typeColors),
|
||||
const SizedBox(height: 12),
|
||||
ClideMarkdown(d.description!, onRecordTap: (id) => _navigateToRecord(ctx, id)),
|
||||
_StatusControls(detail: d, tokens: tokens, controller: c),
|
||||
if (d.description != null && d.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClideMarkdown(d.description!, onRecordTap: (id) => _navigateToRecord(ctx, id)),
|
||||
],
|
||||
if (d.parents.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SectionLabel(label: 'PARENT TREE', tokens: tokens),
|
||||
const SizedBox(height: 6),
|
||||
for (var i = 0; i < d.parents.length; i++)
|
||||
_CompactCard(
|
||||
data: d.parents[i],
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
indent: i,
|
||||
),
|
||||
],
|
||||
if (d.decisions.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SectionLabel(label: 'REFERENCED DECISIONS', tokens: tokens),
|
||||
const SizedBox(height: 6),
|
||||
for (final dec in d.decisions) _DecisionRefCard(data: dec, tokens: tokens),
|
||||
],
|
||||
],
|
||||
if (d.parents.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SectionLabel(label: 'PARENT TREE', tokens: tokens),
|
||||
const SizedBox(height: 6),
|
||||
for (var i = 0; i < d.parents.length; i++)
|
||||
_CompactCard(
|
||||
data: d.parents[i],
|
||||
tokens: tokens,
|
||||
typeColors: typeColors,
|
||||
indent: i,
|
||||
),
|
||||
],
|
||||
if (d.decisions.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SectionLabel(label: 'REFERENCED DECISIONS', tokens: tokens),
|
||||
const SizedBox(height: 6),
|
||||
for (final dec in d.decisions) _DecisionRefCard(data: dec, tokens: tokens),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user