diff --git a/lib/note_list.dart b/lib/note_list.dart index bbba0a9..a609e54 100644 --- a/lib/note_list.dart +++ b/lib/note_list.dart @@ -336,10 +336,20 @@ Happy note taking! ✨ final terms = query.split(' '); // Split search into words final searchTags = terms.where((t) => t.startsWith(RegExp(r'[#@+]'))).toSet(); - final searchText = terms.where((t) => !t.startsWith(RegExp(r'[#@+]'))).join(' '); + final searchFolders = + terms.where((t) => t.startsWith('/')).map((t) => t.substring(1)); + final searchText = + terms.where((t) => !t.startsWith(RegExp(r'[#@+/]'))).join(' '); filteredNotes = notes.where((note) { final filename = note.uri.pathSegments.last.toLowerCase(); + + // Folder terms match the start of the note's folder path, like tags do. + final relative = path.relative(note.parent.path, from: notesDirectoryPath); + final folder = + relative == '.' ? '' : path.split(relative).join('/').toLowerCase(); + if (!searchFolders.every(folder.startsWith)) return false; + final noteTags = _extractTags(note.readAsStringSync()); // ✅ Read file content ONLY if `Include File Content` is enabled @@ -822,7 +832,9 @@ Happy note taking! ✨ onEditingComplete: () {}, onSubmitted: (text) { final trimmedText = text.trim(); - if (_highlighted != null) { + if (trimmedText.startsWith(':')) { + _handleCommand(trimmedText); + } else if (_highlighted != null) { _openNote(filteredNotes[_highlighted!]); } else if (trimmedText.isNotEmpty && _isNewNote(trimmedText)) { _createNewNote(trimmedText); diff --git a/test/note_list_dpad_test.dart b/test/note_list_dpad_test.dart index ac48250..7351db3 100644 --- a/test/note_list_dpad_test.dart +++ b/test/note_list_dpad_test.dart @@ -1,21 +1,8 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:muzzle_velocity/note_editor.dart'; -import 'package:muzzle_velocity/note_list.dart'; -import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class _FakePathProvider extends PathProviderPlatform - with MockPlatformInterfaceMixin { - _FakePathProvider(this.path); - final String path; - - @override - Future getApplicationDocumentsPath() async => path; -} +import 'note_list_harness.dart'; Future _press(WidgetTester tester, LogicalKeyboardKey key) async { await tester.sendKeyEvent(key); @@ -23,31 +10,9 @@ Future _press(WidgetTester tester, LogicalKeyboardKey key) async { } void main() { - late Directory notes; - - setUp(() { - notes = Directory.systemTemp.createTempSync('mv-list'); - for (final name in ['alpha', 'beta', 'gamma']) { - File('${notes.path}/$name.md').writeAsStringSync('# $name\n'); - } - PathProviderPlatform.instance = _FakePathProvider(notes.path); - SharedPreferences.setMockInitialValues({'initialized': true}); - }); - - Future pumpList(WidgetTester tester) async { - await tester.runAsync(() async { - await tester.pumpWidget(MaterialApp( - home: NoteListScreen( - toggleTheme: (_) {}, - toggleTags: () {}, - isDarkMode: false, - showTags: true, - ), - )); - await Future.delayed(const Duration(milliseconds: 200)); - }); - await tester.pumpAndSettle(); - } + setUp(() => setUpNotes({ + for (final name in ['alpha', 'beta', 'gamma']) '$name.md': '# $name\n', + })); bool searchHasFocus(WidgetTester tester) => tester.widget(find.byType(TextField)).focusNode!.hasFocus; diff --git a/test/note_list_harness.dart b/test/note_list_harness.dart new file mode 100644 index 0000000..439be35 --- /dev/null +++ b/test/note_list_harness.dart @@ -0,0 +1,56 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:muzzle_velocity/note_list.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakePathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + _FakePathProvider(this.path); + final String path; + + @override + Future getApplicationDocumentsPath() async => path; +} + +/// Creates a temporary notes folder with [files] (relative path to content) +/// and makes it the app's default notes directory. +Directory setUpNotes(Map files) { + final notes = Directory.systemTemp.createTempSync('mv-list'); + files.forEach((name, content) { + File('${notes.path}/$name') + ..createSync(recursive: true) + ..writeAsStringSync(content); + }); + PathProviderPlatform.instance = _FakePathProvider(notes.path); + SharedPreferences.setMockInitialValues({'initialized': true}); + return notes; +} + +/// Pumps the note list, letting its real file I/O finish. +Future pumpList( + WidgetTester tester, { + void Function(bool)? toggleTheme, + VoidCallback? toggleTags, +}) async { + await tester.runAsync(() async { + await tester.pumpWidget(MaterialApp( + home: NoteListScreen( + toggleTheme: toggleTheme ?? (_) {}, + toggleTags: toggleTags ?? () {}, + isDarkMode: false, + showTags: true, + ), + )); + await Future.delayed(const Duration(milliseconds: 200)); + }); + await tester.pumpAndSettle(); +} + +/// Titles of the notes currently listed. +List listedNotes(WidgetTester tester) => [ + for (final tile in tester.widgetList(find.byType(ListTile))) + (tile.title as Text).data!, + ]; diff --git a/test/note_list_search_test.dart b/test/note_list_search_test.dart new file mode 100644 index 0000000..63acbe1 --- /dev/null +++ b/test/note_list_search_test.dart @@ -0,0 +1,64 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'note_list_harness.dart'; + +Future _search(WidgetTester tester, String query) async { + await tester.enterText(find.byType(TextField), query); + // Filtering is debounced. + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); +} + +void main() { + late Directory notes; + + setUp(() => notes = setUpNotes({ + 'root.md': '# root\n', + 'work/plan.md': '# plan\n', + 'work/deep/notes.md': '# notes\n', + 'home/plan.md': '# plan\n', + })); + + testWidgets('/folder terms filter by the start of the folder path', + (tester) async { + await pumpList(tester); + expect(listedNotes(tester), hasLength(4)); + + await _search(tester, '/work'); + expect(listedNotes(tester)..sort(), ['notes', 'plan']); + + await _search(tester, '/work/deep'); + expect(listedNotes(tester), ['notes']); + + await _search(tester, '/ho plan'); + expect(listedNotes(tester), ['plan']); + + await _search(tester, '/nowhere'); + expect(listedNotes(tester), isEmpty); + }); + + testWidgets('Enter runs a command instead of creating a note', + (tester) async { + var tagToggles = 0; + await pumpList(tester, toggleTags: () => tagToggles++); + + await _search(tester, ':tags'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(tagToggles, 1); + expect(tester.widget(find.byType(TextField)).controller!.text, + isEmpty); + expect(File('${notes.path}/tags.md').existsSync(), isFalse); + }); + + testWidgets('an unknown command does not create a note', (tester) async { + await pumpList(tester); + await _search(tester, ':nope'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(File('${notes.path}/nope.md').existsSync(), isFalse); + }); +}