make sidebar filter boxes CLI-addressable via the MessageBus (T-270)
The sidebar/dock filter fields (the shared ClideFilterBox) had no CLI peer — a one-way, UI-only affordance that broke D-6 parity. Add the drive+observe verb `clide ui filter <address> [<text>]`, routed entirely through the kernel MessageBus pub/sub so a box reacts to a published message identically whether the trigger was a UI keystroke or the CLI — keeping extensions first-class (no dispatcher→widget wiring). - ClideFilterBox gains an `address`; when set it listens on `filter.set` for its address and republishes its value on `filter.state`. Null address keeps the box a kernel-free UI widget. - FilterStateCache (new kernel service) caches the latest `filter.state` per address — the bus has no retention, so this backs the observe-half. - ui.filter: with text → publishes `filter.set` (drive); without → reads the cache (observe). Honest toolError when there is no live UI. - Address every box: decisions/tickets/files/git/output/problems panes, the four search boxes, and the pql search/query/markdown inputs. Addresses are the ids from `clide pane list` (e.g. decisions.panel). settings.json: allow the `clide` CLI + relevant skills. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -150,7 +150,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))),
|
||||
Expanded(child: ClideFilterBox(address: 'decisions.panel', hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideTappable(
|
||||
|
||||
@@ -65,7 +65,7 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
final rootName = root.split(Platform.pathSeparator).last;
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
|
||||
ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
label: 'file tree — $rootName',
|
||||
|
||||
@@ -75,7 +75,7 @@ class _GitPanelViewState extends State<GitPanelView> {
|
||||
explicitChildNodes: true,
|
||||
child: Column(
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
|
||||
ClideFilterBox(address: 'git.panel', hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
|
||||
@@ -125,7 +125,7 @@ class _OutputViewState extends State<OutputView> {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideFilterBox(hint: 'Filter…', onChanged: _c.setText),
|
||||
child: ClideFilterBox(address: 'output.panel', hint: 'Filter…', onChanged: _c.setText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_Chip(
|
||||
|
||||
@@ -103,11 +103,16 @@ class _PqlSearchBodyState extends State<PqlSearchBody> {
|
||||
children: [
|
||||
if (widget.mode == PqlPaneMode.markdown)
|
||||
ClideFilterBox(
|
||||
address: 'search.pql.markdown',
|
||||
hint: 'Filter markdown…',
|
||||
onChanged: (v) => unawaited(c.loadMarkdownFiles(glob: v.isEmpty ? null : '**/*$v*.md')),
|
||||
)
|
||||
else
|
||||
_PqlSearchInput(controller: c, dsl: widget.mode == PqlPaneMode.query),
|
||||
_PqlSearchInput(
|
||||
controller: c,
|
||||
dsl: widget.mode == PqlPaneMode.query,
|
||||
address: widget.mode == PqlPaneMode.query ? 'search.pql.query' : 'search.pql.vault',
|
||||
),
|
||||
if (c.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
@@ -146,13 +151,15 @@ class _PqlSearchBodyState extends State<PqlSearchBody> {
|
||||
}
|
||||
|
||||
class _PqlSearchInput extends StatelessWidget {
|
||||
const _PqlSearchInput({required this.controller, required this.dsl});
|
||||
const _PqlSearchInput({required this.controller, required this.dsl, this.address});
|
||||
final PqlController controller;
|
||||
final bool dsl;
|
||||
final String? address;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideFilterBox(
|
||||
address: address,
|
||||
hint: dsl ? 'PQL query…' : 'Search vault…',
|
||||
debounce: dsl ? Duration.zero : const Duration(milliseconds: 300),
|
||||
onChanged: dsl ? (_) {} : (v) => unawaited(controller.search(v)),
|
||||
|
||||
@@ -54,7 +54,7 @@ class _ProblemsViewState extends State<ProblemsView> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Filter problems…', onChanged: (v) => setState(() => _filter = v)),
|
||||
ClideFilterBox(address: 'problems.panel', hint: 'Filter problems…', onChanged: (v) => setState(() => _filter = v)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Row(
|
||||
|
||||
@@ -105,7 +105,7 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Search', onChanged: c.run, onSubmitted: c.run),
|
||||
ClideFilterBox(address: 'search.findInFiles', hint: 'Search', onChanged: c.run, onSubmitted: c.run),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
@@ -137,7 +137,9 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: ClideFilterBox(hint: 'Replace', icon: null, debounce: Duration.zero, onChanged: c.setReplacement)),
|
||||
Expanded(
|
||||
child: ClideFilterBox(
|
||||
address: 'search.findInFiles.replace', hint: 'Replace', icon: null, debounce: Duration.zero, onChanged: c.setReplacement)),
|
||||
const SizedBox(width: 6),
|
||||
_ReplaceAllButton(
|
||||
enabled: c.replacement.isNotEmpty && c.matchCount > 0,
|
||||
@@ -147,9 +149,15 @@ class _SearchPanelViewState extends State<SearchPanelView> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClideFilterBox(hint: 'files to include (e.g. *.dart)', icon: null, debounce: Duration.zero, onChanged: (v) => c.include = v),
|
||||
ClideFilterBox(
|
||||
address: 'search.findInFiles.include',
|
||||
hint: 'files to include (e.g. *.dart)',
|
||||
icon: null,
|
||||
debounce: Duration.zero,
|
||||
onChanged: (v) => c.include = v),
|
||||
const SizedBox(height: 4),
|
||||
ClideFilterBox(hint: 'files to exclude', icon: null, debounce: Duration.zero, onChanged: (v) => c.exclude = v),
|
||||
ClideFilterBox(
|
||||
address: 'search.findInFiles.exclude', hint: 'files to exclude', icon: null, debounce: Duration.zero, onChanged: (v) => c.exclude = v),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -159,7 +159,7 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))),
|
||||
Expanded(child: ClideFilterBox(address: 'tickets.panel', hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v))),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ClideTappable(
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
library;
|
||||
|
||||
export 'src/events/bus.dart';
|
||||
export 'src/events/filter_state.dart';
|
||||
export 'src/events/message_bus.dart';
|
||||
export 'src/events/types.dart';
|
||||
export 'src/ipc/client.dart';
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'message_bus.dart';
|
||||
|
||||
/// Caches the latest filter value per address, fed by `filter.state`
|
||||
/// messages on the [MessageBus] (T-270, D-6 parity).
|
||||
///
|
||||
/// Sidebar filter boxes are addressable from the CLI: `clide ui filter`
|
||||
/// with an address and text publishes a `filter.set` message the box
|
||||
/// reacts to (the drive-half). The observe-half — the same verb with no
|
||||
/// text — needs to read the box's *current* value back, but the bus is
|
||||
/// a plain broadcast stream with no retention. Each box republishes its
|
||||
/// value on the `filter.state` channel whenever it changes; this cache
|
||||
/// listens once and remembers the latest per address, giving the observe
|
||||
/// verb something to read.
|
||||
///
|
||||
/// Pure Dart — no Flutter import — so it serialises through the kernel and
|
||||
/// stays usable from `dart test`.
|
||||
class FilterStateCache {
|
||||
FilterStateCache({required MessageBus messages}) {
|
||||
_sub = messages.subscribe(channel: 'filter.state').listen((m) {
|
||||
_values[m.publisher] = m.data['query'] as String? ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
final Map<String, String> _values = {};
|
||||
StreamSubscription<Message>? _sub;
|
||||
|
||||
/// The last reported filter value for [address], or null if no box at
|
||||
/// that address has reported yet.
|
||||
String? get(String address) => _values[address];
|
||||
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:clide/kernel/src/commands/palette.dart';
|
||||
import 'package:clide/kernel/src/commands/registry.dart';
|
||||
import 'package:clide/kernel/src/dialog.dart';
|
||||
import 'package:clide/kernel/src/events/bus.dart';
|
||||
import 'package:clide/kernel/src/events/filter_state.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/kernel/src/extensions_manager.dart';
|
||||
import 'package:clide/kernel/src/files.dart';
|
||||
@@ -47,6 +48,7 @@ class KernelServices {
|
||||
required this.settings,
|
||||
required this.events,
|
||||
required this.messages,
|
||||
required this.filterStates,
|
||||
required this.ipc,
|
||||
required this.theme,
|
||||
required this.i18n,
|
||||
@@ -82,6 +84,10 @@ class KernelServices {
|
||||
final SettingsStore settings;
|
||||
final DaemonBus events;
|
||||
final MessageBus messages;
|
||||
|
||||
/// Latest filter value per addressable box, fed by `filter.state`
|
||||
/// messages — backs the observe-half of `clide ui filter` (T-270).
|
||||
final FilterStateCache filterStates;
|
||||
final DaemonClient ipc;
|
||||
final ThemeController theme;
|
||||
final I18n i18n;
|
||||
@@ -135,6 +141,7 @@ class KernelServices {
|
||||
final log = Logger(sinks: [stderrSink, logRing.add]);
|
||||
final events = sharedBus ?? DaemonBus();
|
||||
final messages = MessageBus();
|
||||
final filterStates = FilterStateCache(messages: messages);
|
||||
|
||||
final settings = SettingsStore(appDir: appDir);
|
||||
await settings.load();
|
||||
@@ -236,6 +243,7 @@ class KernelServices {
|
||||
settings: settings,
|
||||
events: events,
|
||||
messages: messages,
|
||||
filterStates: filterStates,
|
||||
ipc: ipc,
|
||||
theme: theme,
|
||||
i18n: i18n,
|
||||
@@ -292,6 +300,7 @@ class KernelServices {
|
||||
keymap.dispose();
|
||||
textZoom.dispose();
|
||||
await log.dispose();
|
||||
filterStates.dispose();
|
||||
messages.dispose();
|
||||
await events.dispose();
|
||||
}
|
||||
|
||||
+9
-1
@@ -98,6 +98,9 @@ Future<void> main() async {
|
||||
// The kernel MessageBus, captured post-boot so `ui.open` can drive the GUI
|
||||
// readers (publish a 'selection') from the CLI — the drive-half of D-6 (T-231).
|
||||
MessageBus? kernelMessages;
|
||||
// The filter-state cache, captured post-boot so `ui.filter` can read a
|
||||
// box's current value back — the observe-half of D-6 (T-270).
|
||||
FilterStateCache? kernelFilterStates;
|
||||
// IPC socket server (T-99 / T-124, per D-70/71/72). One server per
|
||||
// workspace; restarted when the active project switches because the
|
||||
// socket path is workspace-derived. The local DaemonClient connects
|
||||
@@ -216,7 +219,11 @@ Future<void> main() async {
|
||||
// `clide ui open <reader> <id|path>` — drive the GUI readers from the CLI
|
||||
// (T-231, drive-half of D-6). Publishes a 'selection' to the kernel
|
||||
// MessageBus, captured post-boot; null in headless contexts.
|
||||
registerUiCommands(dispatcher, () => kernelMessages?.publish);
|
||||
registerUiCommands(
|
||||
dispatcher,
|
||||
() => kernelMessages?.publish,
|
||||
filterValue: (address) => kernelFilterStates?.get(address),
|
||||
);
|
||||
// `clide image show <path>` — drive an image card into the Claude
|
||||
// conversation log (T-249, drive-half of D-6). Resolves the path
|
||||
// (workspace-relative → absolute, must exist) here where workRoot is in
|
||||
@@ -340,6 +347,7 @@ Future<void> main() async {
|
||||
// only reads it at request time (post-boot), so capturing it here is safe.
|
||||
kernelReaderNav = services.readerNav;
|
||||
kernelMessages = services.messages;
|
||||
kernelFilterStates = services.filterStates;
|
||||
// Tee the IPC/MCP logger into the shared ring so the output dock (T-54)
|
||||
// shows socket-side logs alongside kernel/extension ones.
|
||||
ipcLog.addSink(services.logRing.add);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/// Registers `ui.open` — the drive-half of D-6 parity (T-231).
|
||||
/// Registers the `ui.*` drive/observe verbs — the parity peers of D-6
|
||||
/// (`ui.open` T-231, `ui.toast` T-50, `ui.filter` T-270).
|
||||
///
|
||||
/// Epic C (T-218) gave the CLI the *observe* half: `clide status` / `pane
|
||||
/// list` read live UI state. This is the complement — the agent asks the
|
||||
@@ -16,6 +17,7 @@
|
||||
/// Flutter-free and runs under `dart test`.
|
||||
library;
|
||||
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import 'dispatcher.dart';
|
||||
@@ -40,9 +42,26 @@ const Map<String, ({String publisher, String dataKey})> _readers = {
|
||||
/// literal so this file stays Flutter-free for `dart test`).
|
||||
const Set<String> _toastSeverities = {'success', 'warning', 'error', 'info'};
|
||||
|
||||
void registerUiCommands(DaemonDispatcher d, MessagePublisher? Function() publisher) {
|
||||
/// Reads the current filter value for an addressable box from the kernel's
|
||||
/// `FilterStateCache` — the observe-half of `ui.filter`. Returns null when
|
||||
/// nothing has reported a value for that address (or there is no live UI).
|
||||
typedef FilterValueSource = String? Function(String address);
|
||||
|
||||
void registerUiCommands(
|
||||
DaemonDispatcher d,
|
||||
MessagePublisher? Function() publisher, {
|
||||
FilterValueSource? filterValue,
|
||||
}) {
|
||||
d.register('ui.open', (req) async => _open(req, publisher));
|
||||
d.register('ui.toast', (req) async => _toast(req, publisher));
|
||||
d.register(
|
||||
'ui.filter',
|
||||
(req) async => _filter(req, publisher, filterValue),
|
||||
schema: const CommandSchema(
|
||||
positional: ['address', 'query'],
|
||||
args: {'address': ArgSpec(required: true), 'query': ArgSpec()},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
|
||||
@@ -122,3 +141,50 @@ Future<IpcResponse> _toast(IpcRequest req, MessagePublisher? Function() publishe
|
||||
});
|
||||
return IpcResponse.ok(id: req.id, data: {'message': message, 'severity': severity, 'shown': true});
|
||||
}
|
||||
|
||||
/// `clide ui filter <address> [<text>]` — the drive+observe parity peer for
|
||||
/// the sidebar filter boxes (T-270, D-6). Routed through the MessageBus so a
|
||||
/// box reacts identically whether the trigger was a UI keystroke or this
|
||||
/// verb, keeping extensions first-class (no dispatcher→widget wiring).
|
||||
///
|
||||
/// clide ui filter decisions.panel git # set the filter to "git"
|
||||
/// clide ui filter decisions.panel "" # clear it
|
||||
/// clide ui filter decisions.panel # read the current value
|
||||
///
|
||||
/// `address` is a pane/box id from `clide pane list`. With a `query` arg the
|
||||
/// verb *drives* — publishes a `filter.set` the box consumes. Without one it
|
||||
/// *observes* — reads the box's last reported value from the FilterStateCache.
|
||||
Future<IpcResponse> _filter(
|
||||
IpcRequest req,
|
||||
MessagePublisher? Function() publisherSource,
|
||||
FilterValueSource? filterValue,
|
||||
) async {
|
||||
final address = req.args['address'] as String?;
|
||||
if (address == null || address.isEmpty) {
|
||||
return _userErr(req.id, 'an address is required', hint: 'a pane/box id from `clide pane list` (e.g. decisions.panel)');
|
||||
}
|
||||
|
||||
// Absent query → observe; present (even empty string) → drive. The schema
|
||||
// drops unmapped positionals, so a missing query leaves no `query` key.
|
||||
if (!req.args.containsKey('query')) {
|
||||
final getter = filterValue;
|
||||
if (getter == null) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to observe (clide is not running a GUI)'),
|
||||
);
|
||||
}
|
||||
return IpcResponse.ok(id: req.id, data: {'address': address, 'query': getter(address)});
|
||||
}
|
||||
|
||||
final query = (req.args['query'] as String?) ?? '';
|
||||
final publish = publisherSource();
|
||||
if (publish == null) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'),
|
||||
);
|
||||
}
|
||||
publish(address, 'filter.set', {'query': query});
|
||||
return IpcResponse.ok(id: req.id, data: {'address': address, 'query': query, 'set': true});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/kernel/src/facade.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/widgets/src/clide_icon.dart';
|
||||
import 'package:clide/widgets/src/icons/phosphor.dart';
|
||||
@@ -14,6 +16,7 @@ class ClideFilterBox extends StatefulWidget {
|
||||
this.debounce = const Duration(milliseconds: 200),
|
||||
this.onSubmitted,
|
||||
this.icon = PhosphorIcons.magnifyingGlass,
|
||||
this.address,
|
||||
});
|
||||
|
||||
final ValueChanged<String> onChanged;
|
||||
@@ -25,6 +28,15 @@ class ClideFilterBox extends StatefulWidget {
|
||||
/// that aren't searches (e.g. a replace or glob field).
|
||||
final ClideIconPainter? icon;
|
||||
|
||||
/// Makes this box addressable from the CLI (D-6 parity, T-270). When set,
|
||||
/// the box listens on the MessageBus `filter.set` channel for its address
|
||||
/// — so `clide ui filter <address> <text>` drives it exactly as a UI
|
||||
/// keystroke would — and republishes its value on `filter.state` so the
|
||||
/// observe-half (`clide ui filter <address>`) can read it back. The
|
||||
/// address is the pane/box id surfaced by `clide pane list`. When null the
|
||||
/// box is a plain UI-only widget and never touches the kernel.
|
||||
final String? address;
|
||||
|
||||
@override
|
||||
State<ClideFilterBox> createState() => _ClideFilterBoxState();
|
||||
}
|
||||
@@ -33,18 +45,55 @@ class _ClideFilterBoxState extends State<ClideFilterBox> {
|
||||
final _controller = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
Timer? _debounceTimer;
|
||||
StreamSubscription<Message>? _busSub;
|
||||
MessageBus? _bus;
|
||||
bool _wired = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final address = widget.address;
|
||||
if (address == null || _wired) return;
|
||||
_wired = true;
|
||||
final bus = ClideKernel.of(context).messages;
|
||||
_bus = bus;
|
||||
_busSub = bus.subscribe(publisher: address, channel: 'filter.set').listen(_onRemoteSet);
|
||||
// Report the initial value so an observe before any change still reads it.
|
||||
_publishState(_controller.text);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_busSub?.cancel();
|
||||
_controller.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Apply a value pushed in over the bus. Programmatic sets take effect
|
||||
/// immediately (no debounce) and report their new state.
|
||||
void _onRemoteSet(Message m) {
|
||||
final value = m.data['query'] as String? ?? '';
|
||||
_debounceTimer?.cancel();
|
||||
_controller.text = value;
|
||||
widget.onChanged(value);
|
||||
_publishState(value);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _publishState(String value) {
|
||||
final address = widget.address;
|
||||
if (address == null) return;
|
||||
_bus?.publish(address, 'filter.state', {'query': value});
|
||||
}
|
||||
|
||||
void _onChanged(String value) {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(widget.debounce, () => widget.onChanged(value));
|
||||
_debounceTimer = Timer(widget.debounce, () {
|
||||
widget.onChanged(value);
|
||||
_publishState(value);
|
||||
});
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@@ -52,6 +101,7 @@ class _ClideFilterBoxState extends State<ClideFilterBox> {
|
||||
_controller.clear();
|
||||
_debounceTimer?.cancel();
|
||||
widget.onChanged('');
|
||||
_publishState('');
|
||||
_focus.requestFocus();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user