add a reader chrome: back/forward, pin, and edit-pencil

The markdown and decision sidebar readers gain a shared action bar. A new
lib/builtin/shared/reader_chrome.dart provides ReaderHistory (browser-style
back/forward stack — push truncates forward), a ReaderHistoryMixin that also
holds a single pin slot, and a ReaderActionBar widget. Both readers push to
history only on external selection; back/forward and jump-to-pin reload
in-place without re-publishing a selection (no bus churn / no decision-tab
re-trigger). The edit pencil opens the current doc in the editor
(editor.open) — the markdown path, or the decision's file_path.

T-189, T-190, T-191.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-31 18:38:39 +02:00
co-authored by Claude
parent 9d9def3f08
commit 6d0f5f244c
9 changed files with 1451 additions and 68 deletions
@@ -2318,3 +2318,6 @@ Add a per-test timeout to the gate''s flutter test (e.g. ci/test.sh: ''flutter t
Add a per-test timeout to the gate''s flutter test (e.g. ci/test.sh: ''flutter test --coverage --timeout 60s''). Motivation discovered 2026-05-31: a hung pumpAndSettle has a 10-MINUTE default timeout, so a few hanging widget tests wedge make push-check for 30+ minutes — which is almost certainly why a MacBook session resorted to ''git push --no-verify'' (the gate never returned; it was not disabled, it was hung). A tight per-test timeout makes any future hang fail fast (60s) instead of wedging a pre-push, so the gate stays usable and nobody is tempted to bypass it. Pair with: keep writing pump()-bounded tests instead of pumpAndSettle() where a view has overlapping async loads (see the T-188 decision_reader_test hang).
Measured 2026-05-31 (16-core box, warm cache, full flutter suite --exclude-tags pty): coverage default-concurrency = ~36s; coverage --concurrency=12 = ~37s (NO improvement — coverage runs are concurrency-insensitive, the instrumentation/collection dominates); NO-coverage --concurrency=12 = ~21s. Conclusions: (1) Concurrency only helps the NON-coverage path — do NOT bother adding --concurrency to the coverage gate, it buys nothing. (2) The dev inner-loop win is concrete: a no-coverage ''make test'' with --concurrency=12 runs ~21s vs ~36s today (~40% faster) — this is how we ''beat 40s'' for the edit loop. (3) The push-check coverage run is floored at ~36s by coverage itself; getting below that needs a different coverage approach (package:coverage via VM service, or coverage-on-changed-files) — out of scope for the quick win. So the actionable set is unchanged: no-coverage+concurrency dev target, coverage-only-in-push, drop the redundant a11y pass, add a per-test --timeout for hang-safety.', NULL, '2026-05-31 14:46:39', '2026-05-31 14:46:39', '2026-05-31 14:46:39', NULL, 'b6e7ea80f4abe35cbfc4ba9f2ff80e06', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-189', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 16:13:39', '2026-05-31 16:13:39', '2026-05-31 16:13:39', NULL, '51dba55aeb4ac02a4a4d7e72f09b3e3e', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-191', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 16:13:39', '2026-05-31 16:13:39', '2026-05-31 16:13:39', NULL, '76e72b3d04c0456e8a39d8ca7dba7644', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-190', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 16:13:39', '2026-05-31 16:13:39', '2026-05-31 16:13:39', NULL, 'cc7072933e46f402bf65c02595843185', 1) ON CONFLICT(hash) DO NOTHING;
+4
View File
@@ -2955,3 +2955,7 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
Add a per-test timeout to the gate''s flutter test (e.g. ci/test.sh: ''flutter test --coverage --timeout 60s''). Motivation discovered 2026-05-31: a hung pumpAndSettle has a 10-MINUTE default timeout, so a few hanging widget tests wedge make push-check for 30+ minutes — which is almost certainly why a MacBook session resorted to ''git push --no-verify'' (the gate never returned; it was not disabled, it was hung). A tight per-test timeout makes any future hang fail fast (60s) instead of wedging a pre-push, so the gate stays usable and nobody is tempted to bypass it. Pair with: keep writing pump()-bounded tests instead of pumpAndSettle() where a view has overlapping async loads (see the T-188 decision_reader_test hang).
Measured 2026-05-31 (16-core box, warm cache, full flutter suite --exclude-tags pty): coverage default-concurrency = ~36s; coverage --concurrency=12 = ~37s (NO improvement — coverage runs are concurrency-insensitive, the instrumentation/collection dominates); NO-coverage --concurrency=12 = ~21s. Conclusions: (1) Concurrency only helps the NON-coverage path — do NOT bother adding --concurrency to the coverage gate, it buys nothing. (2) The dev inner-loop win is concrete: a no-coverage ''make test'' with --concurrency=12 runs ~21s vs ~36s today (~40% faster) — this is how we ''beat 40s'' for the edit loop. (3) The push-check coverage run is floored at ~36s by coverage itself; getting below that needs a different coverage approach (package:coverage via VM service, or coverage-on-changed-files) — out of scope for the quick win. So the actionable set is unchanged: no-coverage+concurrency dev target, coverage-only-in-push, drop the redundant a11y pass, add a per-test --timeout for hang-safety.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-31 12:08:42', '2026-05-31 14:46:39', NULL, 'baf9357d919ab245d6e939737a44695a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-193', 'bug', NULL, 'Flaky: transcript_publisher_test ''republishes reader items onto the bus'' fails under the full parallel run', 'test/builtin/claude/transcript_publisher_test.dart ''TranscriptPublisher republishes reader items onto the bus (lead channel + item key)'' fails intermittently during ''make test-coverage'' / push-check (the full parallel flutter test run) but PASSES reliably in isolation (flutter test <file> = 3/3 green). Observed 2026-05-31 — one run failed it, the immediately-preceding agent run passed all 2221. Not caused by T-192 (the coverage path uses default concurrency, unchanged) — it''s a pre-existing timing/ordering flake, likely a MessageBus delivery race: the test asserts a republished item arrived on the lead channel, and under concurrent load the broadcast-stream delivery may not have been awaited deterministically. Fix: make the test await delivery deterministically (pump/await the bus microtask, or expectLater on the stream) rather than relying on timing; check TranscriptPublisher for any real ordering bug too. Impact: intermittently red push-check → tempts --no-verify (see T-192). Acceptance: the test passes deterministically across repeated full-suite runs; root cause (test-only vs a real publisher race) identified.', 'backlog', 'medium', NULL, NULL, NULL, '2026-05-31 16:12:42', '2026-05-31 16:12:42', NULL, '2ef560fd740561a887c132e80b32e27d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-191', 'story', 'T-186', 'Edit pencil in the reader action bar — open the current doc in the editor', 'Since clicking a .md now opens the READER (T-187), give the user a deliberate one-click way to open the SAME doc in the editor in edit mode. Add an edit pencil to the action bar of BOTH sidebar readers (markdown reader, decision reader). The pencil fires the editor path the readers moved away from for plain clicks: kernel.ipc.request(''editor.open'', {path}) on the reader''s current doc. Markdown reader: its current file path. Decision reader: the D-record''s backing file (decisions expose file_path = governance/decisions/<domain>.md via pql decisions read; resolve and open that — note it opens the whole domain file, since a decision is a section within it, unless we later add anchor support). Lives in the same reader chrome as back/forward (T-189) and pin (T-190). Custom chrome only (no Material). Acceptance: a pencil button in each reader opens the currently-shown doc in the editor; widget tests. Sequence with the other reader-chrome work (Wave B).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-31 11:58:19', '2026-05-31 16:13:39', NULL, '46ee324c0653868dcdd3f20ab6e21384', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-189', 'story', 'T-186', 'Back/forward navigation on the markdown & decision readers', 'Add Back/Forward buttons to both sidebar readers so you can return to what you were reading. Neither reader keeps history today: _MarkdownViewerState (markdown_viewer.dart) holds only _path/_content; _DecisionDetailViewState (decision_detail_view.dart) holds only _decision. Add a history stack + index to each, pushing on each new open; Back/Forward walk the stack and load the entry WITHOUT re-publishing a ''selection'' (in-widget navigation). For the decision reader, back/forward must not trigger the extension tab re-contribution — coordinate with the decision-open bug fix (T-188), which should stop re-contributing per selection. Custom chrome only (no Material). Acceptance: Back/Forward controls on both readers walk reading history and disable at the ends; widget tests. Sequence after the reader-open bug fixes (T-187/T-188) since it shares those widgets.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-31 11:38:36', '2026-05-31 16:13:39', NULL, '8e8e53cd7d4bb35535281c433e8d122d', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-190', 'story', 'T-186', 'Pin a doc in the markdown & decision readers (single-slot quick-return)', 'Add a one-click PIN to both sidebar readers. Semantics per the reporter: a SINGLE pin slot per reader — clicking pin remembers the CURRENT doc; setting a new pin REPLACES the previous one (no list to manage); the pinned doc is always one click away to return to, so it is easy to use inline as you work. It is a quick-return bookmark, NOT a freeze-incoming toggle. Add a pinned-doc ref to _MarkdownViewerState and _DecisionDetailViewState; a pin button (set/replace) plus a jump-to-pin affordance in the reader chrome; persist for at least the session. Custom chrome only (no Material). Acceptance: pinning a doc lets you one-click return to it later; pinning another replaces it; works on both readers; widget tests. Sequence after the reader-open bug fixes (T-187/T-188); pairs naturally with back/forward (T-189).', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-31 11:38:42', '2026-05-31 16:13:39', NULL, 'd0ec78c056f0d7f8e35e521f13d229ed', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+6
View File
@@ -16,6 +16,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
### Added
- Sidebar readers (markdown + decision) gain a chrome action bar: back/forward
history, a single-slot pin (one click sets/replaces; one click to return),
and an edit pencil that opens the current doc in the editor. (T-189, T-190, T-191)
### Fixed
- The composer slash typeahead now lists clide-owned commands — `/resume` and
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:clide/builtin/decisions/src/decision_colors.dart';
import 'package:clide/builtin/shared/reader_chrome.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
@@ -13,7 +14,7 @@ class DecisionDetailView extends StatefulWidget {
State<DecisionDetailView> createState() => _DecisionDetailViewState();
}
class _DecisionDetailViewState extends State<DecisionDetailView> {
class _DecisionDetailViewState extends State<DecisionDetailView> with ReaderHistoryMixin<String, DecisionDetailView> {
Map<String, Object?>? _decision;
bool _loading = false;
StreamSubscription<Message>? _sub;
@@ -38,6 +39,7 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
super.dispose();
}
/// Load [id] and push it onto the history stack (external navigation).
Future<void> _load(String id) async {
setState(() => _loading = true);
final kernel = ClideKernel.of(context);
@@ -50,6 +52,48 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
_loading = false;
_decision = resp.ok ? resp.data : null;
});
if (resp.ok) historyPush(id);
}
/// Load [id] WITHOUT pushing onto the history stack (back/forward nav).
Future<void> _loadInPlace(String id) async {
setState(() => _loading = true);
final kernel = ClideKernel.of(context);
final resp = await kernel.ipc.request('pql.decisions.read', args: {'id': id});
if (!mounted) return;
if (resp.ok) {
kernel.messages.publish('builtin.decisions', 'focus', {'id': id});
}
setState(() {
_loading = false;
_decision = resp.ok ? resp.data : null;
});
}
void _onBack() {
final entry = historyBack();
if (entry != null) _loadInPlace(entry);
}
void _onForward() {
final entry = historyForward();
if (entry != null) _loadInPlace(entry);
}
void _onPin() {
pinCurrent();
}
void _onJumpToPin() {
final entry = jumpToPin();
if (entry != null) _loadInPlace(entry);
}
void _onEdit() {
final filePath = _decision?['file_path'] as String?;
if (filePath == null) return;
final kernel = ClideKernel.of(context);
unawaited(kernel.ipc.request('editor.open', args: {'path': filePath}));
}
void _navigateToRecord(BuildContext context, String id) {
@@ -80,63 +124,80 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
final body = d['body'] as String?;
final refs = (d['refs'] as List?)?.cast<Map<String, Object?>>() ?? const [];
final typeColor = typeColors.forType(type);
final filePath = d['file_path'] as String?;
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: tokens.panelBackground,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: tokens.panelBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ClideTooltip(
message: type ?? 'confirmed',
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
),
const SizedBox(width: 8),
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
const Spacer(),
if (domain != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: tokens.panelBorder, borderRadius: BorderRadius.circular(3)),
child: ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
return ClidePaneChrome(
title: id,
subtitle: title,
trailing: [
ReaderActionBar(
canGoBack: canGoBack,
canGoForward: canGoForward,
hasPinned: hasPinned,
onBack: canGoBack ? _onBack : null,
onForward: canGoForward ? _onForward : null,
onPin: _decision != null ? _onPin : null,
onJumpToPin: hasPinned ? _onJumpToPin : null,
onEdit: filePath != null ? _onEdit : null,
),
],
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: tokens.panelBackground,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: tokens.panelBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ClideTooltip(
message: type ?? 'confirmed',
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle)),
),
],
),
const SizedBox(height: 8),
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
if (date != null) ...[
const SizedBox(height: 6),
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
],
if (status != null && status != 'active') ...[
const SizedBox(width: 8),
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
const Spacer(),
if (domain != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: tokens.panelBorder, borderRadius: BorderRadius.circular(3)),
child: ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
),
],
),
const SizedBox(height: 8),
_StatusBadge(status: status, tokens: tokens),
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
if (date != null) ...[
const SizedBox(height: 6),
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily),
],
if (status != null && status != 'active') ...[
const SizedBox(height: 8),
_StatusBadge(status: status, tokens: tokens),
],
],
],
),
),
),
if (body != null && body.isNotEmpty) ...[
const SizedBox(height: 12),
ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id)),
if (body != null && body.isNotEmpty) ...[
const SizedBox(height: 12),
ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id)),
],
if (refs.isNotEmpty) ...[
const SizedBox(height: 16),
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
const SizedBox(height: 6),
for (final ref in refs) _RefCard(ref: ref, tokens: tokens),
],
],
if (refs.isNotEmpty) ...[
const SizedBox(height: 16),
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
const SizedBox(height: 6),
for (final ref in refs) _RefCard(ref: ref, tokens: tokens),
],
],
),
),
);
}
+62 -1
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:clide/builtin/shared/reader_chrome.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
@@ -11,7 +12,7 @@ class MarkdownViewer extends StatefulWidget {
State<MarkdownViewer> createState() => _MarkdownViewerState();
}
class _MarkdownViewerState extends State<MarkdownViewer> {
class _MarkdownViewerState extends State<MarkdownViewer> with ReaderHistoryMixin<String, MarkdownViewer> {
String? _path;
String? _content;
String? _error;
@@ -34,7 +35,26 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
super.dispose();
}
/// Load [path] and push it onto the history stack (external navigation).
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) {
kernel.messages.publish('builtin.markdown', 'focus', {'path': path});
setState(() {
_path = path;
_content = resp.data['content'] as String? ?? '';
_error = null;
});
historyPush(path);
} else {
setState(() => _error = resp.error?.message);
}
}
/// Load [path] WITHOUT pushing onto the history stack (back/forward nav).
Future<void> _loadFileInPlace(String path) async {
final kernel = ClideKernel.of(context);
final resp = await kernel.ipc.request('files.read', args: {'path': path});
if (!mounted) return;
@@ -50,6 +70,35 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
}
}
void _onBack() {
final entry = historyBack();
if (entry != null) _loadFileInPlace(entry);
}
void _onForward() {
final entry = historyForward();
if (entry != null) _loadFileInPlace(entry);
}
void _onPin() {
pinCurrent();
}
void _onJumpToPin() {
final entry = jumpToPin();
if (entry != null) {
// Navigate directly without re-pushing (pin jump is a quick-return).
_loadFileInPlace(entry);
}
}
void _onEdit() {
final p = _path;
if (p == null) return;
final kernel = ClideKernel.of(context);
unawaited(kernel.ipc.request('editor.open', args: {'path': p}));
}
void _navigateToRecord(BuildContext context, String id) {
final kernel = ClideKernel.of(context);
if (id.toLowerCase().endsWith('.md')) {
@@ -76,6 +125,18 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
return ClidePaneChrome(
title: _path ?? 'viewer',
subtitle: '${_content!.split('\n').length} lines',
trailing: [
ReaderActionBar(
canGoBack: canGoBack,
canGoForward: canGoForward,
hasPinned: hasPinned,
onBack: canGoBack ? _onBack : null,
onForward: canGoForward ? _onForward : null,
onPin: _path != null ? _onPin : null,
onJumpToPin: hasPinned ? _onJumpToPin : null,
onEdit: _path != null ? _onEdit : null,
),
],
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: ClideMarkdown(_content!, onRecordTap: (id) => _navigateToRecord(context, id)),
+325
View File
@@ -0,0 +1,325 @@
/// Shared action-bar chrome for sidebar reader widgets (T-189, T-190, T-191).
///
/// Provides:
/// - [ReaderHistory] — back/forward history stack with browser semantics.
/// - [ReaderActionBar] — the visible action bar widget: back, forward, pin,
/// edit pencil. Plugs into a [ClidePaneChrome] via its `trailing:` slot.
///
/// Usage: mix [ReaderHistoryMixin] into a [State] to get back/forward/pin state
/// management, then put a [ReaderActionBar] in [ClidePaneChrome.trailing].
library;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
// ---------------------------------------------------------------------------
// History model
// ---------------------------------------------------------------------------
/// Back/forward history stack. [T] is the entry type (String path for
/// markdown, String id for decisions).
///
/// Standard browser semantics:
/// - [push] appends at [_index+1] and truncates any forward entries.
/// - [back]/[forward] adjust the index without re-pushing.
/// - [canGoBack]/[canGoForward] drive the enabled state of the buttons.
class ReaderHistory<T> {
final List<T> _stack = [];
int _index = -1;
bool get canGoBack => _index > 0;
bool get canGoForward => _index < _stack.length - 1;
T? get current => _index >= 0 && _index < _stack.length ? _stack[_index] : null;
/// Push a new entry, truncating any forward history.
void push(T entry) {
if (_index >= 0 && _stack[_index] == entry) {
// Same entry as current — don't push a duplicate.
return;
}
// Truncate forward history.
if (_index < _stack.length - 1) {
_stack.removeRange(_index + 1, _stack.length);
}
_stack.add(entry);
_index = _stack.length - 1;
}
/// Move back one step. Returns the entry now current, or null.
T? back() {
if (!canGoBack) return null;
_index--;
return _stack[_index];
}
/// Move forward one step. Returns the entry now current, or null.
T? forward() {
if (!canGoForward) return null;
_index++;
return _stack[_index];
}
}
// ---------------------------------------------------------------------------
// Mixin
// ---------------------------------------------------------------------------
/// Mix into a [State] that owns a [ReaderHistory] and an optional pin.
///
/// The concrete state must call [historyPush] whenever it loads a new
/// entry (NOT when navigating back/forward — those call [historyBack] /
/// [historyForward] and then load the returned entry without re-pushing).
mixin ReaderHistoryMixin<T, W extends StatefulWidget> on State<W> {
final ReaderHistory<T> _history = ReaderHistory<T>();
T? _pinned;
bool get canGoBack => _history.canGoBack;
bool get canGoForward => _history.canGoForward;
bool get hasPinned => _pinned != null;
T? get pinnedEntry => _pinned;
/// Record that the reader is now showing [entry]. Must be called AFTER
/// setState has been applied so the action-bar buttons rebuild.
void historyPush(T entry) {
setState(() => _history.push(entry));
}
/// Navigate back. Returns the entry to load, or null if already at start.
T? historyBack() {
final entry = _history.back();
if (entry != null) setState(() {});
return entry;
}
/// Navigate forward. Returns the entry to load, or null if at end.
T? historyForward() {
final entry = _history.forward();
if (entry != null) setState(() {});
return entry;
}
/// Set or replace the pin with the current entry.
void pinCurrent() {
final cur = _history.current;
if (cur == null) return;
setState(() => _pinned = cur);
}
/// Returns the pinned entry, or null if none set.
T? jumpToPin() {
return _pinned;
}
}
// ---------------------------------------------------------------------------
// Action bar widget
// ---------------------------------------------------------------------------
/// A row of reader-chrome action buttons: back, forward, pin (set/jump), edit.
///
/// Designed to plug into [ClidePaneChrome.trailing]. All callbacks are
/// optional — pass null to hide/disable the corresponding button.
class ReaderActionBar extends StatelessWidget {
const ReaderActionBar({
super.key,
required this.canGoBack,
required this.canGoForward,
required this.hasPinned,
required this.onBack,
required this.onForward,
required this.onPin,
required this.onJumpToPin,
required this.onEdit,
});
final bool canGoBack;
final bool canGoForward;
final bool hasPinned;
final VoidCallback? onBack;
final VoidCallback? onForward;
/// Called when the pin button is tapped (set / replace current pin).
final VoidCallback? onPin;
/// Called when the jump-to-pin affordance is tapped.
final VoidCallback? onJumpToPin;
/// Called when the edit pencil is tapped. Pass null to hide the button.
final VoidCallback? onEdit;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_ActionButton(
painter: PhosphorIcons.caretLeft,
tooltip: 'Back',
enabled: canGoBack,
onTap: canGoBack ? onBack : null,
tokens: tokens,
),
const SizedBox(width: 2),
_ActionButton(
painter: PhosphorIcons.caretRight,
tooltip: 'Forward',
enabled: canGoForward,
onTap: canGoForward ? onForward : null,
tokens: tokens,
),
const SizedBox(width: 4),
_PinButton(
hasPinned: hasPinned,
onPin: onPin,
onJumpToPin: onJumpToPin,
tokens: tokens,
),
if (onEdit != null) ...[
const SizedBox(width: 2),
_ActionButton(
painter: PhosphorIcons.pencilSimple,
tooltip: 'Edit in editor',
enabled: true,
onTap: onEdit,
tokens: tokens,
),
],
],
);
}
}
// ---------------------------------------------------------------------------
// Private button widgets
// ---------------------------------------------------------------------------
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.painter,
required this.tooltip,
required this.enabled,
required this.onTap,
required this.tokens,
});
final ClideIconPainter painter;
final String tooltip;
final bool enabled;
final VoidCallback? onTap;
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: tooltip,
enabled: enabled,
onTap: onTap,
excludeSemantics: true,
child: ClideTappable(
onTap: onTap,
tooltip: tooltip,
builder: (ctx, hovered, _) => Container(
width: 20,
height: 20,
alignment: Alignment.center,
decoration: BoxDecoration(
color: hovered && enabled ? tokens.sidebarItemHover : null,
borderRadius: BorderRadius.circular(3),
),
child: ClideIcon(
painter,
size: 11,
color: enabled ? tokens.panelHeaderForeground : tokens.globalTextMuted,
),
),
),
);
}
}
/// Pin button: when [hasPinned] is true it shows the pin "filled" and the
/// button activates jump-to-pin; a long-press (or secondary tap) sets a new
/// pin. When [hasPinned] is false the single tap sets the pin.
///
/// For simplicity (and to keep the widget API flat): a single tap ALWAYS sets
/// the pin (replacing the previous one), while the adjacent jump-to-pin uses a
/// separate Tappable rendered as a small indicator badge.
class _PinButton extends StatelessWidget {
const _PinButton({
required this.hasPinned,
required this.onPin,
required this.onJumpToPin,
required this.tokens,
});
final bool hasPinned;
final VoidCallback? onPin;
final VoidCallback? onJumpToPin;
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// The pin-set button (always shown, sets/replaces the pin).
Semantics(
button: true,
label: hasPinned ? 'Replace pin' : 'Pin current',
onTap: onPin,
excludeSemantics: true,
child: ClideTappable(
onTap: onPin,
tooltip: hasPinned ? 'Replace pin' : 'Pin current',
builder: (ctx, hovered, _) => Container(
width: 20,
height: 20,
alignment: Alignment.center,
decoration: BoxDecoration(
color: hovered ? tokens.sidebarItemHover : null,
borderRadius: BorderRadius.circular(3),
),
child: ClideIcon(
PhosphorIcons.link,
size: 11,
color: hasPinned ? tokens.globalFocus : tokens.panelHeaderForeground,
),
),
),
),
// Jump-to-pin affordance — only visible when a pin is set.
if (hasPinned) ...[
const SizedBox(width: 1),
Semantics(
button: true,
label: 'Jump to pin',
onTap: onJumpToPin,
excludeSemantics: true,
child: ClideTappable(
onTap: onJumpToPin,
tooltip: 'Jump to pin',
builder: (ctx, hovered, _) => Container(
width: 16,
height: 16,
alignment: Alignment.center,
decoration: BoxDecoration(
color: hovered ? tokens.sidebarItemHover : tokens.panelBorder,
borderRadius: BorderRadius.circular(2),
),
child: ClideText(
'',
fontSize: 9,
color: tokens.globalFocus,
),
),
),
),
],
],
);
}
}
+385 -14
View File
@@ -1,4 +1,5 @@
/// T-188: Decision reader — static-tab fix.
/// T-189, T-190, T-191: back/forward, pin, edit pencil in DecisionDetailView.
///
/// Verifies that clicking a decision opens it in the static `decisions.detail`
/// context-panel tab (no per-click uncontribute/contribute churn), that a
@@ -61,7 +62,7 @@ void _select(KernelFixture f, String id) {
// IPC stub: pql.decisions.read
// ---------------------------------------------------------------------------
IpcResponse _decisionResponse(String id) => IpcResponse.ok(
IpcResponse _decisionResponse(String id, {String? filePath}) => IpcResponse.ok(
id: '',
data: {
'id': id,
@@ -72,6 +73,7 @@ IpcResponse _decisionResponse(String id) => IpcResponse.ok(
'date': '2026-01-01',
'body': 'Body of $id.',
'refs': <Object?>[],
'file_path': filePath ?? 'governance/decisions/architecture.md',
},
);
@@ -224,8 +226,10 @@ void main() {
testWidgets('loads and renders initialId on first mount', (tester) async {
await pumpView(tester, initialId: 'D-1');
expect(find.text('D-1'), findsOneWidget);
expect(find.text('Decision D-1'), findsOneWidget);
// The id appears in the pane-chrome title AND in the body card, so
// findsWidgets (≥1) is the right matcher.
expect(find.text('D-1'), findsWidgets);
expect(find.text('Decision D-1'), findsWidgets);
});
testWidgets('selection message loads a decision into the view', (tester) async {
@@ -238,30 +242,31 @@ void main() {
// Give the broadcast stream a microtask to deliver.
await pumpAsync(tester);
expect(find.text('D-7'), findsOneWidget);
expect(find.text('Decision D-7'), findsOneWidget);
// Id appears in both pane header and body card.
expect(find.text('D-7'), findsWidgets);
expect(find.text('Decision D-7'), findsWidgets);
});
testWidgets('second selection switches the displayed decision', (tester) async {
await pumpView(tester, initialId: 'D-1');
expect(find.text('Decision D-1'), findsOneWidget);
expect(find.text('Decision D-1'), findsWidgets);
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
await pumpAsync(tester);
expect(find.text('Decision D-2'), findsOneWidget);
expect(find.text('Decision D-2'), findsWidgets);
expect(find.text('Decision D-1'), findsNothing);
});
testWidgets('clicking the same decision twice leaves view stable', (tester) async {
await pumpView(tester, initialId: 'D-5');
expect(find.text('Decision D-5'), findsOneWidget);
expect(find.text('Decision D-5'), findsWidgets);
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-5'});
await pumpAsync(tester);
// Still shows D-5, no crash, no duplicate.
expect(find.text('Decision D-5'), findsOneWidget);
// Still shows D-5, no crash.
expect(find.text('Decision D-5'), findsWidgets);
});
testWidgets('rapid sequential selections resolve to the last one', (tester) async {
@@ -274,7 +279,8 @@ void main() {
// The last resolved data should be for D-5 (stub is synchronous so
// each load completes before the next, but all 5 fire in order).
expect(find.text('Decision D-5'), findsOneWidget);
// Title appears in pane header subtitle + body card.
expect(find.text('Decision D-5'), findsWidgets);
});
testWidgets('cross-reference tap publishes a new selection', (tester) async {
@@ -295,6 +301,7 @@ void main() {
'refs': [
{'target_id': 'D-11', 'ref_type': 'implements'},
],
'file_path': 'governance/decisions/architecture.md',
},
);
}
@@ -302,7 +309,8 @@ void main() {
});
await pumpView(tester, initialId: 'D-10');
expect(find.text('D-10'), findsOneWidget);
// D-10 appears in pane title + body card; D-11 appears only in the ref card.
expect(find.text('D-10'), findsWidgets);
expect(find.text('D-11'), findsOneWidget);
// Tap the ref card — it should publish selection for D-11.
@@ -349,6 +357,7 @@ void main() {
'date': '2026-01-01',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/questions/architecture.md',
},
));
@@ -371,6 +380,7 @@ void main() {
'date': '2026-01-01',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/questions/architecture.md',
},
));
@@ -393,6 +403,7 @@ void main() {
'date': '2026-01-01',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/decisions/architecture.md',
},
));
@@ -417,6 +428,7 @@ void main() {
'refs': [
{'source_id': 'D-5', 'ref_type': 'amends'},
],
'file_path': 'governance/decisions/architecture.md',
},
));
@@ -445,6 +457,7 @@ void main() {
'refs': [
{'target_id': 'T-123', 'ref_type': 'tracked-by'},
],
'file_path': 'governance/decisions/architecture.md',
},
));
@@ -476,12 +489,14 @@ void main() {
'date': '2026-01-15',
'body': 'This is the decision body text.',
'refs': <Object?>[],
'file_path': 'governance/decisions/architecture.md',
},
));
await pumpView(tester, initialId: 'D-50');
expect(find.text('D-50'), findsOneWidget);
// D-50 appears in pane title + body card.
expect(find.text('D-50'), findsWidgets);
expect(find.text('2026-01-15'), findsOneWidget);
});
@@ -498,13 +513,369 @@ void main() {
'status': 'active',
'body': '',
'refs': <Object?>[],
'file_path': 'governance/decisions/architecture.md',
},
));
await pumpView(tester, initialId: 'D-60');
expect(find.text('D-60'), findsOneWidget);
// D-60 appears in pane title + body card.
expect(find.text('D-60'), findsWidgets);
// No date text node — no crash.
});
});
// -------------------------------------------------------------------------
// T-189: back/forward navigation
// -------------------------------------------------------------------------
group('DecisionDetailView — back/forward (T-189)', () {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create();
f.ipc.stub('pql.decisions.read', (args) async {
final id = args['id'] as String? ?? 'unknown';
return _decisionResponse(id);
});
});
tearDown(() => f.dispose());
Future<void> pumpView(WidgetTester tester, {String? initialId}) async {
tester.view.physicalSize = const Size(600, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(harness(f, DecisionDetailView(initialId: initialId)));
await pumpAsync(tester);
}
testWidgets('back disabled on initial load', (tester) async {
await pumpView(tester, initialId: 'D-1');
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && w.properties.enabled == false,
),
findsOneWidget,
);
});
testWidgets('back enabled after two selections', (tester) async {
await pumpView(tester, initialId: 'D-1');
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
await pumpAsync(tester);
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
),
findsWidgets,
);
});
testWidgets('back navigates to previous decision', (tester) async {
await pumpView(tester, initialId: 'D-1');
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
await pumpAsync(tester);
// Title appears in pane header subtitle + body card.
expect(find.text('Decision D-2'), findsWidgets);
final backBtn = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
await tester.tap(backBtn.first);
await pumpAsync(tester);
expect(find.text('Decision D-1'), findsWidgets);
});
testWidgets('back/forward does NOT re-publish selection bus event', (tester) async {
await pumpView(tester, initialId: 'D-1');
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
await pumpAsync(tester);
final selections = <Message>[];
final sub = f.services.messages.subscribe(publisher: 'builtin.decisions', channel: 'selection').listen(selections.add);
addTearDown(sub.cancel);
// Go back — should NOT publish a selection message.
final backBtn = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
await tester.tap(backBtn.first);
await pumpAsync(tester);
expect(selections, isEmpty, reason: 'back/forward must not churn the selection bus');
});
testWidgets('forward disabled at end of history', (tester) async {
await pumpView(tester, initialId: 'D-1');
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
await pumpAsync(tester);
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
),
findsOneWidget,
);
});
testWidgets('forward navigates after back', (tester) async {
await pumpView(tester, initialId: 'D-1');
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
await pumpAsync(tester);
// Go back to D-1.
final backBtn = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
await tester.tap(backBtn.first);
await pumpAsync(tester);
expect(find.text('Decision D-1'), findsWidgets);
// Go forward to D-2.
final fwdBtn = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true),
);
await tester.tap(fwdBtn.first);
await pumpAsync(tester);
expect(find.text('Decision D-2'), findsWidgets);
});
testWidgets('new selection truncates forward history', (tester) async {
await pumpView(tester, initialId: 'D-1');
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
await pumpAsync(tester);
// Go back to D-1.
final backBtn = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
await tester.tap(backBtn.first);
await pumpAsync(tester);
// Load D-3 — truncates D-2 forward history.
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-3'});
await pumpAsync(tester);
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
),
findsOneWidget,
);
});
});
// -------------------------------------------------------------------------
// T-190: pin
// -------------------------------------------------------------------------
group('DecisionDetailView — pin (T-190)', () {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create();
f.ipc.stub('pql.decisions.read', (args) async {
final id = args['id'] as String? ?? 'unknown';
return _decisionResponse(id);
});
});
tearDown(() => f.dispose());
Future<void> pumpView(WidgetTester tester, {String? initialId}) async {
tester.view.physicalSize = const Size(600, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(harness(f, DecisionDetailView(initialId: initialId)));
await pumpAsync(tester);
}
testWidgets('pin jump affordance not visible before pin set', (tester) async {
await pumpView(tester, initialId: 'D-1');
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsNothing,
);
});
testWidgets('pin current shows jump-to-pin affordance', (tester) async {
await pumpView(tester, initialId: 'D-1');
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin current',
)
.first);
await pumpAsync(tester);
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsOneWidget,
);
});
testWidgets('jump to pin loads the pinned decision', (tester) async {
await pumpView(tester, initialId: 'D-1');
// Pin D-1.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin current',
)
.first);
await pumpAsync(tester);
// Navigate to D-2.
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
await pumpAsync(tester);
// Title appears in pane header subtitle + body card.
expect(find.text('Decision D-2'), findsWidgets);
// Jump to pin.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Jump to pin',
)
.first);
await pumpAsync(tester);
expect(find.text('Decision D-1'), findsWidgets);
});
testWidgets('pin replaces previous pin', (tester) async {
await pumpView(tester, initialId: 'D-1');
// Pin D-1.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin current',
)
.first);
await pumpAsync(tester);
// Navigate to D-2.
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-2'});
await pumpAsync(tester);
// Replace pin with D-2.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Replace pin',
)
.first);
await pumpAsync(tester);
// Navigate to D-3.
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-3'});
await pumpAsync(tester);
// Jump to pin — should go to D-2 (replaced), not D-1.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Jump to pin',
)
.first);
await pumpAsync(tester);
// Title appears in pane header subtitle + body card.
expect(find.text('Decision D-2'), findsWidgets);
});
});
// -------------------------------------------------------------------------
// T-191: edit pencil
// -------------------------------------------------------------------------
group('DecisionDetailView — edit pencil (T-191)', () {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create();
});
tearDown(() => f.dispose());
Future<void> pumpView(WidgetTester tester, {String? initialId}) async {
tester.view.physicalSize = const Size(600, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(harness(f, DecisionDetailView(initialId: initialId)));
await pumpAsync(tester);
}
testWidgets('edit pencil not visible when no decision loaded', (tester) async {
await pumpView(tester);
// Placeholder state — no chrome.
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
findsNothing,
);
});
testWidgets('edit pencil fires editor.open with file_path from decision', (tester) async {
const filePath = 'governance/decisions/architecture.md';
f.ipc.stub('pql.decisions.read', (args) async {
return _decisionResponse('D-1', filePath: filePath);
});
final editorOpenArgs = <Map<String, Object?>>[];
f.ipc.stub('editor.open', (args) async {
editorOpenArgs.add(args);
return IpcResponse.ok(id: '', data: {});
});
await pumpView(tester, initialId: 'D-1');
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
findsOneWidget,
);
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Edit in editor',
)
.first);
await pumpAsync(tester);
expect(editorOpenArgs, hasLength(1));
expect(editorOpenArgs.first['path'], filePath);
});
testWidgets('edit pencil hidden when file_path is absent from response', (tester) async {
f.ipc.stub('pql.decisions.read', (args) async {
return IpcResponse.ok(
id: '',
data: {
'id': 'D-70',
'title': 'No file path',
'type': 'confirmed',
'domain': 'architecture',
'status': 'active',
'date': '2026-01-01',
'body': '',
'refs': <Object?>[],
// No file_path key.
},
);
});
await pumpView(tester, initialId: 'D-70');
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
findsNothing,
);
});
});
}
+331 -1
View File
@@ -1,15 +1,19 @@
/// Widget tests for MarkdownViewer (T-187).
/// Widget tests for MarkdownViewer (T-187, T-189, T-190, T-191).
///
/// Covers:
/// - .md wiki-link via onRecordTap publishes ('builtin.markdown','selection')
/// - T- link routes to tickets publisher
/// - D- link routes to decisions publisher
/// - dead editor.buffer_activated fallback is gone (no _editorSub)
/// - T-189: back/forward navigation (history stack)
/// - T-190: pin set/replace/jump
/// - T-191: edit pencil fires editor.open
library;
import 'package:clide/builtin/markdown/src/markdown_viewer.dart';
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
@@ -25,6 +29,33 @@ void _stubRead(KernelFixture f, String path, String content) {
});
}
/// Stub files.read with a map of path → content.
void _stubReadMap(KernelFixture f, Map<String, String> paths) {
f.ipc.stub('files.read', (args) async {
final path = args['path'] as String? ?? '';
if (paths.containsKey(path)) return _ok({'content': paths[path]!});
return IpcResponse.err(id: '', error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: 'not found'));
});
}
/// Helper: pump the MarkdownViewer widget.
Future<void> pumpView(WidgetTester tester, KernelFixture f) async {
tester.view.physicalSize = const Size(800, 600);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(harness(f, const MarkdownViewer()));
await pumpAsync(tester);
}
/// Trigger a file load via the 'load' channel (mirrors the extension bridge).
Future<void> loadFile(WidgetTester tester, KernelFixture f, String path) async {
f.services.messages.publish('builtin.markdown', 'load', {'path': path});
await pumpAsync(tester);
}
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
@@ -142,4 +173,303 @@ void main() {
semantics.dispose();
});
});
// -------------------------------------------------------------------------
// T-189: back/forward navigation
// -------------------------------------------------------------------------
group('MarkdownViewer — back/forward (T-189)', () {
testWidgets('back button disabled on initial load', (tester) async {
_stubRead(f, 'a.md', '# A');
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
// Back should be disabled — the button is in a disabled state (no back entry).
// We verify via the action-bar semantics label.
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && w.properties.enabled == false,
),
findsOneWidget,
);
});
testWidgets('back enabled after loading two files', (tester) async {
_stubReadMap(f, {'a.md': '# A', 'b.md': '# B'});
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
await loadFile(tester, f, 'b.md');
// Back should be enabled.
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
),
findsWidgets,
);
});
testWidgets('back navigates to previous file', (tester) async {
_stubReadMap(f, {'a.md': 'File A content', 'b.md': 'File B content'});
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
await loadFile(tester, f, 'b.md');
// Currently showing B.
expect(find.text('b.md'), findsOneWidget);
// Tap Back.
final backButton = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
await tester.tap(backButton.first);
await pumpAsync(tester);
// Should now show a.md in the title.
expect(find.text('a.md'), findsOneWidget);
});
testWidgets('forward disabled at end of history', (tester) async {
_stubReadMap(f, {'a.md': '# A', 'b.md': '# B'});
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
await loadFile(tester, f, 'b.md');
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
),
findsOneWidget,
);
});
testWidgets('forward enabled after going back', (tester) async {
_stubReadMap(f, {'a.md': 'A', 'b.md': 'B'});
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
await loadFile(tester, f, 'b.md');
// Go back.
final backButton = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
await tester.tap(backButton.first);
await pumpAsync(tester);
// Forward should now be enabled.
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true),
),
findsWidgets,
);
});
testWidgets('forward navigates to next file after back', (tester) async {
_stubReadMap(f, {'a.md': 'A text', 'b.md': 'B text'});
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
await loadFile(tester, f, 'b.md');
// Go back to a.md.
final backButton = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
await tester.tap(backButton.first);
await pumpAsync(tester);
expect(find.text('a.md'), findsOneWidget);
// Go forward to b.md.
final fwdButton = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && (w.properties.enabled ?? true),
);
await tester.tap(fwdButton.first);
await pumpAsync(tester);
expect(find.text('b.md'), findsOneWidget);
});
testWidgets('loading new file truncates forward history', (tester) async {
_stubReadMap(f, {'a.md': 'A', 'b.md': 'B', 'c.md': 'C'});
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
await loadFile(tester, f, 'b.md');
// Go back to a.md.
final backButton = find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Back' && (w.properties.enabled ?? true),
);
await tester.tap(backButton.first);
await pumpAsync(tester);
// Load c.md — truncates forward history (b.md).
await loadFile(tester, f, 'c.md');
// Forward should now be disabled (b.md was truncated).
expect(
find.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Forward' && w.properties.enabled == false,
),
findsOneWidget,
);
});
});
// -------------------------------------------------------------------------
// T-190: pin
// -------------------------------------------------------------------------
group('MarkdownViewer — pin (T-190)', () {
testWidgets('pin button present when file is loaded', (tester) async {
_stubRead(f, 'a.md', '# A');
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
expect(
find.byWidgetPredicate((w) => w is Semantics && (w.properties.label == 'Pin current' || w.properties.label == 'Replace pin')),
findsOneWidget,
);
});
testWidgets('pin jump affordance not visible before pin is set', (tester) async {
_stubRead(f, 'a.md', '# A');
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsNothing,
);
});
testWidgets('pin current shows jump-to-pin affordance', (tester) async {
_stubReadMap(f, {'a.md': 'A', 'b.md': 'B'});
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
// Tap Pin current.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin current',
)
.first);
await pumpAsync(tester);
// Jump-to-pin affordance should now be visible.
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
findsOneWidget,
);
});
testWidgets('jump to pin loads the pinned file', (tester) async {
_stubReadMap(f, {'a.md': 'File A', 'b.md': 'File B'});
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
// Pin a.md.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin current',
)
.first);
await pumpAsync(tester);
// Navigate to b.md.
await loadFile(tester, f, 'b.md');
expect(find.text('b.md'), findsOneWidget);
// Jump to pin.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Jump to pin',
)
.first);
await pumpAsync(tester);
// Should be back at a.md.
expect(find.text('a.md'), findsOneWidget);
});
testWidgets('pin replaces previous pin', (tester) async {
_stubReadMap(f, {'a.md': 'A', 'b.md': 'B', 'c.md': 'C'});
await pumpView(tester, f);
await loadFile(tester, f, 'a.md');
// Pin a.md.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Pin current',
)
.first);
await pumpAsync(tester);
// Navigate to b.md.
await loadFile(tester, f, 'b.md');
// Replace pin with b.md.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Replace pin',
)
.first);
await pumpAsync(tester);
// Navigate to c.md.
await loadFile(tester, f, 'c.md');
// Jump to pin should go to b.md now (replaced), not a.md.
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Jump to pin',
)
.first);
await pumpAsync(tester);
expect(find.text('b.md'), findsOneWidget);
});
});
// -------------------------------------------------------------------------
// T-191: edit pencil
// -------------------------------------------------------------------------
group('MarkdownViewer — edit pencil (T-191)', () {
testWidgets('edit pencil not visible before a file is loaded', (tester) async {
await pumpView(tester, f);
// No file loaded — placeholder shown, no chrome.
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
findsNothing,
);
});
testWidgets('edit pencil fires editor.open with current path', (tester) async {
const path = 'docs/readme.md';
_stubRead(f, path, '# Readme');
final editorOpenArgs = <Map<String, Object?>>[];
f.ipc.stub('editor.open', (args) async {
editorOpenArgs.add(args);
return IpcResponse.ok(id: '', data: {});
});
await pumpView(tester, f);
await loadFile(tester, f, path);
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Edit in editor'),
findsOneWidget,
);
await tester.tap(find
.byWidgetPredicate(
(w) => w is Semantics && w.properties.label == 'Edit in editor',
)
.first);
await pumpAsync(tester);
expect(editorOpenArgs, hasLength(1));
expect(editorOpenArgs.first['path'], path);
});
});
}
+222
View File
@@ -0,0 +1,222 @@
/// Unit tests for [ReaderHistory] and [ReaderHistoryMixin] (T-189, T-190).
library;
import 'package:clide/builtin/shared/reader_chrome.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
// ---------------------------------------------------------------------------
// ReaderHistory unit tests (no Flutter needed — plain test())
// ---------------------------------------------------------------------------
void main() {
group('ReaderHistory', () {
late ReaderHistory<String> h;
setUp(() => h = ReaderHistory<String>());
test('starts empty — canGoBack/Forward false, current null', () {
expect(h.canGoBack, isFalse);
expect(h.canGoForward, isFalse);
expect(h.current, isNull);
});
test('push one — current is that entry, no back/forward', () {
h.push('A');
expect(h.current, 'A');
expect(h.canGoBack, isFalse);
expect(h.canGoForward, isFalse);
});
test('push two — canGoBack true, canGoForward false', () {
h.push('A');
h.push('B');
expect(h.current, 'B');
expect(h.canGoBack, isTrue);
expect(h.canGoForward, isFalse);
});
test('back() after two pushes returns first entry', () {
h.push('A');
h.push('B');
final result = h.back();
expect(result, 'A');
expect(h.current, 'A');
expect(h.canGoBack, isFalse);
expect(h.canGoForward, isTrue);
});
test('forward() after back() returns second entry', () {
h.push('A');
h.push('B');
h.back();
final result = h.forward();
expect(result, 'B');
expect(h.current, 'B');
expect(h.canGoForward, isFalse);
});
test('back() at start returns null', () {
h.push('A');
expect(h.back(), isNull);
});
test('forward() at end returns null', () {
h.push('A');
h.push('B');
expect(h.forward(), isNull);
});
test('new push truncates forward history', () {
h.push('A');
h.push('B');
h.push('C');
h.back(); // now at B
h.back(); // now at A
expect(h.canGoForward, isTrue);
h.push('D'); // truncates [B, C], appends D
expect(h.current, 'D');
expect(h.canGoBack, isTrue);
expect(h.canGoForward, isFalse);
final prev = h.back();
expect(prev, 'A');
});
test('pushing duplicate of current is a no-op', () {
h.push('A');
h.push('A');
expect(h.canGoBack, isFalse); // still only one entry
expect(h.current, 'A');
});
test('three entries back/forward round-trip', () {
h.push('A');
h.push('B');
h.push('C');
expect(h.back(), 'B');
expect(h.back(), 'A');
expect(h.forward(), 'B');
expect(h.forward(), 'C');
expect(h.canGoForward, isFalse);
});
});
// -------------------------------------------------------------------------
// ReaderHistoryMixin widget integration test — uses a minimal StatefulWidget.
// -------------------------------------------------------------------------
group('ReaderHistoryMixin', () {
testWidgets('pin current / jump-to-pin round-trip', (tester) async {
String? jumpedTo;
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: _MixinHarness(onJump: (v) => jumpedTo = v),
),
);
final state = tester.state<_MixinHarnessState>(find.byType(_MixinHarness));
// No pin yet.
expect(state.hasPinned, isFalse);
expect(state.pinnedEntry, isNull);
// Push 'A', then pin it.
state.historyPush('A');
await tester.pump();
state.pinCurrent();
await tester.pump();
expect(state.hasPinned, isTrue);
expect(state.pinnedEntry, 'A');
// Push 'B', jump to pin → should get 'A'.
state.historyPush('B');
await tester.pump();
final pinEntry = state.jumpToPin();
jumpedTo = pinEntry;
expect(jumpedTo, 'A');
});
testWidgets('pin replaces previous pin', (tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: _MixinHarness(onJump: (_) {}),
),
);
final state = tester.state<_MixinHarnessState>(find.byType(_MixinHarness));
state.historyPush('A');
state.pinCurrent();
await tester.pump();
expect(state.pinnedEntry, 'A');
state.historyPush('B');
state.pinCurrent();
await tester.pump();
expect(state.pinnedEntry, 'B'); // replaced
});
testWidgets('historyBack / historyForward returns correct entries', (tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: _MixinHarness(onJump: (_) {}),
),
);
final state = tester.state<_MixinHarnessState>(find.byType(_MixinHarness));
state.historyPush('X');
state.historyPush('Y');
await tester.pump();
expect(state.canGoBack, isTrue);
expect(state.canGoForward, isFalse);
final back = state.historyBack();
await tester.pump();
expect(back, 'X');
expect(state.canGoBack, isFalse);
expect(state.canGoForward, isTrue);
final fwd = state.historyForward();
await tester.pump();
expect(fwd, 'Y');
});
testWidgets('pinCurrent with empty history is a no-op', (tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: _MixinHarness(onJump: (_) {}),
),
);
final state = tester.state<_MixinHarnessState>(find.byType(_MixinHarness));
state.pinCurrent(); // no current entry — must not throw
await tester.pump();
expect(state.hasPinned, isFalse);
});
});
}
// ---------------------------------------------------------------------------
// Minimal harness widget that mixes in ReaderHistoryMixin.
// ---------------------------------------------------------------------------
class _MixinHarness extends StatefulWidget {
const _MixinHarness({required this.onJump});
final void Function(String?) onJump;
@override
State<_MixinHarness> createState() => _MixinHarnessState();
}
class _MixinHarnessState extends State<_MixinHarness> with ReaderHistoryMixin<String, _MixinHarness> {
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}