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) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 07:56:39 +02:00
co-authored by Claude Opus 4.6
parent 76029a02a0
commit eb8c9ecf5d
10 changed files with 270 additions and 144 deletions
+7
View File
@@ -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
+13 -6
View File
@@ -16,6 +16,7 @@ class _DecisionsViewState extends State<DecisionsView> {
List<_DecisionEntry> _decisions = [];
String? _error;
bool _loading = true;
String _filter = '';
@override
void didChangeDependencies() {
@@ -70,12 +71,18 @@ class _DecisionsViewState extends State<DecisionsView> {
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),
),
),
],
);
}
}
@@ -38,6 +38,14 @@ class FileTreeController extends ChangeNotifier {
final Map<String, List<FileEntry>> _entries = {};
List<FileEntry>? entriesFor(String path) => _entries[path];
List<FileEntry> allLoadedEntries() {
final out = <FileEntry>[];
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<void> load() async {
+70 -19
View File
@@ -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<FileTreeView> {
FileTreeController? _controller;
String _filter = '';
@override
void didChangeDependencies() {
@@ -61,31 +64,45 @@ class _FileTreeViewState extends State<FileTreeView> {
);
}
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<Widget> _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),
),
),
);
}
}
+18 -6
View File
@@ -22,6 +22,13 @@ class _GitPanelViewState extends State<GitPanelView> {
GitController? _controller;
final TextEditingController _commitMsg = TextEditingController();
final FocusNode _commitFocus = FocusNode();
String _filter = '';
List<Map<String, Object?>> _applyFilter(List<Map<String, Object?>> 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<GitPanelView> {
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<GitPanelView> {
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<GitPanelView> {
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<GitPanelView> {
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<GitPanelView> {
),
],
),
),
)),
],
),
);
},
);
+5 -52
View File
@@ -19,8 +19,6 @@ class PqlPanelView extends StatefulWidget {
class _PqlPanelViewState extends State<PqlPanelView> {
PqlController? _controller;
final TextEditingController _queryInput = TextEditingController();
final FocusNode _queryFocus = FocusNode();
@override
void didChangeDependencies() {
@@ -34,8 +32,6 @@ class _PqlPanelViewState extends State<PqlPanelView> {
@override
void dispose() {
_controller?.dispose();
_queryInput.dispose();
_queryFocus.dispose();
super.dispose();
}
@@ -56,10 +52,11 @@ class _PqlPanelViewState extends State<PqlPanelView> {
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<String, Object?> entry;
+36 -55
View File
@@ -18,6 +18,7 @@ class ProblemsView extends StatefulWidget {
class _ProblemsViewState extends State<ProblemsView> {
ProblemsController? _controller;
String _filter = '';
@override
void didChangeDependencies() {
@@ -46,66 +47,46 @@ class _ProblemsViewState extends State<ProblemsView> {
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)],
),
),
),
],
);
}(),
);
},
);
+13 -6
View File
@@ -16,6 +16,7 @@ class _TicketsViewState extends State<TicketsView> {
List<_TicketEntry> _tickets = [];
String? _error;
bool _loading = true;
String _filter = '';
@override
void didChangeDependencies() {
@@ -70,12 +71,18 @@ class _TicketsViewState extends State<TicketsView> {
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),
),
),
],
);
}
}
+99
View File
@@ -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<String> onChanged;
final String hint;
final Duration debounce;
final ValueChanged<String>? onSubmitted;
@override
State<ClideFilterBox> createState() => _ClideFilterBoxState();
}
class _ClideFilterBoxState extends State<ClideFilterBox> {
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),
),
),
],
),
),
),
);
}
}
+1
View File
@@ -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';