feat(search): match word prefix, not arbitrary substring (#96)

Searching for "foo" now finds "foobar" (prefix of a word) but not
"blafoo" (suffix). The FTS5 query already used the foo* prefix form;
this commit extends the same semantics to folder-name and address
matching in the search screen, replacing contains() with a
word-boundary regex check. Tests added for all three paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-15 20:20:16 +02:00
co-authored by Claude Sonnet 4.6
parent 4a25d831fb
commit 9d19bdb81b
3 changed files with 115 additions and 3 deletions
+9 -3
View File
@@ -15,6 +15,11 @@ final _searchHistoryProvider =
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
});
/// Returns true if [text] contains a word that starts with [query].
/// "foo" matches "foobar" or "My Foobar" but NOT "blafoo".
bool _hasWordPrefix(String text, String query) =>
RegExp(r'\b' + RegExp.escape(query), caseSensitive: false).hasMatch(text);
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key, this.accountId});
final String? accountId;
@@ -79,7 +84,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
).wait;
final matchedMailboxes = allMailboxes
.where((m) => m.name.toLowerCase().contains(ql))
.where((m) => _hasWordPrefix(m.name, ql))
.toList()
..sort(compareMailboxes);
@@ -91,8 +96,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
for (final addr in [...email.from, ...email.to, ...email.cc]) {
final key = '${email.accountId}:${addr.email}';
if (seen.contains(key)) continue;
final matchesEmail = addr.email.toLowerCase().contains(ql);
final matchesName = addr.name?.toLowerCase().contains(ql) ?? false;
final matchesEmail = _hasWordPrefix(addr.email, ql);
final matchesName =
addr.name != null && _hasWordPrefix(addr.name!, ql);
if (!matchesEmail && !matchesName) continue;
seen.add(key);
final addrEmail = addr.email;
+32
View File
@@ -421,6 +421,38 @@ void main() {
expect(results3, isEmpty);
});
test('searchEmailsGlobal matches word prefix but not suffix', () async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
subject: const Value('foobar baz'),
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
subject: const Value('blafoo baz'),
receivedAt: DateTime(2024),
),
);
// 'foo' is a prefix of 'foobar' — should match; 'blafoo' is not a
// prefix match so only one result expected.
final results = await r.emails.searchEmailsGlobal(null, 'foo');
expect(results, hasLength(1));
expect(results.first.subject, 'foobar baz');
});
test('searchAddresses returns results sorted by most recently used',
() async {
final r = _makeRepos();
+74
View File
@@ -160,6 +160,80 @@ void main() {
expect(find.text('Archive'), findsOneWidget);
});
testWidgets('folder with query as word prefix is matched', (tester) async {
const foobarMailbox = Mailbox(
id: 'acc-1:Foobar',
accountId: 'acc-1',
path: 'Foobar',
name: 'Foobar',
unreadCount: 0,
totalCount: 0,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([foobarMailbox]),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'foo');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Folders'), findsOneWidget);
expect(find.text('Foobar'), findsOneWidget);
});
testWidgets('folder whose name ends with query is not matched', (
tester,
) async {
const blafooMailbox = Mailbox(
id: 'acc-1:Blafoo',
accountId: 'acc-1',
path: 'Blafoo',
name: 'Blafoo',
unreadCount: 0,
totalCount: 0,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([blafooMailbox]),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'foo');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Blafoo'), findsNothing);
expect(find.text('No results'), findsOneWidget);
});
testWidgets('tapping clear button resets results to placeholder', (
tester,
) async {