From 476ce5e3e285af6436a6ba8a50d33721f8499713 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 23 Oct 2026 08:37:54 -0700 Subject: [PATCH 1/2] Enhance Local History search performance, persistence, and restore safety * Implement `LocalHistoryBatchReader` in `FileLocalHistoryStore` to batch read revisions and optimize search performance. * Persist document identities independently in `documents.json` and improve unindexed capture handling during index recovery. * Add `_LocalHistoryLossProtection` checks to guard entity deletions and external replacements against concurrent disk changes. * Refactor local history restore flows in comparison and workspace controllers with recovery state tracking, workspace navigation prompts, and final newline policy matching. * Observe external and authoritative note replacements separately using `LocalHistoryCaptureReason.externalChange`. --- .../local_history_comparison_view.dart | 102 +++- .../local_history_controller.dart | 98 +++- .../local_history/local_history_store.dart | 416 +++++++++++--- lib/src/workspace/workspace_controller.dart | 418 +++++++++----- test/src/local_history_controller_test.dart | 69 +++ test/src/local_history_store_test.dart | 345 +++++++++++- test/src/local_history_workspace_test.dart | 518 +++++++++++++++++- test/src/nextcloud_workspace_test.dart | 98 ++++ 8 files changed, 1817 insertions(+), 247 deletions(-) diff --git a/lib/src/local_history/local_history_comparison_view.dart b/lib/src/local_history/local_history_comparison_view.dart index 45ffe977..ae5d8027 100644 --- a/lib/src/local_history/local_history_comparison_view.dart +++ b/lib/src/local_history/local_history_comparison_view.dart @@ -98,8 +98,14 @@ class _LocalHistoryComparisonViewState currentSourceVersion: matchingBuffer?.revision ?? cachedNote?.revision, currentSource: matchingBuffer?.text ?? cachedNote?.content, currentPath: document.currentPath, + sourceGeneration: history.comparisonRevision, + deleted: document.deleted, ); if (_request != request) { + if (_request?.documentId != request.documentId || + _request?.revisionId != request.revisionId) { + _recoveryError = null; + } _request = request; _future = _buildSnapshot(document, revision, request); } @@ -134,10 +140,11 @@ class _LocalHistoryComparisonViewState onClose: () => ref .read(localHistoryControllerProvider.notifier) .clearComparison(), - onRestoreAll: actionsAreCurrent && data.canRestore - ? () => _restore(document, revision) + onRestoreAll: !_recovering && actionsAreCurrent && data.canRestore + ? () => _restore(document, revision, comparison: data.comparison) : null, - onRestoreChange: actionsAreCurrent && data.canRestoreChange + onRestoreChange: + !_recovering && actionsAreCurrent && data.canRestoreChange ? (change) => _restore( document, revision, @@ -146,9 +153,13 @@ class _LocalHistoryComparisonViewState ) : null, onRestoreOriginal: - document.remoteNote != null && actionsAreCurrent && data.missing + !_recovering && + document.remoteNote != null && + actionsAreCurrent && + data.missing ? () => unawaited(_recoverRemote(document, revision)) - : actionsAreCurrent && + : !_recovering && + actionsAreCurrent && data.missing && (document.currentPath ?? revision.summary.historicalPath) != null @@ -159,7 +170,10 @@ class _LocalHistoryComparisonViewState ) : null, onRestoreNewLocation: - document.remoteNote == null && actionsAreCurrent && data.missing + !_recovering && + document.remoteNote == null && + actionsAreCurrent && + data.missing ? () => _chooseRestoreLocation(document, revision) : null, ); @@ -217,15 +231,53 @@ class _LocalHistoryComparisonViewState LocalHistoryRevision revision, { SourceComparison? comparison, SourceComparisonChange? change, - }) async { - await ref - .read(workspaceControllerProvider.notifier) - .restoreLocalHistoryRevision( - document: document, - revision: revision, - comparison: comparison, - change: change, + }) => _runRestore(() async { + final controller = ref.read(workspaceControllerProvider.notifier); + final workspace = ref.read(workspaceControllerProvider).workspace; + final path = document.currentPath; + final needsWorkspaceNavigation = + controller.localHistoryBufferForDocument(document, revision) == null && + (document.remoteNote != null + ? workspace?.nextcloudAccountId != document.remoteNote!.accountId + : path != null && + (workspace == null || + workspace.isRemote || + !(p.equals(workspace.rootPath, path) || + p.isWithin(workspace.rootPath, path)))); + if (needsWorkspaceNavigation && + (!await confirmSafeToContinue(context, ref) || !mounted)) { + return null; + } + return controller.restoreLocalHistoryRevision( + document: document, + revision: revision, + comparison: comparison, + change: change, + ); + }); + + Future _runRestore(Future Function() operation) async { + if (_recovering) return; + setState(() { + _recovering = true; + _recoveryError = null; + }); + try { + final restored = await operation(); + if (mounted && restored == false) { + setState( + () => _recoveryError = context.l10n.localHistoryRestoreUnavailable, ); + } + } on Object { + if (mounted) { + setState( + () => _recoveryError = context.l10n.localHistoryRestoreUnavailable, + ); + } + } finally { + if (mounted) setState(() => _recovering = false); + } } Future _chooseRestoreLocation( @@ -298,11 +350,11 @@ class _LocalHistoryComparisonViewState LocalHistoryDocument document, LocalHistoryRevision revision, String path, - ) async { - if (!await confirmSafeToContinue(context, ref) || !mounted) return; + ) => _runRestore(() async { + if (!await confirmSafeToContinue(context, ref) || !mounted) return null; final controller = ref.read(workspaceControllerProvider.notifier); final exists = await controller.localHistoryRestorePathExists(path); - if (!mounted) return; + if (!mounted) return null; var overwrite = false; if (exists) { overwrite = @@ -326,15 +378,15 @@ class _LocalHistoryComparisonViewState ), ) ?? false; - if (!overwrite || !mounted) return; + if (!overwrite || !mounted) return null; } - await controller.restoreMissingLocalHistoryRevision( + return controller.restoreMissingLocalHistoryRevision( document: document, revision: revision, destinationPath: path, overwriteExisting: overwrite, ); - } + }); } class _ComparisonRequest { @@ -346,6 +398,8 @@ class _ComparisonRequest { required this.currentSourceVersion, required this.currentSource, required this.currentPath, + required this.sourceGeneration, + required this.deleted, }); final String documentId; @@ -355,6 +409,8 @@ class _ComparisonRequest { final int? currentSourceVersion; final String? currentSource; final String? currentPath; + final int sourceGeneration; + final bool deleted; @override bool operator ==(Object other) => @@ -365,7 +421,9 @@ class _ComparisonRequest { other.currentBufferId == currentBufferId && other.currentSourceVersion == currentSourceVersion && other.currentSource == currentSource && - other.currentPath == currentPath; + other.currentPath == currentPath && + other.sourceGeneration == sourceGeneration && + other.deleted == deleted; @override int get hashCode => Object.hash( @@ -376,6 +434,8 @@ class _ComparisonRequest { currentSourceVersion, currentSource, currentPath, + sourceGeneration, + deleted, ); } diff --git a/lib/src/local_history/local_history_controller.dart b/lib/src/local_history/local_history_controller.dart index c05f4983..5b4b9842 100644 --- a/lib/src/local_history/local_history_controller.dart +++ b/lib/src/local_history/local_history_controller.dart @@ -64,6 +64,7 @@ class LocalHistoryState { const LocalHistoryState({ this.snapshot = const LocalHistorySnapshot(), this.searchRevision = 0, + this.comparisonRevision = 0, this.loading = false, this.selectedDocumentId, this.selectedRevisionId, @@ -75,6 +76,7 @@ class LocalHistoryState { final LocalHistorySnapshot snapshot; final int searchRevision; + final int comparisonRevision; final bool loading; final String? selectedDocumentId; final String? selectedRevisionId; @@ -95,6 +97,7 @@ class LocalHistoryState { LocalHistoryState copyWith({ LocalHistorySnapshot? snapshot, int? searchRevision, + int? comparisonRevision, bool? loading, Object? selectedDocumentId = _unset, Object? selectedRevisionId = _unset, @@ -105,6 +108,7 @@ class LocalHistoryState { }) => LocalHistoryState( snapshot: snapshot ?? this.snapshot, searchRevision: searchRevision ?? this.searchRevision, + comparisonRevision: comparisonRevision ?? this.comparisonRevision, loading: loading ?? this.loading, selectedDocumentId: identical(selectedDocumentId, _unset) ? this.selectedDocumentId @@ -583,7 +587,12 @@ class LocalHistoryController extends Notifier { Future refresh() async { final generation = ++_loadGeneration; - if (ref.mounted) state = state.copyWith(loading: true); + if (ref.mounted) { + state = state.copyWith( + loading: true, + comparisonRevision: state.comparisonRevision + 1, + ); + } try { // Defaults are presentation state while settings hydrate. In particular, // the default retention age must not delete history before a longer saved @@ -706,6 +715,27 @@ class LocalHistoryController extends Notifier { void observeHistoryTraversal( DocumentBuffer previous, DocumentBuffer current, + ) => _observeSourceReplacement( + previous, + current, + LocalHistoryCaptureReason.beforeHistoryTraversal, + ); + + /// Retains each displaced remote version independently of the coalesced + /// checkpoint for the incoming source, including rapid successive refreshes. + void observeAuthoritativeReplacement( + DocumentBuffer previous, + DocumentBuffer current, + ) => _observeSourceReplacement( + previous, + current, + LocalHistoryCaptureReason.externalChange, + ); + + void _observeSourceReplacement( + DocumentBuffer previous, + DocumentBuffer current, + LocalHistoryCaptureReason reason, ) { if (previous.text == current.text || !policy.recordingEnabled || @@ -720,8 +750,7 @@ class LocalHistoryController extends Notifier { // normal retention may already have evicted their durable revision. final existingOwner = _protections.entries.where((entry) { final retained = entry.value.snapshot; - return entry.value.reason == - LocalHistoryCaptureReason.beforeHistoryTraversal && + return entry.value.reason == reason && _detachedClosedSourceId(entry.key) == previous.id && retained.acceptedClearEpoch == previousSnapshot.acceptedClearEpoch && _sameOptionalPath(retained.path, previousSnapshot.path) && @@ -755,7 +784,7 @@ class LocalHistoryController extends Notifier { } } else { final owner = _newDetachedOwner( - 'history-capture:traversal:${previous.id}', + 'history-capture:replacement:${previous.id}', ); final snapshot = previousSnapshot .forBuffer(owner) @@ -766,7 +795,7 @@ class LocalHistoryController extends Notifier { if (documentId != null) _documentIdsByBuffer[owner] = documentId; _protections[owner] = ( snapshot: snapshot, - reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + reason: reason, force: false, ignoreBinding: false, allowPathChange: false, @@ -4164,6 +4193,11 @@ class LocalHistoryController extends Notifier { String? documentIdForBuffer(String bufferId) => _documentIdsByBuffer[bufferId]; + /// Invalidates closed-file comparisons after filesystem notifications. + void invalidateCurrentSourceComparisons() { + state = state.copyWith(comparisonRevision: state.comparisonRevision + 1); + } + /// Restores the stable history identity recorded with an editor session. /// /// The store snapshot is authoritative: stale session metadata cannot bind @@ -5998,6 +6032,7 @@ class LocalHistoryController extends Notifier { final prefix = [ 'history-capture:closed:', 'history-capture:traversal:', + 'history-capture:replacement:', ].where(owner.startsWith).firstOrNull; if (prefix == null) return null; var sourceAndSuffix = owner.substring(prefix.length); @@ -6236,20 +6271,49 @@ class LocalHistoryController extends Notifier { .where((r) => documents.containsKey(r.documentId)) .toList() ..sort((a, b) => b.capturedAt.compareTo(a.capturedAt)); - for (final summary in summaries) { + const batchSize = 32; + const maximumBatchBytes = 8 * 1024 * 1024; + for (var offset = 0; offset < summaries.length;) { if (!current()) return null; - // Empty scoped queries can use summaries without reading any bodies. - final revision = needle.isEmpty - ? null - : await _store.readRevision(summary.id); + var end = offset + 1; + var bytes = summaries[offset].storageBytes; + while (end < summaries.length && end - offset < batchSize) { + final nextBytes = summaries[end].storageBytes; + if (bytes + nextBytes > maximumBatchBytes) break; + bytes += nextBytes; + end++; + } + final batch = summaries.sublist(offset, end); + offset = end; + final store = _store; + final batchReader = store is LocalHistoryBatchReader + ? store as LocalHistoryBatchReader + : null; + final revisions = needle.isEmpty || batchReader == null + ? {} + : { + for (final revision in await batchReader.readRevisions( + batch.map((r) => r.id), + )) + revision.summary.id: revision, + }; if (!current()) return null; - if (needle.isEmpty || - revision?.source.toLowerCase().contains(needle) == true) { - matches.add( - LocalHistoryRevisionMatch(documents[summary.documentId]!, summary), - ); - if (matches.length > maximumResults) return page(truncated: true); - onProgress?.call(page()); + for (final summary in batch) { + // Empty scoped queries can use summaries without reading any bodies. + final revision = needle.isEmpty + ? null + : batchReader != null + ? revisions[summary.id] + : await store.readRevision(summary.id); + if (!current()) return null; + if (needle.isEmpty || + revision?.source.toLowerCase().contains(needle) == true) { + matches.add( + LocalHistoryRevisionMatch(documents[summary.documentId]!, summary), + ); + if (matches.length > maximumResults) return page(truncated: true); + onProgress?.call(page()); + } } } return current() ? page() : null; diff --git a/lib/src/local_history/local_history_store.dart b/lib/src/local_history/local_history_store.dart index ea0e34cb..d09a1827 100644 --- a/lib/src/local_history/local_history_store.dart +++ b/lib/src/local_history/local_history_store.dart @@ -119,7 +119,15 @@ abstract interface class LocalHistoryStore { Future clearAllOnce({required String operationId}) => clearAll(); } -class FileLocalHistoryStore implements LocalHistoryStore { +/// Reads a bounded group under one store lock and one authoritative index. +abstract interface class LocalHistoryBatchReader { + Future> readRevisions( + Iterable revisionIds, + ); +} + +class FileLocalHistoryStore + implements LocalHistoryStore, LocalHistoryBatchReader { FileLocalHistoryStore({ Future Function()? rootDirectory, String Function()? createId, @@ -203,17 +211,25 @@ class FileLocalHistoryStore implements LocalHistoryStore { if (index.tombstones.contains(captureId)) { throw const LocalHistoryClearConflict(); } - final existing = index.revisions + final indexed = index.revisions .where((revision) => revision.id == captureId) .firstOrNull; + final orphan = indexed == null + ? await _findUnindexedCapture(root, captureId) + : null; + final existing = indexed ?? orphan?.revision.summary; if (existing != null) { - final document = index.documents - .where((candidate) => candidate.id == existing.documentId) - .firstOrNull; - final revision = await _readRevisionFile( - _revisionFile(root, existing.documentId, existing.id), - expected: existing, - ); + final document = + index.documents + .where((candidate) => candidate.id == existing.documentId) + .firstOrNull ?? + orphan?.document; + final revision = + orphan?.revision ?? + await _readRevisionFile( + _revisionFile(root, existing.documentId, existing.id), + expected: existing, + ); if (document == null || existing.checksum != checksum || revision == null || @@ -231,6 +247,58 @@ class FileLocalHistoryStore implements LocalHistoryStore { when target.documentId != document.id) { throw const LocalHistoryReconciliationConflict(); } + if (orphan != null) { + // Finish only the exact retained capture whose record committed + // before its index. Preserve later authoritative identity changes. + if (orphan.document.remoteNote != request.remoteNote || + existing.reason != request.reason || + (request.path != null && + existing.historicalPath != request.path && + !orphan.document.historicalPaths.any( + (path) => p.equals(path, request.path!), + ))) { + throw const LocalHistoryStorageException( + 'Local History capture identity collision.', + ); + } + if (existing.storageBytes > policy.maximumBytes) { + throw const LocalHistoryStorageException( + 'The revision is larger than the Local History storage limit.', + ); + } + final path = document.currentPath; + if (!document.deleted && + path != null && + index.documents.any( + (candidate) => + candidate.id != document.id && + _ownsActivePath(candidate, path), + )) { + throw const LocalHistoryReconciliationConflict(); + } + _requireCaptureStillAccepted(request); + index = index.withCapture(document, existing); + await _publishIndex(root, index); + if (!_captureStillAccepted(request)) { + await _publishIndex( + root, + originalIndex.copyWith( + tombstones: {...originalIndex.tombstones, captureId}, + ), + ); + await _deleteFileBestEffort( + _revisionFile(root, existing.documentId, captureId), + ); + throw const LocalHistoryCaptureCancelled(); + } + await _applyRetention( + root, + index, + policy, + captureId, + request.capturedAt.toUtc(), + ); + } _requireCaptureStillAccepted(request); if (request.reason == LocalHistoryCaptureReason.beforeHistoryTraversal) { @@ -333,12 +401,6 @@ class FileLocalHistoryStore implements LocalHistoryStore { ); } } - final estimatedBytes = utf8.encode(request.source).length + 2048; - if (estimatedBytes > policy.maximumBytes) { - throw const LocalHistoryStorageException( - 'The revision is larger than the Local History storage limit.', - ); - } final revisionId = request.captureId ?? _safeNewId(); var summary = LocalHistoryRevisionSummary( id: revisionId, @@ -352,9 +414,15 @@ class FileLocalHistoryStore implements LocalHistoryStore { ? request.path : identity.path, ); + final encoded = _revisionJson(document, summary, request); + final storageBytes = utf8.encode(encoded).length; + if (storageBytes > policy.maximumBytes) { + throw const LocalHistoryStorageException( + 'The revision is larger than the Local History storage limit.', + ); + } final target = _revisionFile(root, document.id, revisionId); await target.parent.create(recursive: true); - final encoded = _revisionJson(document, summary, request); _requireCaptureStillAccepted(request); await _publishNewFile(target, encoded); if (!_captureStillAccepted(request)) { @@ -367,7 +435,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { await _deleteFileBestEffort(target); throw const LocalHistoryCaptureCancelled(); } - summary = summary.copyWith(storageBytes: await target.length()); + summary = summary.copyWith(storageBytes: storageBytes); index = index.withCapture(document, summary); // A complete revision exists before the index points to it. await _publishIndex(root, index); @@ -401,6 +469,11 @@ class FileLocalHistoryStore implements LocalHistoryStore { LocalHistoryCaptureRequest request, LocalHistoryPolicy policy, ) async { + if (previous.storageBytes > policy.maximumBytes) { + throw const LocalHistoryStorageException( + 'The revision is larger than the Local History storage limit.', + ); + } var renewed = _renewedTraversalProtection(previous, request.capturedAt); final target = _revisionFile(root, document.id, previous.id); final previousRecord = identical(renewed, previous) @@ -411,11 +484,15 @@ class FileLocalHistoryStore implements LocalHistoryStore { // Publish the complete record first so an index rebuild retains the // protection. A failed index publication leaves the caller owning its // independent source and retry work, just like a new revision write. - await _publishReplacingFile( - target, - _revisionJson(document, renewed, request), - ); - renewed = renewed.copyWith(storageBytes: await target.length()); + final encoded = _revisionJson(document, renewed, request); + final bytes = utf8.encode(encoded).length; + if (bytes > policy.maximumBytes) { + throw const LocalHistoryStorageException( + 'The revision is larger than the Local History storage limit.', + ); + } + await _publishReplacingFile(target, encoded); + renewed = renewed.copyWith(storageBytes: bytes); } if (!_captureStillAccepted(request)) { if (previousRecord != null) { @@ -472,6 +549,32 @@ class FileLocalHistoryStore implements LocalHistoryStore { }), ); + @override + Future> readRevisions( + Iterable revisionIds, + ) => _serialized( + (root) => _withFileLock(root, () async { + final index = await _loadIndexUnlocked(root, repair: true); + final summaries = { + for (final revision in index.revisions) revision.id: revision, + }; + final documents = index.documents.map((document) => document.id).toSet(); + final result = []; + for (final id in revisionIds) { + final summary = summaries[id]; + if (summary == null || !documents.contains(summary.documentId)) { + continue; + } + final revision = await _readRevisionFile( + _revisionFile(root, summary.documentId, id), + expected: summary, + ); + if (revision != null) result.add(revision); + } + return result; + }), + ); + @override Future prune(LocalHistoryPolicy policy, DateTime now) => _serialized( (root) => _withFileLock(root, () async { @@ -1015,7 +1118,12 @@ class FileLocalHistoryStore implements LocalHistoryStore { final file = File(p.join(root.path, 'index.json')); try { if (!await file.exists()) { - return repair ? await _rebuildIndex(root) : const _Index(); + if (!repair) return const _Index(); + final rebuilt = await _rebuildIndex(root); + if (rebuilt.revisions.isNotEmpty || rebuilt.tombstones.isNotEmpty) { + await _publishIndex(root, rebuilt); + } + return rebuilt; } final json = jsonDecode(await file.readAsString()); if (json is! Map) throw const FormatException('Invalid history index'); @@ -1023,7 +1131,29 @@ class FileLocalHistoryStore implements LocalHistoryStore { if (map['version'] != formatVersion) { throw UnsupportedLocalHistoryFormat(map['version']); } - return _Index.fromJson(map); + var index = _Index.fromJson(map); + final tombstones = await _readIndexTombstones( + root, + indexed: index.tombstones, + ); + index = index.copyWith( + tombstones: tombstones, + revisions: index.revisions + .where((revision) => !tombstones.contains(revision.id)) + .toList(growable: false), + ); + final identities = await _readDocumentIdentities(root); + if (identities == null) { + // Migrate a legacy store before its rebuildable index can be lost. + await _publishDocumentIdentities(root, index.documents); + return index; + } + return index.copyWith( + documents: identities.values.toList(growable: false), + revisions: index.revisions + .where((revision) => identities.containsKey(revision.documentId)) + .toList(growable: false), + ); } on UnsupportedLocalHistoryFormat { rethrow; } on Object { @@ -1038,11 +1168,15 @@ class FileLocalHistoryStore implements LocalHistoryStore { Future<_Index> _rebuildIndex(Directory root) async { final existing = await _readIndexTombstones(root); + final identities = await _readDocumentIdentities(root); final documents = {}; final revisions = []; final directory = Directory(p.join(root.path, 'revisions')); if (!await directory.exists()) return _Index(tombstones: existing); - await for (final entity in directory.list(recursive: true)) { + await for (final entity in directory.list( + recursive: true, + followLinks: false, + )) { if (entity is! File || p.extension(entity.path) != '.json') continue; final relative = p.relative(entity.path, from: directory.path); final parts = p.split(relative); @@ -1055,30 +1189,40 @@ class FileLocalHistoryStore implements LocalHistoryStore { continue; } try { - final map = (jsonDecode(await entity.readAsString()) as Map) - .cast(); - if (map['version'] != formatVersion) continue; - final document = LocalHistoryDocument.fromJson( - (map['document'] as Map).cast(), - ); - final summary = LocalHistoryRevisionSummary.fromJson( - (map['revision'] as Map).cast(), - ); - if (document.id != documentId || - summary.id != revisionId || - summary.documentId != documentId) { - continue; - } - final source = map['source']; - if (source is! String || sourceChecksum(source) != summary.checksum) { - continue; + final record = await _readRevisionRecord(entity); + if (record == null) continue; + final document = identities?[documentId] ?? record.document; + // An authoritative journal also records absence after clear/retention. + if (identities != null && !identities.containsKey(documentId)) continue; + final previous = documents[documentId]; + if (previous == null || + !document.updatedAt.isBefore(previous.updatedAt)) { + documents[documentId] = document; } - documents[document.id] = document; - revisions.add(summary.copyWith(storageBytes: await entity.length())); + revisions.add(record.revision.summary); } on Object { // A malformed record cannot invalidate independently intact records. } } + // Legacy records have no independent identity authority. Resolve duplicate + // path claims deterministically instead of exposing two live owners. + if (identities == null) { + final ordered = documents.values.toList() + ..sort((a, b) { + final order = b.updatedAt.compareTo(a.updatedAt); + return order != 0 ? order : a.id.compareTo(b.id); + }); + final owners = []; + for (final document in ordered) { + final path = document.currentPath; + if (document.deleted || path == null) continue; + if (owners.any((owner) => p.equals(owner, path))) { + documents[document.id] = document.copyWith(deleted: true); + } else { + owners.add(path); + } + } + } return _Index( documents: documents.values.toList(growable: false), revisions: revisions, @@ -1086,8 +1230,103 @@ class FileLocalHistoryStore implements LocalHistoryStore { ); } - Future> _readIndexTombstones(Directory root) async { - final result = {}; + Future?> _readDocumentIdentities( + Directory root, + ) async { + final file = File(p.join(root.path, 'documents.json')); + if (!await file.exists()) return null; + final map = (jsonDecode(await file.readAsString()) as Map) + .cast(); + if (map['version'] != formatVersion) { + throw UnsupportedLocalHistoryFormat(map['version']); + } + final documents = (map['documents'] as List).map( + (value) => + LocalHistoryDocument.fromJson((value as Map).cast()), + ); + return {for (final document in documents) document.id: document}; + } + + Future _publishDocumentIdentities( + Directory root, + List documents, + ) async { + final file = File(p.join(root.path, 'documents.json')); + final content = const JsonEncoder.withIndent(' ').convert({ + 'version': formatVersion, + 'documents': [for (final document in documents) document.toJson()], + }); + if (!await _journalMatches(file, content)) { + await _publishReplacingFile(file, content); + } + } + + Future<_RevisionRecord?> _findUnindexedCapture( + Directory root, + String captureId, + ) async { + final directory = Directory(p.join(root.path, 'revisions')); + if (!await directory.exists()) return null; + _RevisionRecord? found; + await for (final entity in directory.list(followLinks: false)) { + if (entity is! Directory || !_validId(p.basename(entity.path))) continue; + final file = File(p.join(entity.path, '$captureId.json')); + if (!await file.exists()) continue; + final record = await _readRevisionRecord(file); + if (record == null || found != null) { + throw const LocalHistoryStorageException( + 'Local History capture identity collision.', + ); + } + found = record; + } + return found; + } + + Future<_RevisionRecord?> _readRevisionRecord(File file) async { + try { + final map = (jsonDecode(await file.readAsString()) as Map) + .cast(); + if (map['version'] != formatVersion) { + throw UnsupportedLocalHistoryFormat(map['version']); + } + final document = LocalHistoryDocument.fromJson( + (map['document'] as Map).cast(), + ); + final summary = LocalHistoryRevisionSummary.fromJson( + (map['revision'] as Map).cast(), + ); + final source = map['source']; + if (document.id != p.basename(file.parent.path) || + summary.id != p.basenameWithoutExtension(file.path) || + summary.documentId != document.id || + source is! String || + source.length != summary.sourceLength || + sourceChecksum(source) != summary.checksum) { + return null; + } + return _RevisionRecord( + document: document, + revision: LocalHistoryRevision( + summary: summary.copyWith(storageBytes: await file.length()), + source: source, + format: TextFormatMetadata.fromJson( + (map['format'] as Map).cast(), + ), + ), + ); + } on UnsupportedLocalHistoryFormat { + rethrow; + } on Object { + return null; + } + } + + Future> _readIndexTombstones( + Directory root, { + Set? indexed, + }) async { + final result = {...?indexed}; final journal = File(p.join(root.path, 'tombstones.json')); try { final values = jsonDecode(await journal.readAsString()); @@ -1097,6 +1336,7 @@ class FileLocalHistoryStore implements LocalHistoryStore { } on Object { // A damaged journal does not make intact revision records unreadable. } + if (indexed != null) return result; final file = File(p.join(root.path, 'index.json')); try { final map = (jsonDecode(await file.readAsString()) as Map) @@ -1115,34 +1355,21 @@ class FileLocalHistoryStore implements LocalHistoryStore { File file, { required LocalHistoryRevisionSummary expected, }) async { - try { - final map = (jsonDecode(await file.readAsString()) as Map) - .cast(); - if (map['version'] != formatVersion) { - throw UnsupportedLocalHistoryFormat(map['version']); - } - final summary = LocalHistoryRevisionSummary.fromJson( - (map['revision'] as Map).cast(), - ); - final source = map['source']; - if (summary.id != expected.id || - summary.documentId != expected.documentId || - source is! String || - sourceChecksum(source) != expected.checksum) { - return null; - } - return LocalHistoryRevision( - summary: expected, - source: source, - format: TextFormatMetadata.fromJson( - (map['format'] as Map).cast(), - ), - ); - } on UnsupportedLocalHistoryFormat { - rethrow; - } on Object { + final record = await _readRevisionRecord(file); + if (record == null || + record.revision.summary.id != expected.id || + record.document.id != expected.documentId || + record.revision.summary.checksum != expected.checksum || + record.revision.summary.sourceLength != expected.sourceLength || + record.revision.summary.capturedAt != expected.capturedAt || + record.revision.summary.reason != expected.reason) { return null; } + return LocalHistoryRevision( + summary: expected, + source: record.revision.source, + format: record.revision.format, + ); } Future _applyRetention( @@ -1176,10 +1403,17 @@ class FileLocalHistoryStore implements LocalHistoryStore { final retainedDocumentIds = { for (final revision in revisions) revision.documentId, }; - final documents = [ - for (final document in index.documents) - if (retainedDocumentIds.contains(document.id)) document, - ]; + final knownRevisionIds = { + for (final revision in index.revisions) revision.id, + ...index.tombstones, + }; + final documents = []; + for (final document in index.documents) { + if (retainedDocumentIds.contains(document.id) || + await _hasUnindexedRevision(root, document.id, knownRevisionIds)) { + documents.add(document); + } + } if (removed.isEmpty && documents.length == index.documents.length) { return; } @@ -1201,6 +1435,26 @@ class FileLocalHistoryStore implements LocalHistoryStore { // identical index afterward adds two flushes without another state change. } + Future _hasUnindexedRevision( + Directory root, + String documentId, + Set knownRevisionIds, + ) async { + final directory = Directory(p.join(root.path, 'revisions', documentId)); + if (!await directory.exists()) return false; + await for (final entity in directory.list(followLinks: false)) { + if (entity is! File || p.extension(entity.path) != '.json') continue; + final id = p.basenameWithoutExtension(entity.path); + if (!_validId(id) || knownRevisionIds.contains(id)) continue; + // An interrupted capture can still own independently retained work. + // Preserve its document's authoritative rename/deletion metadata until + // that record is indexed or explicitly cleared, even with no other + // indexed revisions left after retention. + if (await _readRevisionRecord(entity) != null) return true; + } + return false; + } + Future _publishIndex(Directory root, _Index index) async { final journal = File(p.join(root.path, 'tombstones.json')); final tombstones = const JsonEncoder.withIndent( @@ -1212,6 +1466,9 @@ class FileLocalHistoryStore implements LocalHistoryStore { if (!await _journalMatches(journal, tombstones)) { await _publishReplacingFile(journal, tombstones); } + // Identity changes may have no content revision. Keep them independently + // durable before publishing the disposable revision index. + await _publishDocumentIdentities(root, index.documents); await _publishReplacingFile( File(p.join(root.path, 'index.json')), const JsonEncoder.withIndent(' ').convert(index.toJson()), @@ -1301,6 +1558,13 @@ class FileLocalHistoryStore implements LocalHistoryStore { } } +class _RevisionRecord { + const _RevisionRecord({required this.document, required this.revision}); + + final LocalHistoryDocument document; + final LocalHistoryRevision revision; +} + class _ClearIntent { const _ClearIntent({required this.operationId, this.documentId, this.path}); diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 18d4ef75..f778dcb6 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -6,6 +6,7 @@ import 'dart:io'; import 'dart:math' as math; +import 'package:flutter/foundation.dart' show mapEquals, setEquals; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; @@ -512,12 +513,17 @@ class WorkspaceController extends Notifier { if (note == null) { // Repository deletes only after the remote outcome is established. // An editor revision that arrived meanwhile retains its tab for recovery. - if (buffer.isDirty) buffers.add(buffer); + if (buffer.isDirty) { + buffers.add(buffer); + } else { + _settleRemovedRemoteHistory(buffer); + } continue; } if (note.syncState == NoteSyncState.deletedRemotely && !note.hasPendingChanges && !buffer.isDirty) { + _settleRemovedRemoteHistory(buffer); continue; } if (buffer.isDirty) { @@ -526,33 +532,33 @@ class WorkspaceController extends Notifier { buffer.text, ); contentChanged |= text != buffer.text; - buffers.add( - buffer - .replaced(text) - .copyWith( - remoteTitle: note.title, - readonly: note.readonly || note.error, - revision: text == buffer.text - ? buffer.revision - : math.max(buffer.revision, note.revision) + 1, - ), - ); + final updated = buffer + .replaced(text) + .copyWith( + remoteTitle: note.title, + readonly: note.readonly || note.error, + revision: text == buffer.text + ? buffer.revision + : math.max(buffer.revision, note.revision) + 1, + ); + _localHistory.observeAuthoritativeReplacement(buffer, updated); + buffers.add(updated); } else { contentChanged |= note.content != buffer.text; - buffers.add( - buffer - .replaced(note.content) - .copyWith( - dirty: false, - lastSavedText: note.content, - remoteEditBase: repository.editorBase(note.localId), - revision: note.content == buffer.text - ? math.max(buffer.revision, note.revision) - : math.max(buffer.revision, note.revision) + 1, - remoteTitle: note.title, - readonly: note.readonly || note.error, - ), - ); + final updated = buffer + .replaced(note.content) + .copyWith( + dirty: false, + lastSavedText: note.content, + remoteEditBase: repository.editorBase(note.localId), + revision: note.content == buffer.text + ? math.max(buffer.revision, note.revision) + : math.max(buffer.revision, note.revision) + 1, + remoteTitle: note.title, + readonly: note.readonly || note.error, + ); + _localHistory.observeAuthoritativeReplacement(buffer, updated); + buffers.add(updated); } } final activeId = buffers.any((b) => b.id == state.activeBufferId) @@ -599,6 +605,21 @@ class WorkspaceController extends Notifier { } } + void _settleRemovedRemoteHistory(DocumentBuffer buffer) { + unawaited( + _localHistory.flushBuffer(buffer).then((settled) async { + if (!ref.mounted || + state.documentBuffers.any((current) => current.id == buffer.id)) { + return; + } + await _localHistory.handleBufferClosed( + buffer.id, + historySettled: settled, + ); + }), + ); + } + Future openNextcloudWorkspace( String accountId, { List? restoredTabs, @@ -2777,6 +2798,7 @@ class WorkspaceController extends Notifier { } if (!_fileMonitorOperationIsCurrent(workspaceGeneration)) return; _workspaceDiskRevision++; + _localHistory.invalidateCurrentSourceComparisons(); _reconciledNotifications = null; final matching = state.documentBuffers.where((buffer) { final path = buffer.filePath; @@ -4752,7 +4774,8 @@ class WorkspaceController extends Notifier { } Future deleteWorkspaceEntity(String path) async { - if (!await _protectWorkspaceEntityBeforeDelete(path)) return false; + final protection = await _protectWorkspaceEntityBeforeDelete(path); + if (protection == null) return false; String? historyOperationId; var committed = false; try { @@ -4761,6 +4784,15 @@ class WorkspaceController extends Notifier { path: path, recursive: true, filesystemOperation: () async { + if (!await _isLocalHistoryLossProtectionCurrent(protection) || + !setEquals( + await _localHistoryDeletionPaths(path), + protection.paths, + )) { + throw const LocalHistoryStorageException( + 'The files changed while Local History was protecting deletion.', + ); + } _authorizeRemovedBuffers(p.normalize(path), workspace); await _service.deleteEntity(workspace, path); _removalAuthorizations[p.normalize(path)]?.committed = true; @@ -4787,53 +4819,59 @@ class WorkspaceController extends Notifier { } } - Future _protectWorkspaceEntityBeforeDelete(String path) async { - final historyPolicy = _localHistory.policy; - if (!historyPolicy.recordingEnabled || historyPolicy.excludes(path)) { - return true; - } - final workspace = state.workspace; - if (workspace == null) return false; - final protectedPaths = {}; - for (final buffer in state.documentBuffers) { - final filePath = buffer.filePath; - if (filePath == null || - !_isEligibleLocalHistoryPath(filePath) || - historyPolicy.excludes(filePath) || - !(p.equals(filePath, path) || p.isWithin(path, filePath))) { - continue; - } - if (!await _localHistory.captureBeforeLoss( - LocalHistoryBufferSnapshot.fromBuffer(buffer), + Future<_LocalHistoryLossProtection?> _protectWorkspaceEntityBeforeDelete( + String path, + ) async { + if (state.workspace == null) return null; + try { + return await _protectLocalHistoryPaths( + await _localHistoryDeletionPaths(path), LocalHistoryCaptureReason.beforeDelete, - )) { - return false; - } - protectedPaths.add(p.normalize(filePath)); + ); + } on Object { + return null; } - for (final file in workspace.files) { - final filePath = file.absolutePath; - if (protectedPaths.contains(p.normalize(filePath)) || - !_isEligibleLocalHistoryPath(filePath) || - historyPolicy.excludes(filePath) || - !(p.equals(filePath, path) || p.isWithin(path, filePath))) { - continue; - } - try { - final load = await _service.loadTextWithSnapshot(filePath); - if (!await _localHistory.capturePathBeforeLoss( - path: filePath, - text: load.text, - format: load.format, - reason: LocalHistoryCaptureReason.beforeDelete, + } + + Future> _localHistoryDeletionPaths(String path) async { + final policy = _localHistory.policy; + if (!policy.recordingEnabled || policy.excludes(path)) return {}; + final paths = {}; + void include(String candidate) { + if (_isEligibleLocalHistoryPath(candidate) && + !policy.excludes(candidate)) { + paths.add(p.normalize(candidate)); + } + } + + // Enumerate the current filesystem, including files created since the last + // workspace refresh. Open buffers are protected even if already missing. + final type = await FileSystemEntity.type(path, followLinks: false); + if (type == FileSystemEntityType.directory) { + final directories = [Directory(path)]; + while (directories.isNotEmpty) { + await for (final entity in directories.removeLast().list( + followLinks: false, )) { - return false; + if (policy.excludes(entity.path)) continue; + if (entity is Directory) { + directories.add(entity); + } else if (entity is File) { + include(entity.path); + } } - } on Object { - return false; } + } else if (type == FileSystemEntityType.file) { + include(path); } - return true; + for (final buffer in state.documentBuffers) { + final filePath = buffer.filePath; + if (filePath != null && + (p.equals(filePath, path) || p.isWithin(path, filePath))) { + include(filePath); + } + } + return paths; } Future moveWritersideTocEntry({ @@ -5272,9 +5310,10 @@ class WorkspaceController extends Notifier { final deleting = request.analysis.mode == WritersideTopicRemovalMode.safeDeleteFile; final deletedPath = p.normalize(request.analysis.topicPath); - if (deleting && !await _protectWorkspaceEntityBeforeDelete(deletedPath)) { - return null; - } + final protection = deleting + ? await _protectWorkspaceEntityBeforeDelete(deletedPath) + : null; + if (deleting && protection == null) return null; WritersideTopicRemovalResult? result; String? historyOperationId; var historyCommitted = false; @@ -5295,11 +5334,20 @@ class WorkspaceController extends Notifier { ? await _localHistory.runStagedPathDeletion( path: deletedPath, recursive: false, - filesystemOperation: () => _service.applyWritersideTopicRemoval( - workspace, - request, - validateBeforeCommit: validate, - ), + filesystemOperation: () async { + if (!await _isLocalHistoryLossProtectionCurrent( + protection!, + )) { + throw const LocalHistoryStorageException( + 'The topic changed while Local History was protecting deletion.', + ); + } + return _service.applyWritersideTopicRemoval( + workspace, + request, + validateBeforeCommit: validate, + ); + }, didCommit: (value) => value?.deletedFile == true, onOperationStaged: (value) => historyOperationId = value, ) @@ -7634,6 +7682,17 @@ class WorkspaceController extends Notifier { return true; } + LocalHistoryDocument _authoritativeLocalHistoryDocument( + LocalHistoryDocument document, + ) => + ref + .read(localHistoryControllerProvider) + .snapshot + .documents + .where((candidate) => candidate.id == document.id) + .firstOrNull ?? + document; + Future restoreLocalHistoryRevision({ required LocalHistoryDocument document, required LocalHistoryRevision revision, @@ -7641,20 +7700,44 @@ class WorkspaceController extends Notifier { SourceComparisonChange? change, }) async { if (revision.summary.documentId != document.id) return false; - final path = document.currentPath ?? revision.summary.historicalPath; + document = _authoritativeLocalHistoryDocument(document); + if (comparison != null) { + final current = await localHistoryCurrentSource(document, revision); + if (current.id != comparison.currentInput.id || + current.version != comparison.currentInput.version || + current.source != comparison.currentInput.source) { + return false; + } + } + final path = document.deleted ? null : document.currentPath; var buffer = localHistoryBufferForDocument(document, revision); + if (buffer != null && + !buffer.isRemote && + !document.deleted && + path != null && + (buffer.filePath == null || !p.equals(buffer.filePath!, path))) { + return false; + } if (buffer == null && document.remoteNote != null) { + final reference = document.remoteNote!; + if (state.workspace?.nextcloudAccountId != reference.accountId && + !await openNextcloudWorkspace(reference.accountId)) { + return false; + } if (!await openNextcloudNote(document.remoteNote!.localId)) return false; - buffer = state.activeBuffer; + buffer = localHistoryBufferForDocument(document, revision); } if (buffer == null && path != null && await _service.pathExists(path)) { - if (!await _openActiveFile(path)) return false; - buffer = state.activeBuffer; + if (!await _openLocalHistoryRestoreDestination(path)) return false; + buffer = localHistoryBufferForDocument(document, revision); } else if (buffer != null && state.activeBufferId != buffer.id) { if (!await activateDocumentBuffer(buffer.id)) return false; buffer = state.activeBuffer; } if (buffer == null || (buffer.isRemote && buffer.readonly)) return false; + if (comparison != null && buffer.text != comparison.currentInput.source) { + return false; + } final targetId = buffer.id; final targetPath = buffer.filePath; final targetRevision = buffer.revision; @@ -7667,7 +7750,14 @@ class WorkspaceController extends Notifier { comparison: comparison, change: change, ); - if (nextText == null || nextText == targetText) return nextText != null; + if (nextText == null) return false; + if (!buffer.isRemote) { + nextText = _withFinalNewlinePolicy( + nextText, + buffer.format.hasFinalNewline, + ); + } + if (nextText == targetText) return true; if (buffer.remoteNote != null) { try { nextText = await (await _ensureNotesRepository()) @@ -7688,6 +7778,24 @@ class WorkspaceController extends Notifier { )) { return false; } + // A peer can move or clear this lineage while protection is pending. + // Read durable authority after protection, before changing the buffer. + // Keeping acceptance before this await also preserves Clear cancellation. + LocalHistoryDocument? protectedDocument; + try { + final snapshot = await _localHistory.readRecoveryHistory(); + protectedDocument = snapshot.documents + .where((candidate) => candidate.id == document.id) + .firstOrNull; + } on Object { + return false; + } + if (protectedDocument == null || + protectedDocument.currentPath != document.currentPath || + protectedDocument.deleted != document.deleted || + protectedDocument.remoteNote != document.remoteNote) { + return false; + } final current = state.activeBuffer; if (current == null || current.id != targetId || @@ -7699,8 +7807,11 @@ class WorkspaceController extends Notifier { final nextSelection = change == null ? TextSelection.collapsed(offset: nextText.length) : TextSelection( - baseOffset: change.currentRange.start, - extentOffset: change.currentRange.start + change.oldText.length, + baseOffset: math.min(nextText.length, change.currentRange.start), + extentOffset: math.min( + nextText.length, + change.currentRange.start + change.oldText.length, + ), ); final restored = current .edited( @@ -7742,6 +7853,7 @@ class WorkspaceController extends Notifier { canRestore: false, ); } + document = _authoritativeLocalHistoryDocument(document); final buffer = localHistoryBufferForDocument(document, revision); if (buffer != null) { return LocalHistoryCurrentSourceSnapshot( @@ -7766,7 +7878,7 @@ class WorkspaceController extends Notifier { ); } } - final path = document.currentPath ?? revision.summary.historicalPath; + final path = document.deleted ? null : document.currentPath; if (path != null && await _service.pathExists(path)) { final loaded = await _service.loadTextWithSnapshot(path); return LocalHistoryCurrentSourceSnapshot( @@ -7849,7 +7961,15 @@ class WorkspaceController extends Notifier { } return false; } - if (destination.text == revision.source) { + // Keep destination formatting while making the editor's canonical source + // agree with the bytes that Save will produce under that policy. + final restoredText = createdSnapshot != null + ? revision.source + : _withFinalNewlinePolicy( + revision.source, + destination.format.hasFinalNewline, + ); + if (destination.text == restoredText) { return true; } final target = _LocalHistoryRestoreTarget( @@ -7899,13 +8019,17 @@ class WorkspaceController extends Notifier { .firstOrNull!; final restored = current .edited( - revision.source, + restoredText, previousSelection: current.editorState.selection, - nextSelection: TextSelection.collapsed( - offset: revision.source.length, - ), + nextSelection: TextSelection.collapsed(offset: restoredText.length), ) - .copyWith(format: current.format); + .copyWith( + format: createdSnapshot == null + ? current.format + : revision.format.copyWith( + hasFinalNewline: restoredText.endsWith('\n'), + ), + ); _localHistory.observeEdit(current, restored); state = state.copyWith( documentBuffers: _replaceBuffer(state.documentBuffers, restored), @@ -7988,27 +8112,24 @@ class WorkspaceController extends Notifier { LocalHistoryRevision revision, ) { if (revision.summary.documentId != document.id) return null; + document = _authoritativeLocalHistoryDocument(document); if (document.remoteNote case final reference?) { return state.documentBuffers .where((buffer) => buffer.remoteNote == reference) .firstOrNull; } - final boundBufferId = _localHistory.bufferIdForDocument(document.id); - if (boundBufferId != null) { - final bound = state.documentBuffers - .where((buffer) => buffer.id == boundBufferId) - .firstOrNull; - if (bound != null) return bound; - } - final paths = { - document.currentPath, - revision.summary.historicalPath, - }.whereType(); - for (final path in paths) { - final pathBuffer = state.bufferForPath(path); - if (pathBuffer != null) return pathBuffer; - } - return null; + final bound = state.documentBuffers + .where( + (buffer) => + _localHistory.documentIdForBuffer(buffer.id) == document.id, + ) + .firstOrNull; + if (bound != null) return bound; + if (document.deleted || document.currentPath == null) return null; + final pathBuffer = state.bufferForPath(document.currentPath!); + if (pathBuffer == null) return null; + final owner = _localHistory.documentIdForBuffer(pathBuffer.id); + return owner == null || owner == document.id ? pathBuffer : null; } Future _rollbackMissingHistoryDestination( @@ -8037,40 +8158,78 @@ class WorkspaceController extends Notifier { Future protectPathsBeforeExternalReplacement( Iterable paths, ) async { - for (final path in paths.toSet()) { - if (!_isEligibleLocalHistoryPath(path)) continue; + final protection = await _protectLocalHistoryPaths( + paths, + LocalHistoryCaptureReason.beforeDiscard, + ); + return protection != null && + await _isLocalHistoryLossProtectionCurrent(protection); + } + + Future<_LocalHistoryLossProtection?> _protectLocalHistoryPaths( + Iterable paths, + LocalHistoryCaptureReason reason, + ) async { + final policy = _localHistory.policy; + final protection = _LocalHistoryLossProtection(state.workspace?.id); + if (!policy.recordingEnabled) return protection; + for (final path in paths.map(p.normalize).toSet()) { + if (!_isEligibleLocalHistoryPath(path) || policy.excludes(path)) continue; + protection.paths.add(path); final buffer = state.bufferForPath(path); if (buffer != null) { if (!await _localHistory.captureBeforeLoss( LocalHistoryBufferSnapshot.fromBuffer(buffer), - LocalHistoryCaptureReason.beforeDiscard, + reason, )) { - return false; + return null; } - if (!_bufferOperationTargetIsCurrent(buffer)) return false; - continue; + protection.buffers.add(buffer); + if (!_bufferOperationTargetIsCurrent(buffer)) return null; } try { final loaded = await _service.loadTextWithSnapshot(path); - if (!await _localHistory.capturePathBeforeLoss( - path: path, - text: loaded.text, - format: loaded.format, - reason: LocalHistoryCaptureReason.beforeDiscard, - )) { - return false; - } - if (await _service.fileChangedSince(path, loaded.snapshot)) { - return false; + // Capture the actual disk source even when a stale or dirty editor is + // open. These are independent versions of the same document lineage. + if ((buffer == null || + buffer.text != loaded.text || + !mapEquals(buffer.format.toJson(), loaded.format.toJson())) && + !await _localHistory.capturePathBeforeLoss( + path: path, + text: loaded.text, + format: loaded.format, + reason: reason, + )) { + return null; } + protection.diskSnapshots[path] = loaded.snapshot; } on FileSystemException { - // A path already absent from the working tree has no current source - // version to protect. + // Only absence is safe to skip. An unreadable file still has content + // that must not be destroyed under a successful protection result. + if (await _service.pathExists(path)) return null; + protection.diskSnapshots[path] = null; } on FormatException { // Binary or invalid text content is outside Local History's scope. } } - return true; + return await _isLocalHistoryLossProtectionCurrent(protection) + ? protection + : null; + } + + Future _isLocalHistoryLossProtectionCurrent( + _LocalHistoryLossProtection protection, + ) async { + for (final entry in protection.diskSnapshots.entries) { + if (entry.value == null + ? await _service.pathExists(entry.key) + : await _service.fileChangedSince(entry.key, entry.value)) { + return false; + } + } + return ref.mounted && + state.workspace?.id == protection.workspaceId && + protection.buffers.every(_bufferOperationTargetIsCurrent); } String? _restoredRegionText({ @@ -9301,6 +9460,15 @@ class _WorkspaceRefreshBufferReplacement { final DocumentBuffer replacement; } +class _LocalHistoryLossProtection { + _LocalHistoryLossProtection(this.workspaceId); + + final String? workspaceId; + final paths = {}; + final buffers = []; + final diskSnapshots = {}; +} + class _LocalHistoryRestoreTarget { const _LocalHistoryRestoreTarget({ required this.workspaceId, diff --git a/test/src/local_history_controller_test.dart b/test/src/local_history_controller_test.dart index 33ece3d5..3f3de584 100644 --- a/test/src/local_history_controller_test.dart +++ b/test/src/local_history_controller_test.dart @@ -22,6 +22,75 @@ import 'package:intl/intl.dart' show DateFormat; import 'package:path/path.dart' as p; void main() { + test( + 'production-store content search reads revisions in bounded batches', + () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-history-search-', + ); + addTearDown(() => root.delete(recursive: true)); + var locks = 0; + final store = FileLocalHistoryStore.testing( + rootDirectory: () async => root, + acquireLock: (file) async { + locks++; + await file.lock(FileLock.exclusive); + }, + ); + for (var i = 0; i < 96; i++) { + await store.capture( + LocalHistoryCaptureRequest( + path: '/workspace/guide.md', + displayName: 'guide.md', + source: 'version $i', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026, 1, 1).add(Duration(seconds: i)), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + } + final container = _historyContainer(store, <_FakeTimer>[]); + addTearDown(container.dispose); + final history = container.read(localHistoryControllerProvider.notifier); + await Future.delayed(Duration.zero); + await history.refresh(); + locks = 0; + final missing = await history.findRetained(query: 'absent content'); + expect(missing!.matches, isEmpty); + expect(locks, 3, reason: 'One authoritative index read per 32 revisions'); + final matches = await history.findRetained(query: 'version 95'); + expect( + matches!.matches.whereType(), + hasLength(1), + ); + for (final day in [2, 3]) { + await store.capture( + LocalHistoryCaptureRequest( + path: '/workspace/guide.md', + displayName: 'guide.md', + source: '$day' * (5 * 1024 * 1024), + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026, 1, day), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + } + await history.refresh(); + locks = 0; + expect( + (await history.findRetained(query: 'absent content'))!.matches, + isEmpty, + ); + expect( + locks, + greaterThan(3), + reason: 'Large revisions must not accumulate in a single content batch', + ); + }, + ); + test( 'failed opened baseline retries its original source without an edit', () async { diff --git a/test/src/local_history_store_test.dart b/test/src/local_history_store_test.dart index d411552f..6760089d 100644 --- a/test/src/local_history_store_test.dart +++ b/test/src/local_history_store_test.dart @@ -62,6 +62,340 @@ void main() { maximumBytes: 16 * 1024 * 1024, ); + for (final reopen in [false, true]) { + for (final newDocument in [false, true]) { + test('unindexed capture retries after publication failure ' + '(reopen=$reopen, newDocument=$newDocument)', () async { + const captureId = 'interrupted_capture_0001'; + var fail = false; + final tested = FileLocalHistoryStore( + rootDirectory: () async => root, + createId: () { + if (fail && + Directory(p.join(root.path, 'revisions')) + .listSync(recursive: true) + .whereType() + .any( + (file) => p.basename(file.path) == '$captureId.json', + )) { + fail = false; + throw const FileSystemException( + 'Injected index publication failure', + ); + } + return 'retry_identity_${++ids}'; + }, + ); + await tested.capture(request('retained', DateTime.utc(2026)), policy); + final pending = request( + 'protected', + DateTime.utc(2026, 1, 2), + path: newDocument ? '/workspace/new.md' : '/workspace/guide.md', + captureId: captureId, + force: true, + reason: LocalHistoryCaptureReason.beforeDelete, + ); + fail = true; + await expectLater( + tested.capture(pending, policy), + throwsA(isA()), + ); + expect(fail, isFalse); + final LocalHistoryStore retried = reopen + ? FileLocalHistoryStore(rootDirectory: () async => root) + : tested; + expect(await retried.readRevision(captureId), isNull); + final recovered = await retried.capture(pending, policy); + expect(recovered.revision!.id, captureId); + expect(recovered.deduplicated, isTrue); + expect((await retried.readRevision(captureId))!.source, pending.source); + expect((await retried.load()).revisions, hasLength(2)); + }); + } + } + + for (final damage in ['source', 'format', 'document', 'version']) { + test( + 'unindexed retry validates record $damage before publication', + () async { + const captureId = 'unindexed_validation_0001'; + final pending = request( + 'protected', + DateTime.utc(2026), + captureId: captureId, + ); + final captured = await store.capture(pending, policy); + final file = File( + p.join( + root.path, + 'revisions', + captured.document.id, + '$captureId.json', + ), + ); + final map = jsonDecode(await file.readAsString()) as Map; + switch (damage) { + case 'source': + map['source'] = 'damaged'; + case 'format': + map['format']['hasUtf8Bom'] = true; + case 'document': + map['document']['id'] = 'other_identity_0001'; + case 'version': + map['version'] = 999; + } + await file.writeAsString(jsonEncode(map)); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + index['revisions'] = []; + await indexFile.writeAsString(jsonEncode(index)); + final before = await indexFile.readAsString(); + await expectLater( + store.capture(pending, policy), + throwsA( + damage == 'version' + ? isA() + : isA(), + ), + ); + expect(await indexFile.readAsString(), before); + }, + ); + } + + test('orphan retry still respects a later clear barrier', () async { + const captureId = 'unindexed_clear_0000001'; + final acceptedAt = DateTime.now().toUtc().subtract( + const Duration(seconds: 1), + ); + final pending = LocalHistoryCaptureRequest( + path: '/workspace/guide.md', + displayName: 'guide.md', + source: 'protected', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026), + reason: LocalHistoryCaptureReason.beforeDelete, + captureId: captureId, + acceptedAt: acceptedAt, + acceptedClearEpoch: '', + ); + final captured = await store.capture(pending, policy); + final file = File( + p.join(root.path, 'revisions', captured.document.id, '$captureId.json'), + ); + final bytes = await file.readAsBytes(); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + index['revisions'] = []; + await indexFile.writeAsString(jsonEncode(index)); + await store.clearDocument(captured.document.id); + await file.parent.create(recursive: true); + await file.writeAsBytes(bytes); + await expectLater( + store.capture(pending, policy), + throwsA(isA()), + ); + await indexFile.writeAsString('{damaged'); + expect((await store.load()).revisions, isEmpty); + }); + + for (final missing in [false, true]) { + test('index recovery preserves authoritative paths and deleted identities ' + '(missing=$missing)', () async { + final original = await store.capture( + request('original', DateTime.utc(2026), path: '/workspace/A.md'), + policy, + ); + await store.remapPath('/workspace/A.md', '/workspace/B.md'); + final replacement = await store.capture( + request( + 'replacement', + DateTime.utc(2026, 1, 2), + path: '/workspace/A.md', + ), + policy, + ); + await store.markDeleted('/workspace/B.md', recursive: false); + final untitled = await store.capture( + LocalHistoryCaptureRequest( + displayName: 'Untitled', + source: 'draft', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026), + reason: LocalHistoryCaptureReason.saved, + untitled: true, + ), + policy, + ); + await store.promoteUntitledDocument( + documentId: untitled.document.id, + destinationPath: '/workspace/C.md', + displayName: 'C.md', + updatedAt: DateTime.utc(2026, 1, 3), + ); + final index = File(p.join(root.path, 'index.json')); + if (missing) { + await index.delete(); + } else { + await index.writeAsString('{damaged'); + } + final reopened = FileLocalHistoryStore(rootDirectory: () async => root); + final recovered = await reopened.load(); + final byId = { + for (final document in recovered.documents) document.id: document, + }; + expect(byId[original.document.id]!.currentPath, '/workspace/B.md'); + expect(byId[original.document.id]!.deleted, isTrue); + expect(byId[replacement.document.id]!.currentPath, '/workspace/A.md'); + expect(byId[replacement.document.id]!.deleted, isFalse); + expect(byId[untitled.document.id]!.currentPath, '/workspace/C.md'); + expect(byId[untitled.document.id]!.untitled, isFalse); + expect( + await reopened.resolvePathTargets('/workspace/A.md', recursive: false), + hasLength(1), + ); + }); + } + + test( + 'retention preserves renamed identity metadata for an unindexed capture', + () async { + final original = await store.capture( + request('old', DateTime.utc(2026), path: '/workspace/A.md'), + policy, + ); + const captureId = 'unindexed_retention_0001'; + final pending = LocalHistoryCaptureRequest( + documentId: original.document.id, + path: '/workspace/A.md', + displayName: 'A.md', + source: 'protected source', + format: TextFormatMetadata.utf8Lf, + capturedAt: DateTime.utc(2026, 1, 2), + reason: LocalHistoryCaptureReason.beforeDelete, + captureId: captureId, + ); + await store.capture(pending, policy); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + (index['revisions'] as List).removeWhere( + (dynamic revision) => revision['id'] == captureId, + ); + await indexFile.writeAsString(jsonEncode(index)); + await store.remapPath('/workspace/A.md', '/workspace/B.md'); + await store.prune(policy, DateTime.utc(2026, 1, 31, 12)); + expect((await store.load()).revisions, isEmpty); + final reopened = FileLocalHistoryStore(rootDirectory: () async => root); + final recovered = await reopened.capture(pending, policy); + expect(recovered.document.id, original.document.id); + expect(recovered.document.currentPath, '/workspace/B.md'); + expect((await reopened.readRevision(captureId))!.source, pending.source); + }, + ); + + test('legacy identity metadata migrates before index recovery', () async { + await store.capture(request('original', DateTime.utc(2026)), policy); + await store.remapPath('/workspace/guide.md', '/workspace/moved.md'); + await File(p.join(root.path, 'documents.json')).delete(); + await store.load(); + await File(p.join(root.path, 'index.json')).writeAsString('{damaged'); + expect( + (await store.load()).documents.single.currentPath, + '/workspace/moved.md', + ); + }); + + test( + 'JSON expansion cannot evict retained history with an oversized record', + () async { + await store.capture(request('retained', DateTime.utc(2026)), policy); + await expectLater( + store.capture( + request('"' * (9 * 1024 * 1024), DateTime.utc(2026, 1, 2)), + policy, + ), + throwsA(isA()), + ); + await store.prune(policy, DateTime.utc(2026, 1, 2)); + final retained = (await store.load()).revisions.single; + expect((await store.readRevision(retained.id))!.source, 'retained'); + final files = Directory( + p.join(root.path, 'revisions'), + ).listSync(recursive: true).whereType(); + expect(files, hasLength(1)); + }, + ); + + test( + 'oversized traversal reuse cannot evict other revisions after a limit reduction', + () async { + final source = '"' * (9 * 1024 * 1024); + final large = await store.capture( + request(source, DateTime.utc(2026)), + const LocalHistoryPolicy(), + ); + final safe = await store.capture( + request('safe', DateTime.utc(2026, 1, 2)), + const LocalHistoryPolicy(), + ); + await expectLater( + store.capture( + request( + source, + DateTime.utc(2026), + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + captureId: large.revision!.id, + ), + policy, + ), + throwsA(isA()), + ); + expect((await store.load()).revisions, hasLength(2)); + expect((await store.readRevision(safe.revision!.id))!.source, 'safe'); + }, + ); + + test( + 'batch reads share a lock and validate the live revision index', + () async { + var locks = 0; + final tested = FileLocalHistoryStore.testing( + rootDirectory: () async => root, + acquireLock: (file) async { + locks++; + await file.lock(FileLock.exclusive); + }, + ); + final first = await tested.capture( + request('first', DateTime.utc(2026)), + policy, + ); + final second = await tested.capture( + request( + 'second', + DateTime.utc(2026, 1, 2), + path: '/workspace/second.md', + ), + policy, + ); + locks = 0; + final batch = await tested.readRevisions([ + second.revision!.id, + first.revision!.id, + ]); + expect(batch.map((revision) => revision.source), ['second', 'first']); + expect(locks, 1); + await tested.clearDocument(first.document.id); + expect( + (await tested.readRevisions([ + first.revision!.id, + second.revision!.id, + ])).map((revision) => revision.source), + ['second'], + ); + }, + ); + for (final memory in [false, true]) { test( 'retry of a traversal capture identity renews expired protection (memory=$memory)', @@ -1505,9 +1839,8 @@ void main() { final bytes = await revision.readAsBytes(); final before = ids; await store.prune(policy, DateTime.utc(2020, 2, 1)); - // Only the tombstone and index staging identities are allocated: their - // publication precedes cleanup and needs no identical second transaction. - expect(ids - before, 2); + // Tombstones, document identities and the index commit before cleanup. + expect(ids - before, 3); expect(await revision.exists(), isFalse); expect( jsonDecode( @@ -1534,9 +1867,9 @@ void main() { request('current', DateTime.utc(2020, 2, 1)), policy, ); - // Revision identity/stage and changed index, then the retained journal - // and index. The initial capture reuses the unchanged tombstone journal. - expect(ids - before, 5); + // Revision identity/stage, changed document identities and index, then + // retention's tombstone journal and index. Unchanged journals are reused. + expect(ids - before, 6); final snapshot = await store.load(); expect(snapshot.revisions.map((r) => r.id), [current.revision!.id]); expect( diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index f33b1c2c..7b6d440d 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'dart:async'; +import 'dart:convert'; import 'package:busymark/src/app/app_settings.dart'; import 'package:busymark/src/app/app_theme.dart'; @@ -36,6 +37,314 @@ void main() { if (await root.exists()) await root.delete(recursive: true); }); + test( + 'renamed history restores its identity without claiming a replacement path', + () async { + final original = p.join(root.path, 'A.md'); + final renamed = p.join(root.path, 'B.md'); + await File(original).writeAsString('original'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture(store, original, 'historical'); + final revision = (await store.readRevision(captured.revision!.id))!; + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(original); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + expect( + await h.controller.renameWorkspaceEntity(original, 'B.md'), + isTrue, + ); + expect( + await h.controller.closeDocumentBuffer(h.state.activeBuffer!.id), + isTrue, + ); + await File(original).writeAsString('replacement A'); + expect(await h.controller.openActiveFile(original), isTrue); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + + // Exercise a retained document object from before the rename as well. + final current = await h.controller.localHistoryCurrentSource( + captured.document, + revision, + ); + expect(current.kind, LocalHistoryCurrentSourceKind.disk); + expect(current.source, 'original'); + expect( + await h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ), + isTrue, + ); + expect(h.state.activeBuffer!.filePath, renamed); + expect(h.state.bufferForPath(original)!.text, 'replacement A'); + expect(await h.controller.saveActive(), isTrue); + expect(await File(renamed).readAsString(), 'historical'); + expect(await File(original).readAsString(), 'replacement A'); + }, + ); + + for (final duringCapture in [false, true]) { + test( + 'restore rejects a stale buffer after a peer changes its authoritative path ' + '(duringCapture=$duringCapture)', + () async { + final original = p.join(root.path, 'A.md'); + final moved = p.join(root.path, 'B.md'); + await File(original).writeAsString('original source'); + final store = _MutatingProtectionStore(); + final captured = await _capture(store, original, 'historical source'); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(original); + final revision = (await store.readRevision(captured.revision!.id))!; + Future move() async { + await File(original).rename(moved); + await File(original).writeAsString('replacement source'); + await store.remapPath(original, moved); + } + + if (duringCapture) { + store.afterCapture = (request) async { + if (request.reason == LocalHistoryCaptureReason.beforeRestore) { + store.afterCapture = null; + await move(); + } + }; + } else { + await move(); + } + expect( + await h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ), + isFalse, + ); + expect(h.state.activeText, 'original source'); + expect(await File(original).readAsString(), 'replacement source'); + expect(await File(moved).readAsString(), 'original source'); + }, + ); + } + + for (final overwrite in [false, true]) { + for (final finalNewline in [false, true]) { + test('restore and Save agree with destination formatting ' + '(overwrite=$overwrite, finalNewline=$finalNewline)', () async { + final path = p.join(root.path, 'destination.md'); + final original = finalNewline ? 'current\n' : 'current'; + await File( + path, + ).writeAsBytes([0xef, 0xbb, 0xbf, ...original.codeUnits]); + final source = finalNewline ? 'historical' : 'historical\n\n'; + final store = MemoryLocalHistoryStore(); + final captured = await _capture( + store, + overwrite ? p.join(root.path, 'missing.md') : path, + source, + ); + final revision = (await store.readRevision(captured.revision!.id))!; + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(path); + expect( + overwrite + ? await h.controller.restoreMissingLocalHistoryRevision( + document: captured.document, + revision: revision, + destinationPath: path, + overwriteExisting: true, + ) + : await h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ), + isTrue, + ); + final expected = finalNewline ? 'historical\n' : 'historical'; + expect(h.state.activeText, expected); + expect(h.state.activeBuffer!.format.hasFinalNewline, finalNewline); + expect(await h.controller.saveActive(), isTrue); + final loaded = await const WorkspaceService().loadTextWithSnapshot( + path, + ); + expect(loaded.text, expected); + expect(h.state.activeText, loaded.text); + expect(h.state.activeBuffer!.lastSavedText, loaded.text); + expect(h.state.activeBuffer!.isDirty, isFalse); + expect(loaded.format.hasUtf8Bom, isTrue); + }); + } + } + + for (final deletion in [false, true]) { + for (final dirty in [false, true]) { + test('protection retains fresh disk and editor versions ' + '(deletion=$deletion, dirty=$dirty)', () async { + final path = p.join(root.path, 'protected.md'); + await File(path).writeAsString('opened source'); + final store = MemoryLocalHistoryStore(); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(path); + if (dirty) h.controller.updateActiveText('unsaved source'); + await File(path).writeAsString('unreported external source'); + expect( + deletion + ? await h.controller.deleteWorkspaceEntity(path) + : await h.controller.protectPathsBeforeExternalReplacement([ + path, + ]), + isTrue, + ); + final snapshot = await store.load(); + final sources = await Future.wait( + snapshot.revisions.map( + (r) async => (await store.readRevision(r.id))!.source, + ), + ); + expect( + sources, + containsAll([ + 'unreported external source', + dirty ? 'unsaved source' : 'opened source', + ]), + ); + expect(await File(path).exists(), !deletion); + }); + } + } + + test( + 'recursive deletion protects files created since workspace discovery', + () async { + final folder = Directory(p.join(root.path, 'folder')); + await folder.create(); + await File(p.join(folder.path, 'known.md')).writeAsString('known'); + final store = MemoryLocalHistoryStore(); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + final unseen = p.join(folder.path, 'unseen.md'); + await File(unseen).writeAsString('newly created'); + expect(await h.controller.deleteWorkspaceEntity(folder.path), isTrue); + final snapshot = await store.load(); + final revision = snapshot.revisions.singleWhere( + (r) => r.historicalPath == unseen, + ); + expect((await store.readRevision(revision.id))!.source, 'newly created'); + }, + ); + + for (final deletion in [false, true]) { + test( + 'protection rejects disk changes during capture (deletion=$deletion)', + () async { + final path = p.join(root.path, 'racing.md'); + await File(path).writeAsString('editor source'); + final store = _MutatingProtectionStore(); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(path); + await File(path).writeAsString('protected disk source'); + store.afterCapture = (request) async { + if (request.source == 'protected disk source') { + store.afterCapture = null; + await File(path).writeAsString('later external source'); + } + }; + expect( + deletion + ? await h.controller.deleteWorkspaceEntity(path) + : await h.controller.protectPathsBeforeExternalReplacement([ + path, + ]), + isFalse, + ); + expect(await File(path).readAsString(), 'later external source'); + }, + ); + } + + test( + 'deletion rechecks protected disk at the filesystem commit boundary', + () async { + final path = p.join(root.path, 'racing-delete.md'); + await File(path).writeAsString('protected source'); + final store = _MutatingProtectionStore(); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + await h.controller.openActiveFile(path); + store.beforePathOperation = () => + File(path).writeAsString('later source'); + expect(await h.controller.deleteWorkspaceEntity(path), isFalse); + expect(await File(path).readAsString(), 'later source'); + }, + ); + + test( + 'retained protective work recovers an unindexed record with a later retry time', + () async { + final path = p.join(root.path, 'retry.md'); + await File(path).writeAsString('current source'); + final historyRoot = Directory(p.join(root.path, 'history')); + var fail = false; + var ids = 0; + final store = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + createId: () { + if (fail && + Directory(p.join(historyRoot.path, 'revisions')) + .listSync(recursive: true) + .whereType() + .any( + (file) => + (jsonDecode(file.readAsStringSync()) + as Map)['revision']['reason'] == + 'beforeRestore', + )) { + fail = false; + throw const FileSystemException( + 'Injected publication failure', + '', + OSError('', 11), + ); + } + return 'retry_identity_${++ids}'; + }, + ); + final captured = await _capture(store, path, 'historical source'); + var now = DateTime.utc(2026, 1, 1); + final h = await _harness( + store, + fileMonitor: _ControlledFileMonitor(), + clock: () => now = now.add(const Duration(seconds: 1)), + ); + await h.controller.openPath(path); + final revision = (await store.readRevision(captured.revision!.id))!; + fail = true; + expect( + await h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ), + isFalse, + ); + expect(fail, isFalse); + expect(h.state.activeText, 'current source'); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + final recovered = (await store.load()).revisions.singleWhere( + (r) => r.reason == LocalHistoryCaptureReason.beforeRestore, + ); + expect( + (await store.readRevision(recovered.id))!.source, + 'current source', + ); + }, + ); + for (final delayedFailure in [false, true]) { test( 'retention eviction does not discharge a later traversal protection (delayedFailure=$delayedFailure)', @@ -1581,7 +1890,7 @@ void main() { ); final restored = harness.state.activeBuffer!; expect(restored.filePath, destination); - expect(restored.text, revision.source); + expect(restored.text, revision.source.trimRight()); expect(restored.isDirty, isTrue); expect(restored.format.hasUtf8Bom, isTrue); expect(restored.format.lineEnding, DocumentLineEnding.crlf); @@ -1591,7 +1900,7 @@ void main() { expect(harness.controller.undoActiveBuffer(), isTrue); expect(harness.state.activeText, 'Original destination\nSecond line'); expect(harness.controller.redoActiveBuffer(), isTrue); - expect(harness.state.activeText, revision.source); + expect(harness.state.activeText, revision.source.trimRight()); }, ); @@ -6799,6 +7108,174 @@ void main() { }, ); + testWidgets( + 'comparison reports unsuccessful restoration and retains the editor', + (tester) async { + final path = p.join(root.path, 'failed-restore.md'); + final h = (await tester.runAsync(() async { + await File(path).writeAsString('current source'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture(store, path, 'historical source'); + final h = await _harness( + _FailingProtectiveStore(store), + fileMonitor: _ControlledFileMonitor(), + sessionStore: MemoryDocumentSessionStore(), + recoveryStore: MemoryDocumentRecoveryStore(), + ); + await h.controller.openPath(path); + final history = h.container.read( + localHistoryControllerProvider.notifier, + ); + await history.refresh(); + history.selectDocument(captured.document.id); + await history.selectRevision(captured.revision!.id); + return h; + }))!; + await tester.pumpWidget( + UncontrolledProviderScope( + container: h.container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: Colors.blue, + ), + home: const Scaffold(body: LocalHistoryComparisonView()), + ), + ), + ); + await tester.pumpAndSettle(); + final l10n = AppLocalizations.of( + tester.element(find.byType(LocalHistoryComparisonView)), + ); + final restore = find.byWidgetPredicate( + (widget) => + widget is IconButton && + widget.tooltip == l10n.localHistoryRestoreRevision, + ); + expect(tester.widget(restore).onPressed, isNotNull); + await tester.runAsync(() async { + tester.widget(restore).onPressed!(); + await _waitFor( + () => + h.container.read(localHistoryControllerProvider).warning?.kind == + LocalHistoryWarningKind.capture, + ); + await h.controller.flushPersistence(); + }); + await tester.pumpAndSettle(); + expect(h.state.activeText, 'current source'); + expect(find.text(l10n.localHistoryRestoreUnavailable), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + for (final notification in [false, true]) { + testWidgets('closed-file comparison reloads disk and missing-file actions ' + '(notification=$notification)', (tester) async { + final path = p.join(root.path, 'closed.md'); + final anchor = p.join(root.path, 'anchor.md'); + final monitor = _ControlledFileMonitor(); + final currentInputs = []; + final h = (await tester.runAsync(() async { + await File(path).writeAsString('disk original'); + await File(anchor).writeAsString('anchor'); + final store = MemoryLocalHistoryStore(); + final captured = await _capture(store, path, 'historical'); + final h = await _harness( + store, + fileMonitor: monitor, + comparisonComputer: (old, current) async { + currentInputs.add(current); + return compareSource(old, current); + }, + ); + await h.controller.openPath(anchor); + final history = h.container.read( + localHistoryControllerProvider.notifier, + ); + await history.refresh(); + history.inspectRetainedDocument(captured.document.id); + await history.selectRevision(captured.revision!.id); + return h; + }))!; + await tester.runAsync(() async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: h.container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: Colors.blue, + ), + home: const Scaffold(body: LocalHistoryComparisonView()), + ), + ), + ); + await _waitFor(() => currentInputs.isNotEmpty); + }); + await tester.pumpAndSettle(); + expect(currentInputs.last.source, 'disk original'); + final history = h.container.read(localHistoryControllerProvider.notifier); + await tester.runAsync(() async { + await File(path).writeAsString('disk replacement'); + if (notification) { + final version = h.container + .read(localHistoryControllerProvider) + .comparisonRevision; + monitor.emitChange(path); + await _waitFor( + () => + h.container + .read(localHistoryControllerProvider) + .comparisonRevision > + version, + ); + } else { + await history.refresh(); + } + await tester.pump(); + await _waitFor(() => currentInputs.last.source == 'disk replacement'); + }); + await tester.pumpAndSettle(); + expect(currentInputs.last.source, 'disk replacement'); + await tester.runAsync(() async { + await File(path).delete(); + if (notification) { + final version = h.container + .read(localHistoryControllerProvider) + .comparisonRevision; + monitor.emitDeletion(path); + await _waitFor( + () => + h.container + .read(localHistoryControllerProvider) + .comparisonRevision > + version, + ); + } else { + await history.refresh(); + } + await tester.pump(); + await _waitFor(() => currentInputs.last.source.isEmpty); + }); + await tester.pumpAndSettle(); + expect(currentInputs.last.source, isEmpty); + final l10n = AppLocalizations.of( + tester.element(find.byType(LocalHistoryComparisonView)), + ); + expect( + find.text(l10n.localHistoryRestoreOriginalLocation), + findsOneWidget, + ); + expect(find.text(l10n.localHistoryRestoreNewLocation), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + testWidgets( 'comparison renders styled intraline spans without framework errors', (tester) async { @@ -7109,6 +7586,43 @@ Future<_Harness> _harness( return _Harness(container, controller); } +class _MutatingProtectionStore extends MemoryLocalHistoryStore { + Future Function(LocalHistoryCaptureRequest request)? afterCapture; + Future Function()? beforePathOperation; + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + final result = await super.capture(request, policy); + await afterCapture?.call(request); + return result; + } + + @override + Future runPathReconciliation({ + required LocalHistoryPathReconciliationKind kind, + required String sourcePath, + String? destinationPath, + required bool recursive, + Iterable? preparedTargets, + required Future Function(List targets) operation, + required bool Function(T result) didCommit, + }) async { + await beforePathOperation?.call(); + return super.runPathReconciliation( + kind: kind, + sourcePath: sourcePath, + destinationPath: destinationPath, + recursive: recursive, + preparedTargets: preparedTargets, + operation: operation, + didCommit: didCommit, + ); + } +} + class _UnavailableCaptureStore extends MemoryLocalHistoryStore { var available = false; var protectiveAttempts = 0; diff --git a/test/src/nextcloud_workspace_test.dart b/test/src/nextcloud_workspace_test.dart index c76d3b8c..1861f628 100644 --- a/test/src/nextcloud_workspace_test.dart +++ b/test/src/nextcloud_workspace_test.dart @@ -27,6 +27,104 @@ import 'nextcloud_notes/notes_api_test.dart' show serverNote; const _accountId = 'ba6d02cd-42fe-4e14-a577-3e2c13822459'; void main() { + for (final flushBetween in [false, true]) { + test('remote refresh retains displaced and incoming versions after close ' + '(flushBetween=$flushBetween)', () async { + var remote = serverNote(1, content: 'R0', etag: 'v0'); + final h = await _Harness.create( + seedLocal: false, + client: MockClient( + (request) async => http.Response( + jsonEncode(request.url.path.endsWith('/1') ? remote : [remote]), + 200, + ), + ), + ); + addTearDown(h.dispose); + await h.repository.synchronize(_accountId, allowWrites: false); + await h.controller.openNextcloudWorkspace(_accountId); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + for (var version = 1; version <= 2; version++) { + remote = serverNote(1, content: 'R$version', etag: 'v$version'); + await h.controller.refreshNextcloudNotes(); + expect(h.state.activeText, 'R$version'); + if (flushBetween) { + expect(await history.flushAll(h.state.documentBuffers), isTrue); + } + } + expect( + await h.controller.closeDocumentBuffer(h.state.activeBuffer!.id), + isTrue, + ); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + final store = h.container.read(localHistoryStoreProvider); + final snapshot = await store.load(); + final sources = await Future.wait( + snapshot.revisions.map( + (r) async => (await store.readRevision(r.id))!.source, + ), + ); + expect(sources, containsAll(['R0', 'R1', 'R2'])); + expect(snapshot.documents, hasLength(1)); + }); + } + + for (final dirty in [false, true]) { + test( + 'remote history restore navigates accounts safely from local workspace ' + '(dirty=$dirty)', + () async { + final h = await _Harness.create(); + addTearDown(h.dispose); + await h.controller.openNextcloudWorkspace(_accountId); + final buffer = h.state.activeBuffer!; + final store = h.container.read(localHistoryStoreProvider); + final captured = await store.capture( + LocalHistoryCaptureRequest( + remoteNote: buffer.remoteNote, + displayName: buffer.displayName, + source: 'retained remote source\n', + format: buffer.format, + capturedAt: DateTime.now().toUtc(), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + final revision = (await store.readRevision(captured.revision!.id))!; + final localPath = p.join(h.root.path, 'local.md'); + await File(localPath).writeAsString('local source'); + await h.controller.openPath(localPath); + if (dirty) h.controller.updateActiveText('unsaved local source'); + final current = await h.controller.localHistoryCurrentSource( + captured.document, + revision, + ); + expect(current.canRestore, isTrue); + expect( + await h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ), + !dirty, + ); + if (dirty) { + expect(h.state.activeBuffer!.filePath, localPath); + expect(h.state.activeText, 'unsaved local source'); + } else { + expect(h.state.workspace!.nextcloudAccountId, _accountId); + expect(h.state.activeBuffer!.remoteNote, buffer.remoteNote); + expect(h.state.activeText, revision.source); + expect( + h.repository.noteById(buffer.remoteNote!.localId)!.content, + revision.source, + ); + } + expect(await File(localPath).readAsString(), 'local source'); + }, + ); + } + test( 'remote refresh is an undoable boundary with an existing redo branch', () async { From a2cb98deb31ce0613e907488b432354dec3ed89e Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 23 Oct 2026 13:34:53 -0700 Subject: [PATCH 2/2] Add `WorkspaceFileDecodingException` to retain file snapshots on UTF-8 decoding failure --- .../local_history/local_history_store.dart | 63 +++-- lib/src/workspace/workspace_controller.dart | 11 +- .../workspace/workspace_file_snapshot.dart | 9 + lib/src/workspace/workspace_service.dart | 10 +- test/src/local_history_store_test.dart | 226 ++++++++++++++++++ test/src/local_history_workspace_test.dart | 178 ++++++++++++++ test/src/nextcloud_workspace_test.dart | 91 ++++++- 7 files changed, 561 insertions(+), 27 deletions(-) diff --git a/lib/src/local_history/local_history_store.dart b/lib/src/local_history/local_history_store.dart index d09a1827..9c1cd8bc 100644 --- a/lib/src/local_history/local_history_store.dart +++ b/lib/src/local_history/local_history_store.dart @@ -250,8 +250,10 @@ class FileLocalHistoryStore if (orphan != null) { // Finish only the exact retained capture whose record committed // before its index. Preserve later authoritative identity changes. + // The controller may retry a saved capture as a checkpoint. Its + // stable identity, source and format still describe the accepted + // capture; retain the original reason stored in the record. if (orphan.document.remoteNote != request.remoteNote || - existing.reason != request.reason || (request.path != null && existing.historicalPath != request.path && !orphan.document.historicalPaths.any( @@ -278,6 +280,21 @@ class FileLocalHistoryStore } _requireCaptureStillAccepted(request); index = index.withCapture(document, existing); + } + _requireCaptureStillAccepted(request); + if (request.reason == + LocalHistoryCaptureReason.beforeHistoryTraversal) { + return _reuseTraversalProtection( + root, + index, + document, + existing, + request, + policy, + indexBeforeRecovery: orphan == null ? null : originalIndex, + ); + } + if (orphan != null) { await _publishIndex(root, index); if (!_captureStillAccepted(request)) { await _publishIndex( @@ -298,18 +315,7 @@ class FileLocalHistoryStore captureId, request.capturedAt.toUtc(), ); - } - _requireCaptureStillAccepted(request); - if (request.reason == - LocalHistoryCaptureReason.beforeHistoryTraversal) { - return _reuseTraversalProtection( - root, - index, - document, - existing, - request, - policy, - ); + _requireCaptureStillAccepted(request); } return LocalHistoryCaptureResult( document: document, @@ -467,8 +473,9 @@ class FileLocalHistoryStore LocalHistoryDocument document, LocalHistoryRevisionSummary previous, LocalHistoryCaptureRequest request, - LocalHistoryPolicy policy, - ) async { + LocalHistoryPolicy policy, { + _Index? indexBeforeRecovery, + }) async { if (previous.storageBytes > policy.maximumBytes) { throw const LocalHistoryStorageException( 'The revision is larger than the Local History storage limit.', @@ -479,6 +486,23 @@ class FileLocalHistoryStore final previousRecord = identical(renewed, previous) ? null : await target.readAsString(); + Future cancelRenewal({required bool indexPublished}) async { + if (indexBeforeRecovery != null) { + await _publishIndex( + root, + indexBeforeRecovery.copyWith( + tombstones: {...indexBeforeRecovery.tombstones, previous.id}, + ), + ); + await _deleteFileBestEffort(target); + } else { + if (previousRecord != null) { + await _publishReplacingFile(target, previousRecord); + } + if (indexPublished) await _publishIndex(root, index); + } + } + _requireCaptureStillAccepted(request); if (previousRecord != null) { // Publish the complete record first so an index rebuild retains the @@ -495,9 +519,7 @@ class FileLocalHistoryStore renewed = renewed.copyWith(storageBytes: bytes); } if (!_captureStillAccepted(request)) { - if (previousRecord != null) { - await _publishReplacingFile(target, previousRecord); - } + await cancelRenewal(indexPublished: false); throw const LocalHistoryCaptureCancelled(); } final updated = index @@ -510,10 +532,7 @@ class FileLocalHistoryStore ); await _publishIndex(root, updated); if (!_captureStillAccepted(request)) { - if (previousRecord != null) { - await _publishReplacingFile(target, previousRecord); - } - await _publishIndex(root, index); + await cancelRenewal(indexPublished: true); throw const LocalHistoryCaptureCancelled(); } await _applyRetention( diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index f778dcb6..7b0f0cde 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -7801,7 +7801,8 @@ class WorkspaceController extends Notifier { current.id != targetId || current.filePath != targetPath || current.revision != targetRevision || - current.text != targetText) { + current.text != targetText || + (current.isRemote && current.readonly)) { return false; } final nextSelection = change == null @@ -8208,8 +8209,14 @@ class WorkspaceController extends Notifier { // that must not be destroyed under a successful protection result. if (await _service.pathExists(path)) return null; protection.diskSnapshots[path] = null; + } on WorkspaceFileDecodingException catch (error) { + // Unsupported bytes need no history revision, but their classification + // must remain current. A fresh read here could already be valid text. + protection.diskSnapshots[path] = error.snapshot; } on FormatException { - // Binary or invalid text content is outside Local History's scope. + // Without the failed read's snapshot, protection cannot establish that + // the unsupported source stayed unchanged before replacement. + return null; } } return await _isLocalHistoryLossProtectionCurrent(protection) diff --git a/lib/src/workspace/workspace_file_snapshot.dart b/lib/src/workspace/workspace_file_snapshot.dart index a8193eee..7f610be5 100644 --- a/lib/src/workspace/workspace_file_snapshot.dart +++ b/lib/src/workspace/workspace_file_snapshot.dart @@ -35,6 +35,15 @@ class WorkspaceFileSnapshot { } } +/// Carries the snapshot of the exact bytes that could not be decoded. +/// Destructive operations can validate that source without retaining it as text. +class WorkspaceFileDecodingException extends FormatException { + WorkspaceFileDecodingException(FormatException cause, this.snapshot) + : super(cause.message, cause.source, cause.offset); + + final WorkspaceFileSnapshot snapshot; +} + class WorkspaceFileLoad { const WorkspaceFileLoad({ required this.text, diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index d6ab1d2b..6d624ce2 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -1195,10 +1195,16 @@ class WorkspaceService { final file = File(path); final bytes = await file.readAsBytes(); final stat = await file.stat(); - final decoded = decodeUtf8Document(bytes); + final snapshot = _snapshotFromBytes(stat, bytes); + final DecodedUtf8Document decoded; + try { + decoded = decodeUtf8Document(bytes); + } on FormatException catch (error) { + throw WorkspaceFileDecodingException(error, snapshot); + } return WorkspaceFileLoad( text: decoded.text, - snapshot: _snapshotFromBytes(stat, bytes), + snapshot: snapshot, format: decoded.format, ); } diff --git a/test/src/local_history_store_test.dart b/test/src/local_history_store_test.dart index 6760089d..c123fd8b 100644 --- a/test/src/local_history_store_test.dart +++ b/test/src/local_history_store_test.dart @@ -114,6 +114,232 @@ void main() { } } + test( + 'follow-up orphan traversal validates renewal before retention', + () async { + final safe = await store.capture( + request('safe source', DateTime.utc(2026)), + policy, + ); + const captureId = 'near_limit_traversal_0001'; + final captured = await store.capture( + request( + '', + DateTime.utc(2026, 1, 2), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + final file = File( + p.join(root.path, 'revisions', captured.document.id, '$captureId.json'), + ); + final record = jsonDecode(await file.readAsString()) as Map; + final summary = record['revision'] as Map; + const encoder = JsonEncoder.withIndent(' '); + final targetBytes = policy.maximumBytes - 20; + var length = targetBytes - utf8.encode(encoder.convert(record)).length; + late String source; + late String encoded; + while (true) { + source = 'X' * length; + record['source'] = source; + summary['sourceLength'] = length; + summary['checksum'] = sourceChecksum(source); + encoded = encoder.convert(record); + final difference = targetBytes - utf8.encode(encoded).length; + if (difference == 0) break; + length += difference; + } + await file.writeAsString(encoded); + expect(await file.length(), 16777196); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + (index['revisions'] as List).removeWhere( + (dynamic revision) => revision['id'] == captureId, + ); + await indexFile.writeAsString(jsonEncode(index)); + final originalIndex = await indexFile.readAsString(); + final journal = File(p.join(root.path, 'tombstones.json')); + final originalTombstones = await journal.readAsString(); + final retry = request( + source, + DateTime.utc(2026, 1, 3), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ); + expect( + (await store.readRevision(safe.revision!.id))!.source, + 'safe source', + ); + + await expectLater( + store.capture(retry, policy), + throwsA(isA()), + ); + expect( + (await store.readRevision(safe.revision!.id))?.source, + 'safe source', + ); + expect(await store.readRevision(captureId), isNull); + expect(await indexFile.readAsString(), originalIndex); + expect(await journal.readAsString(), originalTombstones); + expect(await file.readAsString() == encoded, isTrue); + + final recovered = await store.capture( + retry, + LocalHistoryPolicy(maximumBytes: policy.maximumBytes + 8192), + ); + expect(recovered.revision!.id, captureId); + expect(recovered.revision!.protectedAt, retry.capturedAt); + expect((await store.readRevision(captureId))!.source == source, isTrue); + expect( + (await store.readRevision(safe.revision!.id))!.source, + 'safe source', + ); + expect((await store.load()).revisions, hasLength(2)); + }, + ); + + for (final afterIndex in [false, true]) { + test('follow-up cancelled orphan renewal stays retired ' + '(afterIndex=$afterIndex)', () async { + final safe = await store.capture( + request('safe', DateTime.utc(2026)), + policy, + ); + const captureId = 'cancelled_orphan_renewal_0001'; + final original = await store.capture( + request( + 'protected', + DateTime.utc(2026, 1, 2), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + final file = File( + p.join(root.path, 'revisions', original.document.id, '$captureId.json'), + ); + final originalRecord = await file.readAsBytes(); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + (index['revisions'] as List).removeWhere( + (dynamic revision) => revision['id'] == captureId, + ); + await indexFile.writeAsString(jsonEncode(index)); + bool accepted() { + if (afterIndex) { + return !((jsonDecode(indexFile.readAsStringSync()) + as Map)['revisions'] + as List) + .any((dynamic revision) => revision['id'] == captureId); + } + return (jsonDecode(file.readAsStringSync()) + as Map)['revision']['protectedAt'] == + null; + } + + await expectLater( + store.capture( + request( + 'protected', + DateTime.utc(2026, 1, 3), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + commitGuard: accepted, + ), + policy, + ), + throwsA(isA()), + ); + expect(await file.exists(), isFalse); + expect((await store.readRevision(safe.revision!.id))!.source, 'safe'); + await expectLater( + store.capture( + request( + 'protected', + DateTime.utc(2026, 1, 3), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ), + throwsA(isA()), + ); + // An interrupted cleanup must not allow index repair to revive it. + await file.writeAsBytes(originalRecord); + await indexFile.writeAsString('{damaged'); + expect((await store.load()).revisions.map((revision) => revision.id), [ + safe.revision!.id, + ]); + }); + } + + test( + 'follow-up orphan renewal retries after index publication failure', + () async { + final safe = await store.capture( + request('safe', DateTime.utc(2026)), + policy, + ); + const captureId = 'orphan_renewal_retry_0001'; + final original = await store.capture( + request( + 'protected', + DateTime.utc(2026, 1, 2), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ), + policy, + ); + final file = File( + p.join(root.path, 'revisions', original.document.id, '$captureId.json'), + ); + final indexFile = File(p.join(root.path, 'index.json')); + final index = jsonDecode(await indexFile.readAsString()) as Map; + (index['revisions'] as List).removeWhere( + (dynamic revision) => revision['id'] == captureId, + ); + await indexFile.writeAsString(jsonEncode(index)); + var fail = true; + final tested = FileLocalHistoryStore( + rootDirectory: () async => root, + createId: () { + if (fail && + (jsonDecode(file.readAsStringSync()) + as Map)['revision']['protectedAt'] != + null) { + fail = false; + throw const FileSystemException( + 'Injected index publication failure', + ); + } + return 'renewal_retry_identity_${++ids}'; + }, + ); + final retry = request( + 'protected', + DateTime.utc(2026, 1, 3), + captureId: captureId, + reason: LocalHistoryCaptureReason.beforeHistoryTraversal, + ); + await expectLater( + tested.capture(retry, policy), + throwsA(isA()), + ); + expect(fail, isFalse); + expect(await tested.readRevision(captureId), isNull); + expect((await tested.readRevision(safe.revision!.id))!.source, 'safe'); + final reopened = FileLocalHistoryStore(rootDirectory: () async => root); + final recovered = await reopened.capture(retry, policy); + expect(recovered.revision!.id, captureId); + expect(recovered.revision!.protectedAt, retry.capturedAt); + expect((await reopened.readRevision(captureId))!.source, 'protected'); + expect((await reopened.load()).revisions, hasLength(2)); + }, + ); + for (final damage in ['source', 'format', 'document', 'version']) { test( 'unindexed retry validates record $damage before publication', diff --git a/test/src/local_history_workspace_test.dart b/test/src/local_history_workspace_test.dart index 7b6d440d..d741f53c 100644 --- a/test/src/local_history_workspace_test.dart +++ b/test/src/local_history_workspace_test.dart @@ -345,6 +345,164 @@ void main() { }, ); + for (final restart in [false, true]) { + test('follow-up saved capture recovers through controller retry ' + '(restart=$restart)', () async { + final path = p.join(root.path, 'saved-retry.md'); + await File(path).writeAsString('baseline'); + final historyRoot = Directory(p.join(root.path, 'history')); + var fail = false; + var ids = 0; + final store = FileLocalHistoryStore( + rootDirectory: () async => historyRoot, + createId: () { + if (fail && + Directory( + p.join(historyRoot.path, 'revisions'), + ).listSync(recursive: true).whereType().any((file) { + final record = jsonDecode(file.readAsStringSync()) as Map; + return record['revision']['reason'] == 'saved' && + record['source'] == 'saved source'; + })) { + fail = false; + throw const FileSystemException( + 'Injected index publication failure', + '', + OSError('', 11), + ); + } + return 'saved_retry_identity_${++ids}'; + }, + ); + final h = await _harness( + store, + fileMonitor: _ControlledFileMonitor(), + sessionStore: MemoryDocumentSessionStore(), + recoveryStore: MemoryDocumentRecoveryStore(), + ); + await h.controller.openPath(path); + final history = h.container.read(localHistoryControllerProvider.notifier); + expect(await history.flushAll(h.state.documentBuffers), isTrue); + final documentId = history.documentIdForBuffer(h.state.activeBuffer!.id); + h.controller.updateActiveText('saved source'); + fail = true; + expect(await h.controller.saveActive(), isTrue); + expect(fail, isFalse); + expect(await File(path).readAsString(), 'saved source'); + final pending = history.pendingSnapshotForBuffer( + h.state.activeBuffer!.id, + )!; + final captureId = pending.captureId!; + expect(await store.readRevision(captureId), isNull); + + if (restart) { + await history.handleBufferClosed( + h.state.activeBuffer!.id, + historySettled: false, + ); + final retained = history.retainedDetachedCaptures + .map( + (capture) => LocalHistoryRetainedCapture.fromJson( + (jsonDecode(jsonEncode(capture.toJson())) as Map) + .cast(), + ), + ) + .toList(); + expect(retained, hasLength(1)); + h.container.dispose(); + final reopened = await _harness( + FileLocalHistoryStore(rootDirectory: () async => historyRoot), + fileMonitor: _ControlledFileMonitor(), + sessionStore: MemoryDocumentSessionStore(), + recoveryStore: MemoryDocumentRecoveryStore(), + ); + final retry = reopened.container.read( + localHistoryControllerProvider.notifier, + ); + retry.restoreRetainedDetachedCaptures(retained); + expect(await retry.flushAll(const []), isTrue); + expect(await retry.flushAll(const []), isTrue); + expect(retry.retainedDetachedCaptures, isEmpty); + } else { + expect(await history.flushBuffer(h.state.activeBuffer!), isTrue); + expect(await history.flushBuffer(h.state.activeBuffer!), isTrue); + expect( + history.pendingSnapshotForBuffer(h.state.activeBuffer!.id), + isNull, + ); + } + final recovered = (await store.readRevision(captureId))!; + expect(recovered.source, 'saved source'); + expect(recovered.summary.reason, LocalHistoryCaptureReason.saved); + expect(recovered.summary.documentId, documentId); + expect( + (await store.load()).revisions.where( + (revision) => revision.id == captureId, + ), + hasLength(1), + ); + }); + } + + for (final changeDuringStaging in [false, true]) { + test('follow-up deletion validates initially undecodable files ' + '(changeDuringStaging=$changeDuringStaging)', () async { + final anchor = File(p.join(root.path, 'anchor.md')); + await anchor.writeAsString('anchor'); + final store = _MutatingProtectionStore(); + final h = await _harness(store, fileMonitor: _ControlledFileMonitor()); + await h.controller.openPath(root.path); + final path = p.join(root.path, 'invalid.md'); + await File(path).writeAsBytes([0xff, 0xfe]); + if (changeDuringStaging) { + store.beforePathOperation = () async { + store.beforePathOperation = null; + await File(path).writeAsString('new valid source'); + }; + } + expect( + await h.controller.deleteWorkspaceEntity(path), + !changeDuringStaging, + ); + if (changeDuringStaging) { + expect(await File(path).readAsString(), 'new valid source'); + // A fresh operation can protect and delete the now-supported text. + expect(await h.controller.deleteWorkspaceEntity(path), isTrue); + final captured = (await store.load()).revisions.singleWhere( + (r) => r.historicalPath == path, + ); + expect( + (await store.readRevision(captured.id))!.source, + 'new valid source', + ); + } + expect(await File(path).exists(), isFalse); + }); + } + + test( + 'follow-up replacement protection detects reclassification during decoding', + () async { + final anchor = File(p.join(root.path, 'anchor.md')); + await anchor.writeAsString('anchor'); + final path = p.join(root.path, 'invalid.md'); + final service = _ReplaceUndecodableFileService(path); + final h = await _harness( + MemoryLocalHistoryStore(), + service: service, + fileMonitor: _ControlledFileMonitor(), + ); + await h.controller.openPath(root.path); + await File(path).writeAsBytes([0xff, 0xfe]); + expect( + await h.controller.protectPathsBeforeExternalReplacement([path]), + isFalse, + ); + expect(service.replaced, isTrue); + expect(await File(path).readAsString(), 'new valid source'); + }, + ); + for (final delayedFailure in [false, true]) { test( 'retention eviction does not discharge a later traversal protection (delayedFailure=$delayedFailure)', @@ -7586,6 +7744,26 @@ Future<_Harness> _harness( return _Harness(container, controller); } +class _ReplaceUndecodableFileService extends WorkspaceService { + _ReplaceUndecodableFileService(this.path); + + final String path; + var replaced = false; + + @override + Future loadTextWithSnapshot(String path) async { + try { + return await super.loadTextWithSnapshot(path); + } on FormatException { + if (p.equals(path, this.path) && !replaced) { + replaced = true; + await File(path).writeAsString('new valid source'); + } + rethrow; + } + } +} + class _MutatingProtectionStore extends MemoryLocalHistoryStore { Future Function(LocalHistoryCaptureRequest request)? afterCapture; Future Function()? beforePathOperation; diff --git a/test/src/nextcloud_workspace_test.dart b/test/src/nextcloud_workspace_test.dart index 1861f628..fc063357 100644 --- a/test/src/nextcloud_workspace_test.dart +++ b/test/src/nextcloud_workspace_test.dart @@ -27,6 +27,75 @@ import 'nextcloud_notes/notes_api_test.dart' show serverNote; const _accountId = 'ba6d02cd-42fe-4e14-a577-3e2c13822459'; void main() { + test( + 'follow-up remote restore rejects a read-only transition during protection', + () async { + var remote = serverNote(1, content: 'current source', etag: 'unchanged'); + var writes = 0; + final store = _BlockingRestoreHistoryStore(); + addTearDown(() { + if (!store.release.isCompleted) store.release.complete(); + }); + final h = await _Harness.create( + seedLocal: false, + historyStore: store, + client: MockClient((request) async { + if (request.method != 'GET') writes++; + return http.Response( + jsonEncode(request.url.path.endsWith('/1') ? remote : [remote]), + 200, + ); + }), + ); + addTearDown(h.dispose); + await h.repository.synchronize(_accountId, allowWrites: false); + await h.controller.openNextcloudWorkspace(_accountId); + await h.controller.refreshNextcloudNotes(); + final before = h.state.activeBuffer!; + final captured = await store.capture( + LocalHistoryCaptureRequest( + remoteNote: before.remoteNote, + displayName: before.displayName, + source: 'historical source', + format: before.format, + capturedAt: DateTime.now().toUtc(), + reason: LocalHistoryCaptureReason.saved, + ), + const LocalHistoryPolicy(), + ); + final revision = (await store.readRevision(captured.revision!.id))!; + final restoring = h.controller.restoreLocalHistoryRevision( + document: captured.document, + revision: revision, + ); + await store.started.future; + remote = serverNote( + 1, + content: 'current source', + etag: 'unchanged', + readonly: true, + ); + await h.controller.refreshNextcloudNotes(); + expect(h.state.activeBuffer!.readonly, isTrue); + expect(h.state.activeBuffer!.revision, before.revision); + expect(h.state.activeText, before.text); + store.release.complete(); + expect(await restoring, isFalse); + expect(h.state.activeBuffer!.readonly, isTrue); + expect(h.state.activeBuffer!.revision, before.revision); + expect(h.state.activeText, before.text); + expect( + h.state.activeBuffer!.editorState.undoState, + same(before.editorState.undoState), + ); + expect( + h.repository.noteById(before.remoteNote!.localId)!.content, + before.text, + ); + expect(writes, 0); + }, + ); + for (final flushBetween in [false, true]) { test('remote refresh retains displaced and incoming versions after close ' '(flushBetween=$flushBetween)', () async { @@ -691,6 +760,7 @@ class _Harness { bool seedLocal = true, bool deferPersistence = false, Duration? persistenceDelay, + LocalHistoryStore? historyStore, }) async { final directory = root ?? @@ -738,7 +808,9 @@ class _Harness { const Duration(hours: 1), ), localSettingsStoreProvider.overrideWithValue(_Settings()), - localHistoryStoreProvider.overrideWithValue(MemoryLocalHistoryStore()), + localHistoryStoreProvider.overrideWithValue( + historyStore ?? MemoryLocalHistoryStore(), + ), nextcloudNotesRepositoryProvider.overrideWith( (ref) async => repository, ), @@ -771,6 +843,23 @@ class _Harness { } } +class _BlockingRestoreHistoryStore extends MemoryLocalHistoryStore { + final started = Completer(); + final release = Completer(); + + @override + Future capture( + LocalHistoryCaptureRequest request, + LocalHistoryPolicy policy, + ) async { + if (request.reason == LocalHistoryCaptureReason.beforeRestore) { + if (!started.isCompleted) started.complete(); + await release.future; + } + return super.capture(request, policy); + } +} + class _Monitor extends WorkspaceFileMonitor { int starts = 0; @override