reader pin UX: push-pin icon, toggle, left/right split
Per user feedback on the reader action bar: use the push-pin glyph (not the chain/link), make the pin button toggle the pinned state (tap to pin current, tap again to unpin) via ReaderNav.togglePin, and split the layout so the pin/unpin toggle sits on the left while jump-to-pin joins the navigator (back/forward) on the right — left toggles, right navigates. The action button gains an active (accent) state for the pinned indicator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,3 +2,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
|
||||
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-195', 'status', 'backlog', 'done', NULL, '2026-06-01 07:08:16', '2026-06-01 07:08:16', '2026-06-01 07:08:16', NULL, 'c2fb7c6ede9e970a8186b5932141f472', 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-197', 'status', 'backlog', 'done', NULL, '2026-06-01 10:45:14', '2026-06-01 10:45:14', '2026-06-01 10:45:14', NULL, '7d9a5a6040ccaff63f95c106c088f7b5', 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-196', 'status', 'backlog', 'done', NULL, '2026-06-01 10:45:14', '2026-06-01 10:45:14', '2026-06-01 10:45:14', NULL, 'a6afc45a7b24dc8dc65a0a6cda319f7d', 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-198', 'status', 'backlog', 'done', NULL, '2026-06-01 11:31:39', '2026-06-01 11:31:39', '2026-06-01 11:31:39', NULL, '5abb12dc3c3b72a4bccf1760204fe1ae', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -2,3 +2,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
|
||||
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-195', 'story', 'T-132', 'Reader can''t open user-scope Claude config files (~/.claude skills/agents/commands)', 'Clicking a user-scope skill in the Claude Config tab (e.g. /home/<user>/.claude/skills/peon-ping-toggle/SKILL.md) shows ''path outside workspace''. The markdown reader loads via files.read, which is workspace-confined for security (T-102); but user-scope Claude config files legitimately live under ~/.claude, outside the repo. Per D-76, Claude''s config surface (skills/agents/commands under ~/.claude AND the repo .claude) is clide-managed and safe to surface. Proposed: a scoped trusted-read — a new IPC verb (e.g. claude.read-config-file) that permits reads ONLY under the resolved Claude config roots (~/.claude + <repo>/.claude), still rejecting arbitrary paths. Route the Config tab''s skill/agent/command markdown clicks through it, or have the markdown reader fall back to it when the path is under a Claude config root but outside the workspace. Alternative: the Config tab already reads SKILL.md during scan (claude_config.dart) and could publish content directly via a load-by-content channel rather than a path (the reader currently only reads by path). Repo-scope skills already work after T-194; this covers the user-scope half.', 'done', 'medium', NULL, NULL, NULL, '2026-06-01 06:56:05', '2026-06-01 07:08:16', NULL, '7b46d0491c01bcc056c430a7af48f734', 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-197', 'bug', NULL, 'Editor pane never reveals on editor.open (edit button, file-tree open)', 'Opening a file in the editor (the reader''s edit pencil, a non-.md file-tree click, a decision''s edit) calls editor.open and the daemon opens the buffer, but the editor pane never appears in the workspace slot above the Claude pane. Root cause: EditorExtension contributes a workspace tab (editor.active, priority 80) but has no activate() that reveals/activates it. editor.opened is emitted on the DaemonBus, but the EditorController that handles it only exists once EditorView is mounted — and nothing ever activates the editor tab to mount it. Fix: add EditorExtension.activate() subscribing to the editor.opened / editor.active-changed DaemonEvents and calling panels.activateTab(Slots.workspace, ''editor.active''). EditorView.hydrate() already pulls the active buffer on mount, so reveal-then-hydrate avoids any publish/subscribe race.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 08:58:19', '2026-06-01 10:45:14', NULL, '05ae54e31d0c9befdbfef2fe096737c0', 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-196', 'bug', 'T-186', 'Decision reader: first click switches tab but doesn''t load (lost on tab mount)', 'Clicking a D-record activates the decisions.detail context-panel tab but does not load the record; only the SECOND click (any record) works. Root cause: DecisionDetailView subscribes to the ''selection'' MessageBus channel in didChangeDependencies, which runs only AFTER the extension''s activateTab mounts the view. The bus is broadcast (no replay), so the ''selection'' message that triggered the reveal is already gone — the view subscribes too late and misses it. The markdown extension already solved this: activateTab, then addPostFrameCallback -> re-publish on a separate ''load'' channel the viewer subscribes to, so the load fires after the viewer mounts+subscribes. Fix: mirror that — DecisionDetailView subscribes to ''load''; DecisionsExtension forwards ''selection'' -> post-frame ''load'' after activateTab.', 'done', 'high', NULL, NULL, NULL, '2026-06-01 08:58:10', '2026-06-01 10:45:14', NULL, 'c87a8726edd727831291bd1055e39ae1', 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-198', 'task', 'T-186', 'Reader pin UX: pin icon, toggle, left-toggle/right-navigate', 'Polish on the reader action bar (follows T-190). (1) push-pin glyph instead of the chain/link icon; (2) the pin button TOGGLES pinned state (tap to pin current, tap again to unpin) rather than set/replace; (3) layout split — pin/unpin toggle on the LEFT, jump-to-pin moved into the navigator (back/forward) on the right. Added ReaderNav.togglePin(), PhosphorIcons.pushPin (0xe3e2) + arrowUUpLeft (0xe08a), and an active state on the action button.', 'done', 'low', NULL, NULL, NULL, '2026-06-01 11:11:32', '2026-06-01 11:31:39', NULL, 'd2392d2e56ffc552c8f4f8993350d939', 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);
|
||||
|
||||
+3
-3
@@ -40,9 +40,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
`.clideignore` pair — the single ignore knob clide owns. (T-52)
|
||||
- `files.walk` command — a recursive, ignore-pruned, capped flat file listing
|
||||
of the workspace, backing quick-open and search. (T-51, T-52)
|
||||
- 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)
|
||||
- Sidebar readers (markdown + decision) gain a chrome action bar: a pin/unpin
|
||||
toggle on the left, then a navigator (back, forward, jump-to-pin) and an edit
|
||||
pencil on the right. (T-189, T-190, T-191, T-198)
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
|
||||
void _onBack() => _nav?.back();
|
||||
void _onForward() => _nav?.forward();
|
||||
void _onPin() => _nav?.pin();
|
||||
void _onPin() => _nav?.togglePin();
|
||||
void _onJumpToPin() => _nav?.jumpToPin();
|
||||
|
||||
void _onEdit() {
|
||||
|
||||
@@ -67,7 +67,7 @@ class _MarkdownViewerState extends State<MarkdownViewer> {
|
||||
|
||||
void _onBack() => _nav?.back();
|
||||
void _onForward() => _nav?.forward();
|
||||
void _onPin() => _nav?.pin();
|
||||
void _onPin() => _nav?.togglePin();
|
||||
void _onJumpToPin() => _nav?.jumpToPin();
|
||||
|
||||
void _onEdit() {
|
||||
|
||||
@@ -14,9 +14,12 @@ import 'package:flutter/widgets.dart';
|
||||
// Action bar widget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A row of reader-chrome action buttons: back, forward, pin (set/jump), edit.
|
||||
/// A row of reader-chrome action buttons. Layout (T-196 UX): a pin/unpin
|
||||
/// **toggle on the left**, then the **navigator** on the right — back,
|
||||
/// forward, jump-to-pin (only while pinned) — and the edit pencil last.
|
||||
/// The left toggles pinned state; the right navigates.
|
||||
///
|
||||
/// Designed to plug into [ClidePaneChrome.trailing]. All callbacks are
|
||||
/// Designed to plug into [ClidePaneChrome.trailing]. All callbacks are
|
||||
/// optional — pass null to hide/disable the corresponding button.
|
||||
class ReaderActionBar extends StatelessWidget {
|
||||
const ReaderActionBar({
|
||||
@@ -37,13 +40,14 @@ class ReaderActionBar extends StatelessWidget {
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onForward;
|
||||
|
||||
/// Called when the pin button is tapped (set / replace current pin).
|
||||
/// Called when the pin toggle is tapped — pins the current entry when
|
||||
/// nothing is pinned, else clears the pin.
|
||||
final VoidCallback? onPin;
|
||||
|
||||
/// Called when the jump-to-pin affordance is tapped.
|
||||
/// Called when the jump-to-pin button (in the navigator) is tapped.
|
||||
final VoidCallback? onJumpToPin;
|
||||
|
||||
/// Called when the edit pencil is tapped. Pass null to hide the button.
|
||||
/// Called when the edit pencil is tapped. Pass null to hide the button.
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
@@ -52,6 +56,17 @@ class ReaderActionBar extends StatelessWidget {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Left — toggle the pinned state.
|
||||
_ActionButton(
|
||||
painter: PhosphorIcons.pushPin,
|
||||
tooltip: hasPinned ? 'Unpin' : 'Pin',
|
||||
enabled: onPin != null,
|
||||
active: hasPinned,
|
||||
onTap: onPin,
|
||||
tokens: tokens,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Right — the navigator.
|
||||
_ActionButton(
|
||||
painter: PhosphorIcons.caretLeft,
|
||||
tooltip: 'Back',
|
||||
@@ -67,15 +82,18 @@ class ReaderActionBar extends StatelessWidget {
|
||||
onTap: canGoForward ? onForward : null,
|
||||
tokens: tokens,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_PinButton(
|
||||
hasPinned: hasPinned,
|
||||
onPin: onPin,
|
||||
onJumpToPin: onJumpToPin,
|
||||
tokens: tokens,
|
||||
),
|
||||
if (onEdit != null) ...[
|
||||
if (hasPinned) ...[
|
||||
const SizedBox(width: 2),
|
||||
_ActionButton(
|
||||
painter: PhosphorIcons.arrowUUpLeft,
|
||||
tooltip: 'Jump to pin',
|
||||
enabled: true,
|
||||
onTap: onJumpToPin,
|
||||
tokens: tokens,
|
||||
),
|
||||
],
|
||||
if (onEdit != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
_ActionButton(
|
||||
painter: PhosphorIcons.pencilSimple,
|
||||
tooltip: 'Edit in editor',
|
||||
@@ -90,7 +108,7 @@ class ReaderActionBar extends StatelessWidget {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private button widgets
|
||||
// Private button widget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
@@ -100,20 +118,34 @@ class _ActionButton extends StatelessWidget {
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
required this.tokens,
|
||||
this.active = false,
|
||||
});
|
||||
|
||||
final ClideIconPainter painter;
|
||||
final String tooltip;
|
||||
final bool enabled;
|
||||
|
||||
/// Renders the glyph in the focus/accent colour to signal an on state
|
||||
/// (used by the pin toggle when something is pinned).
|
||||
final bool active;
|
||||
final VoidCallback? onTap;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color color;
|
||||
if (!enabled) {
|
||||
color = tokens.globalTextMuted;
|
||||
} else if (active) {
|
||||
color = tokens.globalFocus;
|
||||
} else {
|
||||
color = tokens.panelHeaderForeground;
|
||||
}
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
enabled: enabled,
|
||||
toggled: active,
|
||||
onTap: onTap,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
@@ -127,96 +159,9 @@ class _ActionButton extends StatelessWidget {
|
||||
color: hovered && enabled ? tokens.sidebarItemHover : null,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: ClideIcon(
|
||||
painter,
|
||||
size: 11,
|
||||
color: enabled ? tokens.panelHeaderForeground : tokens.globalTextMuted,
|
||||
),
|
||||
child: ClideIcon(painter, size: 11, color: color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,17 @@ class ReaderNav extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Toggle the pinned state: pin the current entry when nothing is
|
||||
/// pinned, otherwise clear the pin. (The reader's left-hand pin button.)
|
||||
void togglePin() {
|
||||
if (_pinnedIndex != null) {
|
||||
_pinnedIndex = null;
|
||||
notifyListeners();
|
||||
} else {
|
||||
pin();
|
||||
}
|
||||
}
|
||||
|
||||
/// Jump to the pinned entry and re-emit it. No-op when nothing is pinned.
|
||||
void jumpToPin() {
|
||||
final p = _pinnedIndex;
|
||||
|
||||
@@ -63,6 +63,8 @@ abstract class PhosphorIcons {
|
||||
static const treeStructure = PhosphorIconPainter(0xe67c);
|
||||
static const image = PhosphorIconPainter(0xe2ca);
|
||||
static const link = PhosphorIconPainter(0xe2e2);
|
||||
static const pushPin = PhosphorIconPainter(0xe3e2);
|
||||
static const arrowUUpLeft = PhosphorIconPainter(0xe08a);
|
||||
static const chatCircle = PhosphorIconPainter(0xe168);
|
||||
static const robot = PhosphorIconPainter(0xe762);
|
||||
static const ticket = PhosphorIconPainter(0xe490);
|
||||
|
||||
@@ -725,7 +725,7 @@ void main() {
|
||||
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
(w) => w is Semantics && w.properties.label == 'Pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
@@ -742,7 +742,7 @@ void main() {
|
||||
// Pin D-1.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
(w) => w is Semantics && w.properties.label == 'Pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
@@ -763,41 +763,32 @@ void main() {
|
||||
expect(find.text('Decision D-1'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('pin replaces previous pin', (tester) async {
|
||||
testWidgets('pin toggles off on a second tap (unpin)', (tester) async {
|
||||
await pumpView(tester, initialId: 'D-1');
|
||||
|
||||
// Pin D-1.
|
||||
// Pin D-1 → the jump-to-pin button appears in the navigator.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
(w) => w is Semantics && w.properties.label == 'Pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Navigate to D-2.
|
||||
await open(tester, 'D-2');
|
||||
|
||||
// Replace pin with D-2.
|
||||
// Tapping the toggle again (now 'Unpin') clears the pin.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Replace pin',
|
||||
(w) => w is Semantics && w.properties.label == 'Unpin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
|
||||
// Navigate to D-3.
|
||||
await open(tester, 'D-3');
|
||||
|
||||
// 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);
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -326,7 +326,7 @@ void main() {
|
||||
await loadFile(tester, f, 'a.md');
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && (w.properties.label == 'Pin current' || w.properties.label == 'Replace pin')),
|
||||
find.byWidgetPredicate((w) => w is Semantics && (w.properties.label == 'Pin' || w.properties.label == 'Unpin')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
@@ -350,7 +350,7 @@ void main() {
|
||||
// Tap Pin current.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
(w) => w is Semantics && w.properties.label == 'Pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
@@ -370,7 +370,7 @@ void main() {
|
||||
// Pin a.md.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
(w) => w is Semantics && w.properties.label == 'Pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
@@ -391,42 +391,34 @@ void main() {
|
||||
expect(find.text('a.md'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('pin replaces previous pin', (tester) async {
|
||||
_stubReadMap(f, {'a.md': 'A', 'b.md': 'B', 'c.md': 'C'});
|
||||
testWidgets('pin toggles off on a second tap (unpin)', (tester) async {
|
||||
_stubReadMap(f, {'a.md': 'A', 'b.md': 'B'});
|
||||
await pumpView(tester, f);
|
||||
await loadFile(tester, f, 'a.md');
|
||||
|
||||
// Pin a.md.
|
||||
// Pin a.md → jump-to-pin appears.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Pin current',
|
||||
(w) => w is Semantics && w.properties.label == 'Pin',
|
||||
)
|
||||
.first);
|
||||
await pumpAsync(tester);
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Navigate to b.md.
|
||||
await loadFile(tester, f, 'b.md');
|
||||
|
||||
// Replace pin with b.md.
|
||||
// Tap the toggle again (now 'Unpin') → pin cleared.
|
||||
await tester.tap(find
|
||||
.byWidgetPredicate(
|
||||
(w) => w is Semantics && w.properties.label == 'Replace pin',
|
||||
(w) => w is Semantics && w.properties.label == 'Unpin',
|
||||
)
|
||||
.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);
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Jump to pin'),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -115,6 +115,15 @@ void main() {
|
||||
expect(loads.last, 'D-1');
|
||||
});
|
||||
|
||||
test('togglePin pins the current entry, then clears it', () {
|
||||
nav.open('D-1');
|
||||
expect(nav.hasPinned, isFalse);
|
||||
nav.togglePin();
|
||||
expect(nav.hasPinned, isTrue);
|
||||
nav.togglePin();
|
||||
expect(nav.hasPinned, isFalse);
|
||||
});
|
||||
|
||||
test('pin replaces the previous pin', () {
|
||||
nav.open('D-1');
|
||||
nav.pin();
|
||||
|
||||
Reference in New Issue
Block a user