diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index b15a66d..8f0c0c3 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -38,9 +38,40 @@ class MongoDocumentsView extends material.StatefulWidget { _MongoDocumentsViewState(); } +/// Total document count implied by one page alone, or null when it is unknown. +/// +/// A short non-empty page (or an empty first page) ends the result set, so the +/// total is `skip + pageLength` without a separate count round trip. +int? mongoExactTotalFromPage({ + required int skip, + required int limit, + required int pageLength, +}) { + if (pageLength >= limit) return null; + if (pageLength == 0 && skip > 0) return null; + return skip + pageLength; +} + +/// Whether a next page may exist: from the [total] when known, otherwise +/// whenever the current page is full. +bool mongoHasNextPage({ + required int skip, + required int limit, + required int pageLength, + int? total, +}) { + if (total != null) return skip + limit < total; + return pageLength >= limit; +} + class _MongoDocumentsViewState extends material.State { List> _documents = []; - int _totalCount = 0; + /// Exact match count, or null while the background count is still running + /// (the first page is painted from `find` alone). + int? _totalCount; + + /// Bumped per load so a slow older find / count cannot overwrite a newer one. + int _loadSeq = 0; int _skip = 0; final int _limit = _defaultLimit; bool _loading = true; @@ -70,19 +101,18 @@ class _MongoDocumentsViewState extends material.State { super.dispose(); } - Future _load() async { + /// Loads the current page. Page navigation passes [keepTotal] so the known + /// total is reused instead of recounting on every page. + Future _load({bool keepTotal = false}) async { if (!mounted) return; + final seq = ++_loadSeq; + final knownTotal = keepTotal ? _totalCount : null; setState(() { _loading = true; _error = null; + _totalCount = knownTotal; }); try { - final count = await MongoService.instance.countDocuments( - widget.connection, - widget.database, - widget.collection, - filter: _activeFilter, - ); final docs = await MongoService.instance.find( widget.connection, widget.database, @@ -91,9 +121,17 @@ class _MongoDocumentsViewState extends material.State { limit: _limit, skip: _skip, ); - if (!mounted) return; + if (!mounted || seq != _loadSeq) return; + // A short, non-empty page (or an empty first page) pins the total + // without a count round trip. + final exactTotal = knownTotal ?? + mongoExactTotalFromPage( + skip: _skip, + limit: _limit, + pageLength: docs.length, + ); setState(() { - _totalCount = count; + _totalCount = exactTotal; _documents = docs; _loading = false; _emptyFilterHint = docs.isEmpty && @@ -102,8 +140,9 @@ class _MongoDocumentsViewState extends material.State { ? kMongoFilterIdStringHint : null; }); + if (exactTotal == null) unawaited(_loadCount(seq)); } catch (e) { - if (mounted) { + if (mounted && seq == _loadSeq) { setState(() { _error = e.toString(); _loading = false; @@ -112,6 +151,33 @@ class _MongoDocumentsViewState extends material.State { } } + /// Exact count after the first page is on screen. Failures leave the total + /// unknown ("N+ documents") instead of failing the list. + Future _loadCount(int seq) async { + try { + final count = await MongoService.instance.countDocuments( + widget.connection, + widget.database, + widget.collection, + filter: _activeFilter, + ); + if (!mounted || seq != _loadSeq) return; + setState(() => _totalCount = count); + } catch (_) { + // Keep the "maybe more" pagination. + } + } + + /// True when a next page may exist: known total, else "page is full". + bool get _hasNextPage { + return mongoHasNextPage( + skip: _skip, + limit: _limit, + pageLength: _documents.length, + total: _totalCount, + ); + } + void _applyFilter() { final text = _filterController.text.trim(); if (text.isEmpty) { @@ -140,16 +206,16 @@ class _MongoDocumentsViewState extends material.State { } void _goNextPage() { - if (_skip + _limit < _totalCount) { + if (_hasNextPage) { _skip += _limit; - _load(); + _load(keepTotal: true); } } void _goPrevPage() { if (_skip > 0) { - _skip = (_skip - _limit).clamp(0, _totalCount); - _load(); + _skip = (_skip - _limit).clamp(0, _skip); + _load(keepTotal: true); } } @@ -378,9 +444,12 @@ class _MongoDocumentsViewState extends material.State { Widget _buildPaginationBar(ColorScheme cs) { final shadcnCs = shadcn.Theme.of(context).colorScheme; final currentPage = (_skip / _limit).floor() + 1; - final totalPages = (_totalCount / _limit).ceil(); - final from = _totalCount == 0 ? 0 : _skip + 1; - final to = (_skip + _limit).clamp(0, _totalCount); + final total = _totalCount; + final shown = _skip + _documents.length; + final totalPages = total == null ? null : (total / _limit).ceil(); + final from = shown == _skip ? 0 : _skip + 1; + final to = shown; + final hasNext = _hasNextPage; return material.Container( constraints: const material.BoxConstraints(minHeight: 44), @@ -394,7 +463,11 @@ class _MongoDocumentsViewState extends material.State { ), child: Row( children: [ - Text('$_totalCount documents').muted().small(), + Text(total != null + ? '$total documents' + : '$shown${hasNext ? '+' : ''} documents') + .muted() + .small(), const Spacer(), Text('$from – $to').muted().small(), const Gap(16), @@ -411,16 +484,16 @@ class _MongoDocumentsViewState extends material.State { ), ), const Gap(8), - Text('$currentPage / $totalPages').small(), + Text('$currentPage / ${totalPages ?? '?'}').small(), const Gap(8), material.InkWell( - onTap: _skip + _limit < _totalCount ? _goNextPage : null, + onTap: hasNext ? _goNextPage : null, child: material.Padding( padding: const material.EdgeInsets.all(4), child: material.Icon( material.Icons.chevron_right_rounded, size: 20, - color: _skip + _limit < _totalCount + color: hasNext ? shadcnCs.foreground : shadcnCs.mutedForeground, ), diff --git a/test/features/mongodb/mongo_documents_paging_test.dart b/test/features/mongodb/mongo_documents_paging_test.dart new file mode 100644 index 0000000..9ee42a9 --- /dev/null +++ b/test/features/mongodb/mongo_documents_paging_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/mongodb/mongo_documents_view.dart'; + +void main() { + group('mongoExactTotalFromPage', () { + test('a short first page pins the total without a count', () { + expect(mongoExactTotalFromPage(skip: 0, limit: 25, pageLength: 7), 7); + expect(mongoExactTotalFromPage(skip: 0, limit: 25, pageLength: 0), 0); + }); + + test('a short later page pins skip + length', () { + expect(mongoExactTotalFromPage(skip: 50, limit: 25, pageLength: 3), 53); + }); + + test('a full page leaves the total unknown', () { + expect(mongoExactTotalFromPage(skip: 0, limit: 25, pageLength: 25), + isNull); + expect(mongoExactTotalFromPage(skip: 25, limit: 25, pageLength: 25), + isNull); + }); + + test('an empty page past the start leaves the total unknown', () { + expect(mongoExactTotalFromPage(skip: 75, limit: 25, pageLength: 0), + isNull); + }); + }); + + group('mongoHasNextPage', () { + test('uses the known total', () { + expect( + mongoHasNextPage(skip: 0, limit: 25, pageLength: 25, total: 25), + isFalse, + ); + expect( + mongoHasNextPage(skip: 0, limit: 25, pageLength: 25, total: 26), + isTrue, + ); + expect( + mongoHasNextPage(skip: 25, limit: 25, pageLength: 1, total: 26), + isFalse, + ); + }); + + test('assumes more while the count is pending and the page is full', () { + expect(mongoHasNextPage(skip: 0, limit: 25, pageLength: 25), isTrue); + expect(mongoHasNextPage(skip: 0, limit: 25, pageLength: 24), isFalse); + }); + }); +}