From 9d26d28579f224af2f8508ed6bf497187a093a87 Mon Sep 17 00:00:00 2001
From: ArthurHeitmann <art.tec.15@gmail.com>
Date: Tue, 25 Oct 2022 08:10:48 +0200
Subject: [PATCH] fixed search cancel race condition

---
 lib/background/searchService.dart             |  30 ++++-
 lib/utils.dart                                |   6 +
 .../FileHierarchyExplorer/FileExplorer.dart   |   3 +-
 lib/widgets/filesView/fileTabView.dart        |   2 +-
 lib/widgets/filesView/searchPanel.dart        | 117 +++++++++++++++---
 pubspec.lock                                  |   7 ++
 pubspec.yaml                                  |   1 +
 7 files changed, 142 insertions(+), 24 deletions(-)

diff --git a/lib/background/searchService.dart b/lib/background/searchService.dart
index 5253b955..17792e91 100644
--- a/lib/background/searchService.dart
+++ b/lib/background/searchService.dart
@@ -59,6 +59,7 @@ class SearchService {
   final StreamController<SearchResult> controller = StreamController<SearchResult>();
   Isolate? _isolate;
   SendPort? _sendPort;
+  final _isDoneCompleter = Completer<void>();
   final BoolProp isSearching;
 
   SearchService({ required this.isSearching });
@@ -96,30 +97,40 @@ class SearchService {
     else if (message is SearchResult)
       controller.add(message);
     else if (message is String && message == "done") {
+      if (!_isDoneCompleter.isCompleted) {
+        isSearching.value = false;
+        _isDoneCompleter.complete();
+      }
       if (controller.isClosed)
         return;
       controller.close();
       _isolate?.kill(priority: Isolate.beforeNextEvent);
-      isSearching.value = false;
     } else
       print("Unhandled message: $message");
   }
 
-  void cancel() async {
+  Future<void> cancel() {
     _sendPort?.send("cancel");
-    await Future.delayed(Duration(milliseconds: 500));
-    _isolate?.kill(priority: Isolate.immediate);
+    Future.delayed(Duration(milliseconds: 500)).then((_) {
+      _isolate?.kill(priority: Isolate.immediate);
+      if (!_isDoneCompleter.isCompleted)
+        _isDoneCompleter.complete();
+    });
+    return _isDoneCompleter.future;
   }
 }
 
 /// In new isolate search recursively for files with given extensions in given path
 class _SearchServiceWorker {
   bool _isCanceled = false;
+  int _resultsCount = 0;
+  static const int _maxResults = 1000;
 
   void search(SearchOptions options) async {
     var receivePort = ReceivePort();
     receivePort.listen(_onMessage);
     options.sendPort!.send(receivePort.sendPort);
+    var t1 = DateTime.now();
 
     try {
       if (options is SearchOptionsText)
@@ -129,7 +140,8 @@ class _SearchServiceWorker {
     }
     finally {
       options.sendPort!.send("done");
-      print("Search done");
+      var t2 = DateTime.now();
+      print("Search done in ${t2.difference(t1).inSeconds} s");
     }
   }
 
@@ -159,6 +171,11 @@ class _SearchServiceWorker {
         if (!test(line))
           continue;
         options.sendPort!.send(SearchResultText(filePath, line));
+        _resultsCount++;
+        if (_resultsCount >= _maxResults) {
+          _isCanceled = true;
+          return;
+        }
       }
     }
     else {
@@ -230,6 +247,9 @@ class _SearchServiceWorker {
     if (idData.id != options.id)
       return;
     options.sendPort!.send(SearchResultId(idData.xmlPath, idData));
+    _resultsCount++;
+    if (_resultsCount >= _maxResults)
+      _isCanceled = true;
   }
 
   void _searchIdInXml(String filePath, XmlElement root, SearchOptionsId options) {
diff --git a/lib/utils.dart b/lib/utils.dart
index d3f08df9..ccad4ee7 100644
--- a/lib/utils.dart
+++ b/lib/utils.dart
@@ -463,3 +463,9 @@ Future<List<String>> _getDatFileListFromMetadata(String metadataPath) async {
 
   return files;
 }
+
+String pluralStr(int number, String label, [String numberSuffix = ""]) {
+  if (number == 1)
+    return "$number$numberSuffix $label";
+  return "$number$numberSuffix ${label}s";
+}
diff --git a/lib/widgets/FileHierarchyExplorer/FileExplorer.dart b/lib/widgets/FileHierarchyExplorer/FileExplorer.dart
index d6548694..3ba87ed3 100644
--- a/lib/widgets/FileHierarchyExplorer/FileExplorer.dart
+++ b/lib/widgets/FileHierarchyExplorer/FileExplorer.dart
@@ -3,6 +3,7 @@ import 'package:desktop_drop/desktop_drop.dart';
 import 'package:flutter/material.dart';
 
 import '../../stateManagement/statusInfo.dart';
+import '../../utils.dart';
 import '../../widgets/theme/customTheme.dart';
 import '../../stateManagement/ChangeNotifierWidget.dart';
 import '../../stateManagement/FileHierarchy.dart';
@@ -29,7 +30,7 @@ class _FileExplorerState extends ChangeNotifierState<FileExplorer> {
 
     await Future.wait(futures);
 
-    messageLog.add("Opened ${details.files.length} file${details.files.length == 1 ? "" : "s"}");
+    messageLog.add("Opened ${pluralStr(details.files.length, "file")}");
   }
 
   @override
diff --git a/lib/widgets/filesView/fileTabView.dart b/lib/widgets/filesView/fileTabView.dart
index 99162cf9..6795ac24 100644
--- a/lib/widgets/filesView/fileTabView.dart
+++ b/lib/widgets/filesView/fileTabView.dart
@@ -84,7 +84,7 @@ class _FileTabViewState extends ChangeNotifierState<FileTabView> {
     windowManager.focus();
     setState(() {});
 
-    messageLog.add("Opened ${files.length} file${files.length == 1 ? "" : "s"}");
+    messageLog.add("Opened ${pluralStr(files.length, "file")}");
   }
 
   void pruneCachedWidgets() {
diff --git a/lib/widgets/filesView/searchPanel.dart b/lib/widgets/filesView/searchPanel.dart
index a8b1be92..27f61843 100644
--- a/lib/widgets/filesView/searchPanel.dart
+++ b/lib/widgets/filesView/searchPanel.dart
@@ -1,5 +1,6 @@
 
 import 'package:flutter/material.dart';
+import 'package:mutex/mutex.dart';
 
 import '../../background/IdsIndexer.dart';
 import '../../background/searchService.dart';
@@ -7,6 +8,7 @@ import '../../stateManagement/ChangeNotifierWidget.dart';
 import '../../stateManagement/Property.dart';
 import '../../stateManagement/nestedNotifier.dart';
 import '../../stateManagement/openFilesManager.dart';
+import '../../utils.dart';
 import '../misc/RowSeparated.dart';
 import '../propEditors/simpleProps/boolPropIcon.dart';
 import '../propEditors/simpleProps/propEditorFactory.dart';
@@ -28,6 +30,8 @@ class _SearchPanelState extends State<SearchPanel> {
   Stream<SearchResult>? searchStream;
   ValueNestedNotifier<SearchResult> searchResults = ValueNestedNotifier([]);
   BoolProp isSearching = BoolProp(false);
+  Mutex cancelMutex = Mutex();
+  late final void Function() updateSearchStream;
   // common options
   StringProp extensions = StringProp("");
   StringProp path = StringProp("");
@@ -41,6 +45,8 @@ class _SearchPanelState extends State<SearchPanel> {
 
   @override
   void initState() {
+    updateSearchStream = throttle(_updateSearchStream, 200);
+
     extensions.addListener(updateSearchStream);
     path.addListener(updateSearchStream);
     query.addListener(updateSearchStream);
@@ -80,12 +86,13 @@ class _SearchPanelState extends State<SearchPanel> {
     );
   }
 
-  void resetSearch() {
-    searchService?.cancel();
+  Future<void> resetSearch() async {
+    if (searchService == null)
+      return;
+    await searchService?.cancel();
     searchService = null;
     searchStream = null;
     searchResults.clear();
-    print("#########");
   }
 
   bool areAllFieldsFilled() {
@@ -101,8 +108,9 @@ class _SearchPanelState extends State<SearchPanel> {
     return true;
   }
 
-  void updateSearchStream() {
-    resetSearch();
+  void _updateSearchStream() async {
+    await cancelMutex.protect<void>(() => resetSearch());
+
     if (!areAllFieldsFilled())
       return;
 
@@ -265,20 +273,57 @@ class _SearchPanelState extends State<SearchPanel> {
       child: ChangeNotifierBuilder(
         notifier: searchResults,
         builder: (context) {
+          String? errorText;
+          String? infoText;
           if (!areAllFieldsFilled())
-            return Center(child: Text("Fill all fields to start search"));
+            errorText = "Fill all fields to start search";
           if (searchResults.isEmpty) {
             if (isSearching.value)
-              return Center(child: Text("Searching..."));
+              errorText = "Searching...";
             else
-              return Center(child: Text("No results"));
+              errorText = "No results";
           }
-          return ListView.builder(
-          itemCount: searchResults.length,
-          itemBuilder: (context, index) => _makeSearchResult(searchResults[index]),
-        );
+          var results = searchResults.toList();
+          var optP = "";
+          if (searchResults.length >= 1000) {
+            results = results.sublist(0, 1000);
+            infoText = "Stopped at 1000 results";
+            optP = "+";
+          }
+
+          var resultsFilesCount = searchResults.map((e) => e.filePath).toSet().length;
+          var infoStyle = TextStyle(
+            color: getTheme(context).textColor!.withOpacity(0.5),
+            fontSize: 12,
+          );
+          return Column(
+            crossAxisAlignment: CrossAxisAlignment.stretch,
+            children: [
+              Padding(
+                padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
+                child: Text(
+                  errorText ?? "Found ${pluralStr(searchResults.length, "result", optP)} in ${pluralStr(resultsFilesCount, "file", optP)}",
+                  style: infoStyle,
+                ),
+              ),
+              if (infoText != null)
+                Padding(
+                  padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
+                  child: Text(infoText, style: infoStyle),
+                ),
+              SizedBox(height: 5),
+              Divider(height: 1,),
+              if (errorText == null)
+                Expanded(
+                  child: ListView.builder(
+                    itemCount: results.length,
+                    itemBuilder: (context, index) => _makeSearchResult(results[index]),
+                  ),
+                ),
+            ],
+          );
         },
-      )
+      ),
     );
   }
 
@@ -300,14 +345,52 @@ class _SearchPanelState extends State<SearchPanel> {
   }
 
   Widget _makeSearchResultText(SearchResultText result) {
+    String text = result.line.replaceAll("\t", "  ");
+    List<TextSpan> textSpans;
+    List<String> fillStrings;
+    RegExp regex;
+    if (isRegex.value) {
+      regex = RegExp(query.value, caseSensitive: isCaseSensitive.value);
+      textSpans = text.split(regex)
+        .map((e) => TextSpan(text: e))
+        .toList();
+    }
+    else {
+      regex = RegExp(RegExp.escape(query.value), caseSensitive: isCaseSensitive.value);
+      textSpans = text.split(regex)
+        .map((e) => TextSpan(
+          text: e,
+          style: TextStyle(
+            color: getTheme(context).textColor,
+          ),
+        ))
+        .toList();
+    }
+    fillStrings = regex.allMatches(text)
+      .map((e) => e.group(0)!)
+      .toList();
+    // insert colored spans of query between all text spans
+    for (int i = 1; i < textSpans.length; i += 2) {
+      textSpans.insert(i, TextSpan(
+        text: fillStrings[(i - 1) ~/ 2],
+        style: TextStyle(
+          color: getTheme(context).textColor,
+          backgroundColor: Theme.of(context).colorScheme.secondary.withOpacity(0.35),
+          overflow: TextOverflow.ellipsis,
+        ),
+      ));
+    }
     return Column(
       crossAxisAlignment: CrossAxisAlignment.start,
       children: [
-        Text(
-          result.line,
-          style: TextStyle(
-            fontSize: 14,
+        RichText(
+          text: TextSpan(
+            children: textSpans,
+            style: TextStyle(
+              overflow: TextOverflow.ellipsis,
+            ),
           ),
+          overflow: TextOverflow.ellipsis,
           maxLines: 1,
         ),
         Tooltip(
diff --git a/pubspec.lock b/pubspec.lock
index c768ee36..9a9b00de 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -268,6 +268,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.8.0"
+  mutex:
+    dependency: "direct main"
+    description:
+      name: mutex
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.0.0"
   path:
     dependency: "direct main"
     description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 70689674..b68e6bd4 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -26,6 +26,7 @@ dependencies:
   flutter_launcher_icons: ^0.10.0
   fluttertoast: ^8.0.9
   highlight: ^0.7.0
+  mutex: ^3.0.0
   path: ^1.8.2
   shared_preferences: ^2.0.15
   tuple: ^2.0.0