cache note metadata at load time to eliminate per-search file I/O

_filterNotes was calling note.readAsStringSync() and _extractTags() for
every note on every keystroke, plus recomputing path.relative() each
time. For large collections with many subdirectories this is O(n * disk).

Fix: build three maps during _loadNotes (which already reads every file
once) and use them in _filterNotes instead of touching the filesystem:
  - _noteRelativePaths: pre-lowercased relative path per note
  - _noteContentLower:  pre-lowercased content for full-text search
  - noteTags:           already existed, now actually used in filter

_filterNotes now does only in-memory map lookups and string.contains
checks — no file reads, no regex, no path computation per keystroke.

Also fix _createNewNote: the Navigator.push was not awaited so
_loadNotes() was never called on return, leaving stale caches after
editing a freshly created note. Now awaits the push and reloads on
return, mirroring the existing-note tap behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-06-19 17:45:48 +03:00
parent 6470a878fd
commit f91484d6d4

View file

@ -40,6 +40,8 @@ class _NoteListScreenState extends State<NoteListScreen> {
int _visibleLimit = 30; int _visibleLimit = 30;
bool _isLoadingMore = false; bool _isLoadingMore = false;
Map<File, Set<String>> noteTags = {}; Map<File, Set<String>> noteTags = {};
Map<File, String> _noteRelativePaths = {};
Map<File, String> _noteContentLower = {};
String? notesDirectoryPath; String? notesDirectoryPath;
String? defaultDir; String? defaultDir;
bool _useExternalEditor = false; bool _useExternalEditor = false;
@ -239,7 +241,9 @@ Happy note taking! ✨
List<File> allNotes = []; List<File> allNotes = [];
Map<String, DateTime> folderTimestamps = {}; Map<String, DateTime> folderTimestamps = {};
Map<File, Set<String>> extractedTags = {}; // Store extracted tags here Map<File, Set<String>> extractedTags = {};
Map<File, String> extractedRelPaths = {};
Map<File, String> extractedContentLower = {};
void fetchNotes(Directory dir) { void fetchNotes(Directory dir) {
final entries = dir.listSync(recursive: _includeSubdirectories); final entries = dir.listSync(recursive: _includeSubdirectories);
@ -261,6 +265,8 @@ Happy note taking! ✨
final content = entry.readAsStringSync(); final content = entry.readAsStringSync();
extractedTags[entry] = _extractTags(content); extractedTags[entry] = _extractTags(content);
extractedRelPaths[entry] = path.relative(entry.path, from: notesDirectoryPath!).toLowerCase();
extractedContentLower[entry] = content.toLowerCase();
} }
} }
} }
@ -286,7 +292,9 @@ Happy note taking! ✨
setState(() { setState(() {
notes = allNotes; notes = allNotes;
filteredNotes = allNotes; filteredNotes = allNotes;
noteTags = extractedTags; // Update stored tags noteTags = extractedTags;
_noteRelativePaths = extractedRelPaths;
_noteContentLower = extractedContentLower;
}); });
_filterNotes(); // Apply search immediately if a term is active _filterNotes(); // Apply search immediately if a term is active
@ -329,15 +337,12 @@ Happy note taking! ✨
final normalizedSearch = searchText.replaceFirst(RegExp(r'^/'), ''); final normalizedSearch = searchText.replaceFirst(RegExp(r'^/'), '');
filteredNotes = notes.where((note) { filteredNotes = notes.where((note) {
final relativePath = path.relative(note.path, from: notesDirectoryPath!).toLowerCase(); final relativePath = _noteRelativePaths[note] ?? '';
final noteTags = _extractTags(note.readAsStringSync()); final tags = noteTags[note] ?? {};
final content = _includeFileContent ? (_noteContentLower[note] ?? '') : '';
// Read file content ONLY if `Include File Content` is enabled
final content = _includeFileContent ? note.readAsStringSync().toLowerCase() : '';
// Match partial tags instead of requiring full matches (case-insensitive)
final matchesTags = searchTags.every((tag) => final matchesTags = searchTags.every((tag) =>
noteTags.any((noteTag) => noteTag.toLowerCase().startsWith(tag))); tags.any((noteTag) => noteTag.toLowerCase().startsWith(tag)));
if (!_includeFileContent) { if (!_includeFileContent) {
return relativePath.contains(normalizedSearch) && matchesTags; return relativePath.contains(normalizedSearch) && matchesTags;
@ -439,13 +444,17 @@ Happy note taking! ✨
} }
if (!newNote.existsSync()) { if (!newNote.existsSync()) {
newNote.writeAsStringSync('# $safeTitle\n\n'); final newContent = '# $safeTitle\n\n';
newNote.writeAsStringSync(newContent);
setState(() { setState(() {
notes.add(newNote); notes.add(newNote);
filteredNotes.add(newNote); filteredNotes.add(newNote);
noteTags[newNote] = {};
_noteRelativePaths[newNote] = path.relative(newNote.path, from: notesDirectoryPath!).toLowerCase();
_noteContentLower[newNote] = newContent.toLowerCase();
}); });
Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => NoteEditorScreen( builder: (context) => NoteEditorScreen(
@ -456,6 +465,7 @@ Happy note taking! ✨
), ),
), ),
); );
if (context.mounted) _loadNotes();
} }
} catch (e) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(