@tduncan / Cortana / commits / ab82e41

fix: preserve message scrolling, export conversation folders, and invalidate stale catalog searches

fix: preserve message scrolling, export conversation folders, and invalidate stale catalog searches

abdulmominsakib committed Jul 5, 2026 at 16:08 UTC ab82e4122fc03066adbe7f312035f20afbdff460
5 files changed +339 -102
lib/core/services/data_backup_service.dart
+116 -77
@@ -16,53 +16,53 @@ import '../../objectbox.g.dart';
16
17 class DataBackupService {
18 Map<String, dynamic> _messageToMap(Message message) => {
19 - 'id': message.id,
20 - 'conversationId': message.conversationId,
21 - 'role': message.role.name,
22 - 'content': message.content,
23 - 'createdAt': message.createdAt.toIso8601String(),
24 - 'status': message.status.name,
25 - 'modelId': message.modelId,
26 - 'tokenCount': message.tokenCount,
27 - 'errorMessage': message.errorMessage,
28 - 'attachmentPaths': message.attachmentPaths,
29 - 'generationTimeMs': message.generationTimeMs,
30 - 'reasoningContent': message.reasoningContent,
31 - 'toolCallId': message.toolCallId,
32 - 'isProcessing': message.isProcessing,
33 - 'toolSessionId': message.toolSessionId,
34 - 'variantGroupId': message.variantGroupId,
35 - 'variantIndex': message.variantIndex,
36 - 'threadOrder': message.threadOrder,
37 - 'isActiveVariant': message.isActiveVariant,
38 - 'parentMessageId': message.parentMessageId,
39 - };
19 + 'id': message.id,
20 + 'conversationId': message.conversationId,
21 + 'role': message.role.name,
22 + 'content': message.content,
23 + 'createdAt': message.createdAt.toIso8601String(),
24 + 'status': message.status.name,
25 + 'modelId': message.modelId,
26 + 'tokenCount': message.tokenCount,
27 + 'errorMessage': message.errorMessage,
28 + 'attachmentPaths': message.attachmentPaths,
29 + 'generationTimeMs': message.generationTimeMs,
30 + 'reasoningContent': message.reasoningContent,
31 + 'toolCallId': message.toolCallId,
32 + 'isProcessing': message.isProcessing,
33 + 'toolSessionId': message.toolSessionId,
34 + 'variantGroupId': message.variantGroupId,
35 + 'variantIndex': message.variantIndex,
36 + 'threadOrder': message.threadOrder,
37 + 'isActiveVariant': message.isActiveVariant,
38 + 'parentMessageId': message.parentMessageId,
39 + };
40
41 Map<String, dynamic> _conversationToMap(Conversation domain) => {
42 - 'id': domain.id,
43 - 'title': domain.title,
44 - 'createdAt': domain.createdAt.toIso8601String(),
45 - 'updatedAt': domain.updatedAt.toIso8601String(),
46 - 'isPinned': domain.isPinned,
47 - 'personaId': domain.personaId,
48 - 'serverId': domain.serverId,
49 - 'modelId': domain.modelId,
50 - 'messageCount': domain.messageCount,
51 - 'lastMessagePreview': domain.lastMessagePreview,
52 - 'systemPrompt': domain.systemPrompt,
53 - 'temperature': domain.temperature,
54 - 'topP': domain.topP,
55 - 'maxTokens': domain.maxTokens,
56 - 'contextLength': domain.contextLength,
57 - 'mcpEnabled': domain.mcpEnabled,
58 - 'smartReplies': domain.smartReplies,
59 - 'smartRepliesLastMessageId': domain.smartRepliesLastMessageId,
60 - 'folderId': domain.folderId,
61 - 'isTemporary': domain.isTemporary,
62 - 'isArchived': domain.isArchived,
63 - 'characterCount': domain.characterCount,
64 - 'totalTokenCount': domain.totalTokenCount,
65 - };
42 + 'id': domain.id,
43 + 'title': domain.title,
44 + 'createdAt': domain.createdAt.toIso8601String(),
45 + 'updatedAt': domain.updatedAt.toIso8601String(),
46 + 'isPinned': domain.isPinned,
47 + 'personaId': domain.personaId,
48 + 'serverId': domain.serverId,
49 + 'modelId': domain.modelId,
50 + 'messageCount': domain.messageCount,
51 + 'lastMessagePreview': domain.lastMessagePreview,
52 + 'systemPrompt': domain.systemPrompt,
53 + 'temperature': domain.temperature,
54 + 'topP': domain.topP,
55 + 'maxTokens': domain.maxTokens,
56 + 'contextLength': domain.contextLength,
57 + 'mcpEnabled': domain.mcpEnabled,
58 + 'smartReplies': domain.smartReplies,
59 + 'smartRepliesLastMessageId': domain.smartRepliesLastMessageId,
60 + 'folderId': domain.folderId,
61 + 'isTemporary': domain.isTemporary,
62 + 'isArchived': domain.isArchived,
63 + 'characterCount': domain.characterCount,
64 + 'totalTokenCount': domain.totalTokenCount,
65 + };
66
67 List<Map<String, dynamic>> _exportSavedMessages(Store store) {
68 final box = store.box<SavedMessageEntity>();
@@ -94,6 +94,21 @@ class DataBackupService {
94 }).toList();
95 }
96
97 + List<Map<String, dynamic>> _conversationFoldersForConversationIds(
98 + Map<String, dynamic> all,
99 + Set<String> conversationIds,
100 + ) {
101 + final folderIds = (all['conversations'] as List)
102 + .where((c) => conversationIds.contains((c as Map)['id']))
103 + .map((c) => (c as Map)['folderId'] as String?)
104 + .whereType<String>()
105 + .toSet();
106 + return (all['conversationFolders'] as List)
107 + .where((f) => folderIds.contains((f as Map)['id']))
108 + .toList()
109 + .cast<Map<String, dynamic>>();
110 + }
111 +
112 List<Map<String, dynamic>> _exportSavedMessageFolders(Store store) {
113 final box = store.box<SavedMessageFolderEntity>();
114 return box.getAll().map((e) {
@@ -142,10 +157,14 @@ class DataBackupService {
157 final messageBox = store.box<MessageEntity>();
158 final personaBox = store.box<PersonaEntity>();
159
145 - final conversations =
146 - convBox.getAll().map((e) => _conversationToMap(e.toDomain())).toList();
147 - final messages =
148 - messageBox.getAll().map((e) => _messageToMap(e.toDomain())).toList();
160 + final conversations = convBox
161 + .getAll()
162 + .map((e) => _conversationToMap(e.toDomain()))
163 + .toList();
164 + final messages = messageBox
165 + .getAll()
166 + .map((e) => _messageToMap(e.toDomain()))
167 + .toList();
168
169 final personas = personaBox
170 .getAll()
@@ -192,6 +211,7 @@ class DataBackupService {
211 'messages': all['messages'],
212 'savedMessages': all['savedMessages'],
213 'savedMessageFolders': all['savedMessageFolders'],
214 + 'conversationFolders': all['conversationFolders'],
215 };
216 }
217
@@ -213,6 +233,9 @@ class DataBackupService {
233 final savedMessageFolders = (all['savedMessageFolders'] as List)
234 .where((f) => folderIds.contains((f as Map)['id']))
235 .toList();
236 + final conversationFolders = _conversationFoldersForConversationIds(all, {
237 + conversationId,
238 + });
239
240 return {
241 'version': 3,
@@ -222,6 +245,7 @@ class DataBackupService {
245 'messages': messages,
246 'savedMessages': savedMessages,
247 'savedMessageFolders': savedMessageFolders,
248 + 'conversationFolders': conversationFolders,
249 };
250 }
251
@@ -237,9 +261,7 @@ class DataBackupService {
261 .where((m) => conversationIds.contains((m as Map)['conversationId']))
262 .toList();
263 final savedMessages = (all['savedMessages'] as List)
240 - .where(
241 - (m) => conversationIds.contains((m as Map)['conversationId']),
242 - )
264 + .where((m) => conversationIds.contains((m as Map)['conversationId']))
265 .toList();
266 final folderIds = savedMessages
267 .map((m) => (m as Map)['folderId'] as String?)
@@ -248,6 +270,10 @@ class DataBackupService {
270 final savedMessageFolders = (all['savedMessageFolders'] as List)
271 .where((f) => folderIds.contains((f as Map)['id']))
272 .toList();
273 + final conversationFolders = _conversationFoldersForConversationIds(
274 + all,
275 + conversationIds,
276 + );
277
278 return {
279 'version': 3,
@@ -257,15 +283,16 @@ class DataBackupService {
283 'messages': messages,
284 'savedMessages': savedMessages,
285 'savedMessageFolders': savedMessageFolders,
286 + 'conversationFolders': conversationFolders,
287 };
288 }
289
290 String exportConversationsForIdsAsJson(
291 Store store,
292 Set<String> conversationIds,
266 - ) =>
267 - const JsonEncoder.withIndent(' ')
268 - .convert(exportConversationsForIds(store, conversationIds));
293 + ) => const JsonEncoder.withIndent(
294 + ' ',
295 + ).convert(exportConversationsForIds(store, conversationIds));
296
297 Map<String, dynamic> exportPersonas(Store store) {
298 final all = exportAll(store);
@@ -302,8 +329,9 @@ class DataBackupService {
329 const JsonEncoder.withIndent(' ').convert(exportConversations(store));
330
331 String exportConversationAsJson(Store store, String conversationId) =>
305 - const JsonEncoder.withIndent(' ')
306 - .convert(exportConversation(store, conversationId));
332 + const JsonEncoder.withIndent(
333 + ' ',
334 + ).convert(exportConversation(store, conversationId));
335
336 String exportPersonasAsJson(Store store) =>
337 const JsonEncoder.withIndent(' ').convert(exportPersonas(store));
@@ -312,10 +340,9 @@ class DataBackupService {
340 String settingsJson, {
341 Store? store,
342 SharedPreferences? prefs,
315 - }) =>
316 - const JsonEncoder.withIndent(' ').convert(
317 - exportSettings(settingsJson, store: store, prefs: prefs),
318 - );
343 + }) => const JsonEncoder.withIndent(
344 + ' ',
345 + ).convert(exportSettings(settingsJson, store: store, prefs: prefs));
346
347 ArchiveFile _jsonArchiveFile(String name, String json) {
348 final bytes = utf8.encode(json);
@@ -334,9 +361,7 @@ class DataBackupService {
361 exportConversationsAsJson(store),
362 ),
363 )
337 - ..addFile(
338 - _jsonArchiveFile('personas.json', exportPersonasAsJson(store)),
339 - )
364 + ..addFile(_jsonArchiveFile('personas.json', exportPersonasAsJson(store)))
365 ..addFile(
366 _jsonArchiveFile(
367 'settings.json',
@@ -347,8 +372,9 @@ class DataBackupService {
372 }
373
374 String exportAllAsJson(Store store, {SharedPreferences? prefs}) {
350 - return const JsonEncoder.withIndent(' ')
351 - .convert(exportAll(store, prefs: prefs));
375 + return const JsonEncoder.withIndent(
376 + ' ',
377 + ).convert(exportAll(store, prefs: prefs));
378 }
379
380 Future<void> importFromJson(Store store, String jsonString) async {
@@ -383,7 +409,10 @@ class DataBackupService {
409 final name = backupImportString(item['name']);
410 final createdAt = backupImportDateTime(item['createdAt']);
411 final updatedAt = backupImportDateTime(item['updatedAt']);
386 - if (id == null || name == null || createdAt == null || updatedAt == null) {
412 + if (id == null ||
413 + name == null ||
414 + createdAt == null ||
415 + updatedAt == null) {
416 continue;
417 }
418 final persona = Persona(
@@ -400,8 +429,9 @@ class DataBackupService {
429 ? Map<String, dynamic>.from(item['preferredParams'] as Map)
430 : null,
431 );
403 - final query =
404 - personaBox.query(PersonaEntity_.id.equals(persona.id)).build();
432 + final query = personaBox
433 + .query(PersonaEntity_.id.equals(persona.id))
434 + .build();
435 final existing = query.findFirst();
436 query.close();
437 final entity = PersonaEntity.fromDomain(persona);
@@ -427,7 +457,9 @@ class DataBackupService {
457 final server = Server(
458 id: id,
459 name: name,
430 - type: ServerType.values.byName(item['type'] as String? ?? 'lmStudio'),
460 + type: ServerType.values.byName(
461 + item['type'] as String? ?? 'lmStudio',
462 + ),
463 host: item['host'] as String? ?? 'localhost',
464 port: item['port'] as int? ?? 1234,
465 apiKey: item['apiKey'] as String?,
@@ -440,7 +472,9 @@ class DataBackupService {
472 iconName: item['iconName'] as String?,
473 pathPrefix: item['pathPrefix'] as String?,
474 );
443 - final query = serverBox.query(ServerEntity_.id.equals(server.id)).build();
475 + final query = serverBox
476 + .query(ServerEntity_.id.equals(server.id))
477 + .build();
478 final existing = query.findFirst();
479 query.close();
480 final entity = ServerEntity.fromDomain(server);
@@ -499,8 +533,9 @@ class DataBackupService {
533 isArchived: item['isArchived'] as bool? ?? false,
534 savedAt: savedAt,
535 );
502 - final query =
503 - savedBox.query(SavedMessageEntity_.id.equals(entity.id)).build();
536 + final query = savedBox
537 + .query(SavedMessageEntity_.id.equals(entity.id))
538 + .build();
539 final existing = query.findFirst();
540 query.close();
541 if (existing != null) entity.internalId = existing.internalId;
@@ -568,8 +603,9 @@ class DataBackupService {
603 characterCount: item['characterCount'] as int? ?? 0,
604 totalTokenCount: item['totalTokenCount'] as int?,
605 );
571 - final query =
572 - convBox.query(ConversationEntity_.id.equals(conversation.id)).build();
606 + final query = convBox
607 + .query(ConversationEntity_.id.equals(conversation.id))
608 + .build();
609 final existing = query.findFirst();
610 query.close();
611 final entity = ConversationEntity.fromDomain(conversation);
@@ -625,7 +661,9 @@ class DataBackupService {
661
662 final entity = MessageEntity.fromDomain(message)
663 ..conversation.target = convEntity;
628 - final query = messageBox.query(MessageEntity_.id.equals(message.id)).build();
664 + final query = messageBox
665 + .query(MessageEntity_.id.equals(message.id))
666 + .build();
667 final existing = query.findFirst();
668 query.close();
669 if (existing != null) entity.internalId = existing.internalId;
@@ -641,7 +679,8 @@ class DataBackupService {
679 for (final entry in raw) {
680 if (entry is! String || entry.isEmpty) continue;
681 final normalized = p.normalize(entry);
644 - if (normalized == attachmentsDir || p.isWithin(attachmentsDir, normalized)) {
682 + if (normalized == attachmentsDir ||
683 + p.isWithin(attachmentsDir, normalized)) {
684 sanitized.add(entry);
685 }
686 }
lib/features/chat/views/components/message_list/message_list.dart
+9
@@ -123,6 +123,7 @@ class _MessageListConsumer extends ConsumerStatefulWidget {
123
124 class _MessageListConsumerState extends ConsumerState<_MessageListConsumer> {
125 final _messageKeys = <String, GlobalKey>{};
126 + bool _checkedInitialScrollTarget = false;
127
128 @override
129 void didUpdateWidget(covariant _MessageListConsumer oldWidget) {
@@ -156,6 +157,14 @@ class _MessageListConsumerState extends ConsumerState<_MessageListConsumer> {
157 }
158 });
159
160 + if (!_checkedInitialScrollTarget) {
161 + _checkedInitialScrollTarget = true;
162 + final pendingScroll = ref.read(scrollToMessageIdProvider);
163 + if (pendingScroll != null) {
164 + _scrollToMessage(pendingScroll);
165 + }
166 + }
167 +
168 final streamingMessage = ref.watch(
169 chatProvider.select((s) => s.streamingMessage),
170 );
lib/features/lm_studio_catalog/providers/lm_studio_catalog_providers.dart
+27 -25
@@ -16,15 +16,17 @@ final lmStudioCatalogServiceProvider = Provider<LmStudioCatalogService>((ref) {
16 return LmStudioCatalogService(ref.read(dioProvider));
17 });
18
19 -final lmStudioDownloadServiceProvider = Provider<LmStudioDownloadService>((ref) {
19 +final lmStudioDownloadServiceProvider = Provider<LmStudioDownloadService>((
20 + ref,
21 +) {
22 return LmStudioDownloadService(ref.read(dioProvider));
23 });
24
25 final lmStudioStaffPicksProvider =
26 FutureProvider.autoDispose<List<LmCatalogModel>>((ref) async {
25 - final service = ref.read(lmStudioCatalogServiceProvider);
26 - return service.fetchStaffPicks();
27 -});
27 + final service = ref.read(lmStudioCatalogServiceProvider);
28 + return service.fetchStaffPicks();
29 + });
30
31 class LmCatalogSearchState {
32 const LmCatalogSearchState({
@@ -82,26 +84,28 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
84 return;
85 }
86 _query = trimmed;
87 + final generation = ++_searchGeneration;
88
89 if (trimmed.isEmpty) {
90 state = const LmCatalogSearchState();
91 return;
92 }
93
91 - final generation = ++_searchGeneration;
94 state = state.copyWith(isLoading: true, clearError: true);
95 final service = ref.read(lmStudioCatalogServiceProvider);
96
97 try {
98 final staffPicks = await ref.read(lmStudioStaffPicksProvider.future);
99 if (generation != _searchGeneration) return;
98 - final staffMatches =
99 - staffPicks.where((m) => m.matchesQuery(trimmed)).toList();
100 + final staffMatches = staffPicks
101 + .where((m) => m.matchesQuery(trimmed))
102 + .toList();
103 final page = await service.searchHuggingFace(query: trimmed);
104 if (generation != _searchGeneration) return;
105 final staffIds = staffMatches.map((m) => m.id).toSet();
103 - final community =
104 - page.models.where((m) => !staffIds.contains(m.id)).toList();
106 + final community = page.models
107 + .where((m) => !staffIds.contains(m.id))
108 + .toList();
109
110 state = LmCatalogSearchState(
111 staffMatches: staffMatches,
@@ -125,8 +129,9 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
129 try {
130 final page = await service.searchHuggingFace(nextUrl: state.nextUrl);
131 final existingIds = state.allModels.map((m) => m.id).toSet();
128 - final more =
129 - page.models.where((m) => !existingIds.contains(m.id)).toList();
132 + final more = page.models
133 + .where((m) => !existingIds.contains(m.id))
134 + .toList();
135
136 state = state.copyWith(
137 communityModels: [...state.communityModels, ...more],
@@ -142,20 +147,17 @@ class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
147
148 final lmCatalogSearchProvider =
149 NotifierProvider<LmCatalogSearchNotifier, LmCatalogSearchState>(
145 - LmCatalogSearchNotifier.new,
146 -);
147 -
148 -final lmModelDetailProvider =
149 - FutureProvider.autoDispose.family<LmModelDetail, LmCatalogModel>(
150 - (ref, model) async {
151 - final service = ref.read(lmStudioCatalogServiceProvider);
152 - return service.fetchModelDetail(model);
153 -});
150 + LmCatalogSearchNotifier.new,
151 + );
152 +
153 +final lmModelDetailProvider = FutureProvider.autoDispose
154 + .family<LmModelDetail, LmCatalogModel>((ref, model) async {
155 + final service = ref.read(lmStudioCatalogServiceProvider);
156 + return service.fetchModelDetail(model);
157 + });
158
159 class LmDownloadManagerState {
156 - const LmDownloadManagerState({
157 - this.jobs = const [],
158 - });
160 + const LmDownloadManagerState({this.jobs = const []});
161
162 final List<LmDownloadJob> jobs;
163
@@ -445,8 +447,8 @@ class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
447
448 final lmDownloadManagerProvider =
449 NotifierProvider<LmDownloadManagerNotifier, LmDownloadManagerState>(
448 - LmDownloadManagerNotifier.new,
449 -);
450 + LmDownloadManagerNotifier.new,
451 + );
452
453 final lmActiveDownloadCountProvider = Provider<int>((ref) {
454 return ref.watch(lmDownloadManagerProvider).activeJobs.length;
test/core/services/data_backup_service_test.dart new
+107
@@ -0,0 +1,107 @@
1 +import 'dart:convert';
2 +
3 +import 'package:flutter_test/flutter_test.dart';
4 +import 'package:localmind/core/services/data_backup_service.dart';
5 +import 'package:localmind/core/storage/entities.dart';
6 +import 'package:localmind/features/conversations/data/models/conversation.dart';
7 +import 'package:localmind/objectbox.g.dart';
8 +
9 +void main() {
10 + test('conversation exports include the referenced folder metadata', () {
11 + final folder = ConversationFolderEntity(
12 + id: 'folder-1',
13 + name: 'Inbox',
14 + sortOrder: 3,
15 + createdAt: DateTime.utc(2026, 7, 5),
16 + );
17 + final otherFolder = ConversationFolderEntity(
18 + id: 'folder-2',
19 + name: 'Archive',
20 + sortOrder: 9,
21 + createdAt: DateTime.utc(2026, 7, 4),
22 + );
23 + final conversation = ConversationEntity.fromDomain(
24 + Conversation(
25 + id: 'conv-1',
26 + title: 'Exported Chat',
27 + createdAt: DateTime.utc(2026, 7, 5),
28 + updatedAt: DateTime.utc(2026, 7, 5),
29 + folderId: folder.id,
30 + ),
31 + );
32 + final otherConversation = ConversationEntity.fromDomain(
33 + Conversation(
34 + id: 'conv-2',
35 + title: 'Different Chat',
36 + createdAt: DateTime.utc(2026, 7, 5),
37 + updatedAt: DateTime.utc(2026, 7, 5),
38 + folderId: otherFolder.id,
39 + ),
40 + );
41 +
42 + final store = _FakeStore({
43 + ConversationEntity: _FakeBox([conversation, otherConversation]),
44 + MessageEntity: _FakeBox<MessageEntity>(const []),
45 + PersonaEntity: _FakeBox<PersonaEntity>(const []),
46 + SavedMessageEntity: _FakeBox<SavedMessageEntity>(const []),
47 + SavedMessageFolderEntity: _FakeBox<SavedMessageFolderEntity>(const []),
48 + ServerEntity: _FakeBox<ServerEntity>(const []),
49 + ConversationFolderEntity: _FakeBox([folder, otherFolder]),
50 + });
51 +
52 + final service = DataBackupService();
53 +
54 + final allExport =
55 + jsonDecode(service.exportConversationsAsJson(store))
56 + as Map<String, dynamic>;
57 + expect(
58 + (allExport['conversationFolders'] as List)
59 + .cast<Map<String, dynamic>>()
60 + .map((folder) => folder['id'])
61 + .toSet(),
62 + {folder.id, otherFolder.id},
63 + );
64 +
65 + final singleExport =
66 + jsonDecode(service.exportConversationAsJson(store, conversation.id))
67 + as Map<String, dynamic>;
68 + expect(
69 + (singleExport['conversationFolders'] as List)
70 + .cast<Map<String, dynamic>>()
71 + .single['id'],
72 + folder.id,
73 + );
74 +
75 + final filteredExport =
76 + jsonDecode(
77 + service.exportConversationsForIdsAsJson(store, {
78 + otherConversation.id,
79 + }),
80 + )
81 + as Map<String, dynamic>;
82 + expect(
83 + (filteredExport['conversationFolders'] as List)
84 + .cast<Map<String, dynamic>>()
85 + .single['id'],
86 + otherFolder.id,
87 + );
88 + });
89 +}
90 +
91 +class _FakeStore extends Fake implements Store {
92 + _FakeStore(this._boxes);
93 +
94 + final Map<Type, Object> _boxes;
95 +
96 + @override
97 + Box<T> box<T>() => _boxes[T] as Box<T>;
98 +}
99 +
100 +class _FakeBox<T> extends Fake implements Box<T> {
101 + _FakeBox(this._items);
102 +
103 + final List<T> _items;
104 +
105 + @override
106 + List<T> getAll() => List<T>.from(_items);
107 +}
test/features/lm_studio_catalog/lm_catalog_search_notifier_test.dart new
+80
@@ -0,0 +1,80 @@
1 +import 'dart:async';
2 +
3 +import 'package:dio/dio.dart';
4 +import 'package:flutter_riverpod/flutter_riverpod.dart';
5 +import 'package:flutter_test/flutter_test.dart';
6 +import 'package:localmind/features/lm_studio_catalog/data/catalog_models.dart';
7 +import 'package:localmind/features/lm_studio_catalog/data/lm_studio_catalog_service.dart';
8 +import 'package:localmind/features/lm_studio_catalog/providers/lm_studio_catalog_providers.dart';
9 +
10 +void main() {
11 + test('clearing the query invalidates an in-flight search', () async {
12 + final searchCompleter = Completer<HfSearchPage>();
13 + final service = _FakeLmStudioCatalogService(
14 + staffPicks: [
15 + const LmCatalogModel(
16 + id: 'lmstudio/foo',
17 + owner: 'lmstudio',
18 + name: 'foo',
19 + description: 'staff pick',
20 + isStaffPick: true,
21 + isVerified: true,
22 + ),
23 + ],
24 + searchCompleter: searchCompleter,
25 + );
26 +
27 + final container = ProviderContainer(
28 + overrides: [
29 + lmStudioCatalogServiceProvider.overrideWithValue(service),
30 + lmStudioStaffPicksProvider.overrideWith(
31 + (ref) async => service.staffPicks,
32 + ),
33 + ],
34 + );
35 + addTearDown(container.dispose);
36 +
37 + final notifier = container.read(lmCatalogSearchProvider.notifier);
38 + final searchFuture = notifier.search('foo');
39 +
40 + await Future<void>.delayed(Duration.zero);
41 + await notifier.search('');
42 +
43 + expect(container.read(lmCatalogSearchProvider).allModels, isEmpty);
44 +
45 + searchCompleter.complete(
46 + const HfSearchPage(
47 + models: [
48 + LmCatalogModel(id: 'community/bar', owner: 'community', name: 'bar'),
49 + ],
50 + ),
51 + );
52 +
53 + await searchFuture;
54 + await Future<void>.delayed(Duration.zero);
55 +
56 + expect(container.read(lmCatalogSearchProvider).allModels, isEmpty);
57 + });
58 +}
59 +
60 +class _FakeLmStudioCatalogService extends LmStudioCatalogService {
61 + _FakeLmStudioCatalogService({
62 + required this.staffPicks,
63 + required this.searchCompleter,
64 + }) : super(Dio());
65 +
66 + final List<LmCatalogModel> staffPicks;
67 + final Completer<HfSearchPage> searchCompleter;
68 +
69 + @override
70 + Future<List<LmCatalogModel>> fetchStaffPicks() async => staffPicks;
71 +
72 + @override
73 + Future<HfSearchPage> searchHuggingFace({
74 + String query = '',
75 + int limit = 50,
76 + String? nextUrl,
77 + }) {
78 + return searchCompleter.future;
79 + }
80 +}