From eb8c9ecf5db0fbfc145ab0e6519c532198869810 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 23 Apr 2026 07:56:39 +0200 Subject: [PATCH] add ClideFilterBox widget to all sidebar panes Shared filter box with search icon, debounced input, and clear button. Applied to all six left-panel panes for consistent filtering: Files (flat path results when filtering), Git (filter staged/unstaged/untracked by path), Decisions (by ID/title/domain), Tickets (by ID/title/status), Problems (by source/message), and pql Query (replaces custom _QueryInput). Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 7 ++ lib/builtin/decisions/src/decisions_view.dart | 19 ++-- .../files/src/file_tree_controller.dart | 8 ++ lib/builtin/files/src/file_tree_view.dart | 89 +++++++++++++---- lib/builtin/git/src/git_panel_view.dart | 24 +++-- lib/builtin/pql/src/pql_panel_view.dart | 57 +---------- lib/builtin/problems/src/problems_view.dart | 91 +++++++---------- lib/builtin/tickets/src/tickets_view.dart | 19 ++-- lib/widgets/src/clide_filter_box.dart | 99 +++++++++++++++++++ lib/widgets/widgets.dart | 1 + 10 files changed, 270 insertions(+), 144 deletions(-) create mode 100644 lib/widgets/src/clide_filter_box.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 0197fde7..d5f2b736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Shared ClideFilterBox widget with search icon, clear button, and + debounced input. Applied consistently across all sidebar panes: + Files (path filter with flat results), Git (filter staged/unstaged + by path), Decisions (filter by ID/title/domain), Tickets (filter + by ID/title/status), Problems (filter by source/message), and + pql Query (replaces custom input). + - Interaction model from Wireframe Flows v3: eight new D-records (D-047 through D-054) and five Q-records (Q-026 through Q-030) codifying layout invariants, chrome budget, editor mode, context diff --git a/lib/builtin/decisions/src/decisions_view.dart b/lib/builtin/decisions/src/decisions_view.dart index d63fa3cc..1d6c009d 100644 --- a/lib/builtin/decisions/src/decisions_view.dart +++ b/lib/builtin/decisions/src/decisions_view.dart @@ -16,6 +16,7 @@ class _DecisionsViewState extends State { List<_DecisionEntry> _decisions = []; String? _error; bool _loading = true; + String _filter = ''; @override void didChangeDependencies() { @@ -70,12 +71,18 @@ class _DecisionsViewState extends State { child: ClideText('No decisions found.\nRun `pql decisions sync` to index.', muted: true), ); } - return ListView.builder( - itemCount: _decisions.length, - itemBuilder: (ctx, i) { - final d = _decisions[i]; - return _DecisionRow(entry: d, tokens: tokens); - }, + final lf = _filter.toLowerCase(); + final filtered = lf.isEmpty ? _decisions : _decisions.where((d) => d.id.toLowerCase().contains(lf) || d.title.toLowerCase().contains(lf) || (d.domain ?? '').toLowerCase().contains(lf)).toList(); + return Column( + children: [ + ClideFilterBox(hint: 'Filter decisions…', onChanged: (v) => setState(() => _filter = v)), + Expanded( + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (ctx, i) => _DecisionRow(entry: filtered[i], tokens: tokens), + ), + ), + ], ); } } diff --git a/lib/builtin/files/src/file_tree_controller.dart b/lib/builtin/files/src/file_tree_controller.dart index 09ff1eb5..7ca4342f 100644 --- a/lib/builtin/files/src/file_tree_controller.dart +++ b/lib/builtin/files/src/file_tree_controller.dart @@ -38,6 +38,14 @@ class FileTreeController extends ChangeNotifier { final Map> _entries = {}; List? entriesFor(String path) => _entries[path]; + List allLoadedEntries() { + final out = []; + for (final list in _entries.values) { + out.addAll(list.where((e) => !e.isDirectory)); + } + return out; + } + /// Initial boot: resolve the workspace root, load the root dir, /// subscribe to `files.changed` events. Future load() async { diff --git a/lib/builtin/files/src/file_tree_view.dart b/lib/builtin/files/src/file_tree_view.dart index c8a5a5b3..0ca5ed5b 100644 --- a/lib/builtin/files/src/file_tree_view.dart +++ b/lib/builtin/files/src/file_tree_view.dart @@ -5,6 +5,8 @@ import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; +import 'package:clide/src/files/listing.dart' show FileEntry; + import 'file_tree_controller.dart'; /// Sidebar panel rendering the workspace file tree. @@ -24,6 +26,7 @@ class FileTreeView extends StatefulWidget { class _FileTreeViewState extends State { FileTreeController? _controller; + String _filter = ''; @override void didChangeDependencies() { @@ -61,31 +64,45 @@ class _FileTreeViewState extends State { ); } final rootName = root.split(Platform.pathSeparator).last; - return Semantics( - label: 'file tree — $rootName', - container: true, - explicitChildNodes: true, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - _DirRow( - name: rootName, - path: '', - controller: c, - depth: 0, + return Column( + children: [ + ClideFilterBox(hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)), + Expanded( + child: Semantics( + label: 'file tree — $rootName', + container: true, + explicitChildNodes: true, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (_filter.isEmpty) ...[ + _DirRow(name: rootName, path: '', controller: c, depth: 0), + if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1), + ] else + ..._filteredEntries(c), + ], + ), ), - if (c.isExpanded('')) - _Children(path: '', controller: c, depth: 1), - ], + ), ), - ), + ], ); }, ); } + + List _filteredEntries(FileTreeController c) { + final lowerFilter = _filter.toLowerCase(); + final matches = c.allLoadedEntries().where((e) { + return e.path.toLowerCase().contains(lowerFilter) || e.name.toLowerCase().contains(lowerFilter); + }).toList(); + return [ + for (final e in matches) _FilteredFileRow(entry: e), + ]; + } } class _Children extends StatelessWidget { @@ -269,3 +286,37 @@ class _RowState extends State<_Row> { } } +class _FilteredFileRow extends StatefulWidget { + const _FilteredFileRow({required this.entry}); + final FileEntry entry; + + @override + State<_FilteredFileRow> createState() => _FilteredFileRowState(); +} + +class _FilteredFileRowState extends State<_FilteredFileRow> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + final kernel = ClideKernel.of(context); + unawaited(kernel.ipc.request('editor.open', args: {'path': widget.entry.path})); + }, + child: Container( + color: _hover ? tokens.sidebarItemHover : null, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), + child: ClideText(widget.entry.path, maxLines: 1, overflow: TextOverflow.ellipsis, color: tokens.sidebarForeground), + ), + ), + ); + } +} + diff --git a/lib/builtin/git/src/git_panel_view.dart b/lib/builtin/git/src/git_panel_view.dart index 8bfa4f97..1be686d2 100644 --- a/lib/builtin/git/src/git_panel_view.dart +++ b/lib/builtin/git/src/git_panel_view.dart @@ -22,6 +22,13 @@ class _GitPanelViewState extends State { GitController? _controller; final TextEditingController _commitMsg = TextEditingController(); final FocusNode _commitFocus = FocusNode(); + String _filter = ''; + + List> _applyFilter(List> entries) { + if (_filter.isEmpty) return entries; + final lf = _filter.toLowerCase(); + return entries.where((e) => ((e['path'] as String?) ?? '').toLowerCase().contains(lf)).toList(); + } @override void didChangeDependencies() { @@ -66,7 +73,10 @@ class _GitPanelViewState extends State { label: 'git panel', container: true, explicitChildNodes: true, - child: SingleChildScrollView( + child: Column( + children: [ + ClideFilterBox(hint: 'Filter changes…', onChanged: (v) => setState(() => _filter = v)), + Expanded(child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -98,13 +108,13 @@ class _GitPanelViewState extends State { if (c.conflicted.isNotEmpty) _FileGroup( label: 'Merge conflicts', - entries: c.conflicted, + entries: _applyFilter(c.conflicted), actions: const [], ), if (c.staged.isNotEmpty) ...[ _FileGroup( label: 'Staged', - entries: c.staged, + entries: _applyFilter(c.staged), actions: [ _GroupAction( label: 'Unstage all', @@ -122,7 +132,7 @@ class _GitPanelViewState extends State { if (c.unstaged.isNotEmpty) _FileGroup( label: 'Changes', - entries: c.unstaged, + entries: _applyFilter(c.unstaged), actions: [ _GroupAction( label: 'Stage all', @@ -135,7 +145,7 @@ class _GitPanelViewState extends State { if (c.untracked.isNotEmpty) _FileGroup( label: 'Untracked', - entries: c.untracked, + entries: _applyFilter(c.untracked), actions: [ _GroupAction( label: 'Stage all', @@ -151,7 +161,9 @@ class _GitPanelViewState extends State { ), ], ), - ), + )), + ], + ), ); }, ); diff --git a/lib/builtin/pql/src/pql_panel_view.dart b/lib/builtin/pql/src/pql_panel_view.dart index 7d19badb..d63a30b6 100644 --- a/lib/builtin/pql/src/pql_panel_view.dart +++ b/lib/builtin/pql/src/pql_panel_view.dart @@ -19,8 +19,6 @@ class PqlPanelView extends StatefulWidget { class _PqlPanelViewState extends State { PqlController? _controller; - final TextEditingController _queryInput = TextEditingController(); - final FocusNode _queryFocus = FocusNode(); @override void didChangeDependencies() { @@ -34,8 +32,6 @@ class _PqlPanelViewState extends State { @override void dispose() { _controller?.dispose(); - _queryInput.dispose(); - _queryFocus.dispose(); super.dispose(); } @@ -56,10 +52,11 @@ class _PqlPanelViewState extends State { children: [ _ViewTabs(controller: c), if (c.view == PqlView.query) - _QueryInput( - input: _queryInput, - focus: _queryFocus, - controller: c, + ClideFilterBox( + hint: 'PQL query…', + debounce: Duration.zero, + onChanged: (_) {}, + onSubmitted: (v) => unawaited(c.runQuery(v)), ), if (c.error != null) Padding( @@ -158,50 +155,6 @@ class _ViewTabs extends StatelessWidget { }; } -class _QueryInput extends StatelessWidget { - const _QueryInput({ - required this.input, - required this.focus, - required this.controller, - }); - - final TextEditingController input; - final FocusNode focus; - final PqlController controller; - - @override - Widget build(BuildContext context) { - final tokens = ClideTheme.of(context).surface; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Semantics( - label: 'pql query', - textField: true, - child: Container( - decoration: BoxDecoration( - border: Border.all(color: tokens.globalBorder), - ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: EditableText( - controller: input, - focusNode: focus, - style: TextStyle( - fontFamily: clideMonoFamily, - fontSize: clideFontMono, - color: tokens.globalForeground, - ), - cursorColor: tokens.globalFocus, - backgroundCursorColor: tokens.globalFocus, - maxLines: 1, - onSubmitted: (_) => - unawaited(controller.runQuery(input.text)), - ), - ), - ), - ); - } -} - class _FileRow extends StatefulWidget { const _FileRow({required this.entry}); final Map entry; diff --git a/lib/builtin/problems/src/problems_view.dart b/lib/builtin/problems/src/problems_view.dart index 22fde0c8..491f53ab 100644 --- a/lib/builtin/problems/src/problems_view.dart +++ b/lib/builtin/problems/src/problems_view.dart @@ -18,6 +18,7 @@ class ProblemsView extends StatefulWidget { class _ProblemsViewState extends State { ProblemsController? _controller; + String _filter = ''; @override void didChangeDependencies() { @@ -46,66 +47,46 @@ class _ProblemsViewState extends State { label: 'problems panel', container: true, explicitChildNodes: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - child: Row( - children: [ - Expanded( - child: ClideText( - 'Problems (${c.problems.length})', - fontSize: clideFontCaption, - color: tokens.sidebarForeground, - ), - ), - Semantics( - button: true, - label: 'refresh problems', - child: GestureDetector( - onTap: () => unawaited(c.refresh()), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: ClideText( - 'Refresh', - fontSize: clideFontCaption, - color: tokens.sidebarForeground, - ), + child: () { + final lf = _filter.toLowerCase(); + final filtered = lf.isEmpty ? c.problems : c.problems.where((p) => p.message.toLowerCase().contains(lf) || p.source.toLowerCase().contains(lf)).toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClideFilterBox(hint: 'Filter problems…', onChanged: (v) => setState(() => _filter = v)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + child: Row( + children: [ + Expanded(child: ClideText('Problems (${filtered.length})', fontSize: clideFontCaption, color: tokens.sidebarForeground)), + Semantics( + button: true, + label: 'refresh problems', + child: GestureDetector( + onTap: () => unawaited(c.refresh()), + child: MouseRegion(cursor: SystemMouseCursors.click, child: ClideText('Refresh', fontSize: clideFontCaption, color: tokens.sidebarForeground)), ), ), - ), - ], - ), - ), - if (c.loading && c.problems.isEmpty) - const Padding( - padding: EdgeInsets.all(12), - child: ClideText('Scanning…', muted: true), - ), - if (!c.loading && c.problems.isEmpty) - const Padding( - padding: EdgeInsets.all(12), - child: ClideText( - 'No problems found.', - muted: true, - ), - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - for (final p in c.problems) _ProblemRow(problem: p), ], ), ), - ), - ], - ), + if (c.loading && c.problems.isEmpty) + const Padding(padding: EdgeInsets.all(12), child: ClideText('Scanning…', muted: true)), + if (!c.loading && filtered.isEmpty) + const Padding(padding: EdgeInsets.all(12), child: ClideText('No problems found.', muted: true)), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [for (final p in filtered) _ProblemRow(problem: p)], + ), + ), + ), + ], + ); + }(), ); }, ); diff --git a/lib/builtin/tickets/src/tickets_view.dart b/lib/builtin/tickets/src/tickets_view.dart index 7164b267..d66f0711 100644 --- a/lib/builtin/tickets/src/tickets_view.dart +++ b/lib/builtin/tickets/src/tickets_view.dart @@ -16,6 +16,7 @@ class _TicketsViewState extends State { List<_TicketEntry> _tickets = []; String? _error; bool _loading = true; + String _filter = ''; @override void didChangeDependencies() { @@ -70,12 +71,18 @@ class _TicketsViewState extends State { child: ClideText('No tickets found.\nRun `pql ticket new` to create one.', muted: true), ); } - return ListView.builder( - itemCount: _tickets.length, - itemBuilder: (ctx, i) { - final t = _tickets[i]; - return _TicketRow(entry: t, tokens: tokens); - }, + final lf = _filter.toLowerCase(); + final filtered = lf.isEmpty ? _tickets : _tickets.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').toLowerCase().contains(lf)).toList(); + return Column( + children: [ + ClideFilterBox(hint: 'Filter tickets…', onChanged: (v) => setState(() => _filter = v)), + Expanded( + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (ctx, i) => _TicketRow(entry: filtered[i], tokens: tokens), + ), + ), + ], ); } } diff --git a/lib/widgets/src/clide_filter_box.dart b/lib/widgets/src/clide_filter_box.dart new file mode 100644 index 00000000..af3d6e5e --- /dev/null +++ b/lib/widgets/src/clide_filter_box.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/widgets/src/clide_icon.dart'; +import 'package:clide/widgets/src/icons/phosphor.dart'; +import 'package:clide/widgets/src/typography.dart'; +import 'package:flutter/widgets.dart'; + +class ClideFilterBox extends StatefulWidget { + const ClideFilterBox({ + super.key, + required this.onChanged, + this.hint = 'Filter…', + this.debounce = const Duration(milliseconds: 200), + this.onSubmitted, + }); + + final ValueChanged onChanged; + final String hint; + final Duration debounce; + final ValueChanged? onSubmitted; + + @override + State createState() => _ClideFilterBoxState(); +} + +class _ClideFilterBoxState extends State { + final _controller = TextEditingController(); + final _focus = FocusNode(); + Timer? _debounceTimer; + + @override + void dispose() { + _debounceTimer?.cancel(); + _controller.dispose(); + _focus.dispose(); + super.dispose(); + } + + void _onChanged(String value) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(widget.debounce, () => widget.onChanged(value)); + setState(() {}); + } + + void _clear() { + _controller.clear(); + _debounceTimer?.cancel(); + widget.onChanged(''); + _focus.requestFocus(); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final hasText = _controller.text.isNotEmpty; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Semantics( + label: widget.hint, + textField: true, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: tokens.globalBorder), + borderRadius: BorderRadius.circular(4), + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + child: Row( + children: [ + ClideIcon(PhosphorIcons.magnifyingGlass, size: 13, color: tokens.globalTextMuted), + const SizedBox(width: 6), + Expanded( + child: EditableText( + controller: _controller, + focusNode: _focus, + style: TextStyle(fontSize: clideFontCaption, color: tokens.globalForeground), + cursorColor: tokens.globalFocus, + backgroundCursorColor: tokens.globalTextMuted, + maxLines: 1, + onChanged: _onChanged, + onSubmitted: widget.onSubmitted != null ? (v) => widget.onSubmitted!(v) : null, + ), + ), + if (hasText) + GestureDetector( + onTap: _clear, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: ClideIcon(PhosphorIcons.xMark, size: 11, color: tokens.globalTextMuted), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 7145928a..b4254600 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -7,6 +7,7 @@ library; export 'src/clide_button.dart'; export 'src/clide_divider.dart'; +export 'src/clide_filter_box.dart'; export 'src/clide_icon.dart'; export 'src/clide_icon_rail.dart'; export 'src/clide_palette.dart';