From 73c9f9c1aca8d55c98fb3375e34cbc7bd0de353d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Polykanine?= Date: Wed, 28 Jan 2026 23:04:40 +0100 Subject: [PATCH] Add per-file progress --- CLAUDE.md | 34 ++-- README.md | 7 + examples/BasicSyncExample.cs | 5 + src/SharpSync/Core/FileProgressEventArgs.cs | 69 ++++++++ src/SharpSync/Core/FileTransferOperation.cs | 16 ++ src/SharpSync/Core/ISyncEngine.cs | 34 +++- src/SharpSync/Core/ISyncStorage.cs | 11 ++ src/SharpSync/Storage/LocalFileStorage.cs | 17 ++ src/SharpSync/Sync/SyncEngine.cs | 38 +++++ tests/SharpSync.Tests/Sync/SyncEngineTests.cs | 160 ++++++++++++++++++ 10 files changed, 376 insertions(+), 15 deletions(-) create mode 100644 src/SharpSync/Core/FileProgressEventArgs.cs create mode 100644 src/SharpSync/Core/FileTransferOperation.cs diff --git a/CLAUDE.md b/CLAUDE.md index cfc62a0..18b044d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -243,7 +243,7 @@ SharpSync serves as the core sync library for **Nimbus**, an accessible Nextclou | Feature | Status | Notes | |---------|--------|-------| -| Progress events | Excellent | `ProgressChanged` event with percentage, item counts, current file | +| Progress events | Excellent | `ProgressChanged` (item-level) and `FileProgressChanged` (per-file byte-level) | | Conflict events | Excellent | `ConflictDetected` with rich `ConflictAnalysis` data | | Sync preview | Excellent | `GetSyncPlanAsync()` returns detailed plan before execution | | OAuth2 abstraction | Good | `IOAuth2Provider` interface for app-specific implementation | @@ -275,12 +275,21 @@ var engine = new SyncEngine(localStorage, remoteStorage, database); // 3. Wire up UI binding via events engine.ProgressChanged += (s, e) => { Dispatcher.Invoke(() => { - ProgressBar.Value = e.Progress.Percentage; + OverallProgressBar.Value = e.Progress.Percentage; StatusLabel.Text = $"Syncing: {e.Progress.CurrentItem}"; ItemCountLabel.Text = $"{e.Progress.ProcessedItems}/{e.Progress.TotalItems}"; }); }; +// 3b. Wire up per-file transfer progress for detailed UI +engine.FileProgressChanged += (s, e) => { + Dispatcher.Invoke(() => { + FileProgressBar.Value = e.PercentComplete; + FileProgressLabel.Text = $"{e.Path}: {e.BytesTransferred / 1024}KB / {e.TotalBytes / 1024}KB"; + TransferTypeLabel.Text = e.Operation == FileTransferOperation.Upload ? "Uploading" : "Downloading"; + }); +}; + // 4. Implement conflict resolution with UI dialogs var resolver = new SmartConflictResolver( conflictHandler: async (analysis, ct) => { @@ -361,15 +370,17 @@ var deleted = await engine.ClearOperationHistoryAsync(DateTime.UtcNow.AddDays(-3 | GetSyncPlanAsync integration | `GetSyncPlanAsync()` now incorporates pending changes from notifications | | Activity history | `GetRecentOperationsAsync()` - query completed operations for activity feed | | History cleanup | `ClearOperationHistoryAsync()` - purge old operation records | +| Per-file progress | `FileProgressChanged` event on `ISyncEngine` - byte-level progress for individual file transfers | ### Required SharpSync API Additions (v1.0) These APIs are required for v1.0 release to support Nimbus desktop client: -**Progress & History:** -1. Per-file progress events (currently only per-sync-operation) - **✅ Completed:** +- `FileProgressChanged` event on `ISyncEngine` - Per-file byte-level progress during uploads/downloads +- `FileProgressEventArgs` - Per-file progress data with path, bytes transferred, total bytes, and operation type +- `FileTransferOperation` enum - Upload/Download operation type for per-file progress +- `ISyncStorage.ProgressChanged` - Standardized progress event on the storage interface - OCIS TUS 1.0.0 protocol - Resumable uploads for OCIS servers with chunked transfer and fallback - `GetRecentOperationsAsync()` - Operation history for activity feed with time filtering - `ClearOperationHistoryAsync()` - Cleanup old operation history entries @@ -401,15 +412,13 @@ These APIs are required for v1.0 release to support Nimbus desktop client: | Core sync engine | 9/10 | Production-ready, well-tested | | Nextcloud WebDAV | 9/10 | Full support including OCIS TUS protocol | | OAuth2 abstraction | 9/10 | Clean interface, Nimbus implements | -| UI binding (events) | 9/10 | Excellent progress/conflict events | +| UI binding (events) | 10/10 | Per-file byte-level progress, item-level progress, conflict events | | Conflict resolution | 9/10 | Rich analysis, extensible callbacks | | Selective sync | 10/10 | Complete: folder/file/incremental sync, batch notifications, rename tracking | | Pause/Resume | 10/10 | Fully implemented with graceful pause points | | Desktop integration hooks | 10/10 | Virtual file callback, bandwidth throttling, pause/resume, pending operations | -**Current Overall: 9.5/10** - Production-ready with comprehensive desktop client APIs - -**Target for v1.0: 9.7/10** - Per-file progress remaining +**Current Overall: 10/10** - Production-ready with comprehensive desktop client APIs including per-file progress ## Version 1.0 Release Readiness @@ -461,11 +470,7 @@ All critical items have been resolved. - BenchmarkDotNet suite for sync operations - Helps track performance regressions -5. **Per-file Progress Events** - - Currently only per-sync-operation progress - - Would improve UI granularity for large file transfers - -6. **Advanced Filtering (Regex Support)** +5. **Advanced Filtering (Regex Support)** - Current glob patterns are sufficient for most use cases ### 📊 Quality Metrics for v1.0 @@ -499,4 +504,5 @@ All critical items have been resolved. - ✅ FileSystemWatcher integration (`NotifyLocalChangeAsync()`, `NotifyLocalChangesAsync()`, `NotifyLocalRenameAsync()`) - ✅ Pending operations query (`GetPendingOperationsAsync()`) - ✅ Activity history (`GetRecentOperationsAsync()`, `ClearOperationHistoryAsync()`) +- ✅ Per-file progress events (`FileProgressChanged` on `ISyncEngine`, `FileProgressEventArgs`, `FileTransferOperation`) - ✅ Examples directory with working samples diff --git a/README.md b/README.md index 974b3a4..137d5a6 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,19 @@ else ### With Progress Reporting ```csharp +// Item-level progress (overall sync progress) engine.ProgressChanged += (sender, e) => { Console.WriteLine($"[{e.Progress.Percentage:F1}%] {e.Progress.CurrentItem}"); Console.WriteLine($" {e.Progress.ProcessedItems}/{e.Progress.TotalItems} items"); }; +// Per-file byte-level progress (individual file transfer progress) +engine.FileProgressChanged += (sender, e) => +{ + Console.WriteLine($" {e.Operation}: {e.Path} - {e.PercentComplete}% ({e.BytesTransferred}/{e.TotalBytes} bytes)"); +}; + var result = await engine.SynchronizeAsync(); ``` diff --git a/examples/BasicSyncExample.cs b/examples/BasicSyncExample.cs index 996907b..3b50022 100644 --- a/examples/BasicSyncExample.cs +++ b/examples/BasicSyncExample.cs @@ -59,6 +59,11 @@ public static async Task BasicSyncAsync() { Console.WriteLine($"[{e.Progress.Percentage:F0}%] {e.Operation}: {e.Progress.CurrentItem}"); }; + // Per-file byte-level progress for large file transfers + syncEngine.FileProgressChanged += (sender, e) => { + Console.WriteLine($" {e.Operation}: {e.Path} - {e.PercentComplete}% ({e.BytesTransferred}/{e.TotalBytes} bytes)"); + }; + syncEngine.ConflictDetected += (sender, e) => { Console.WriteLine($"Conflict: {e.Path}"); }; diff --git a/src/SharpSync/Core/FileProgressEventArgs.cs b/src/SharpSync/Core/FileProgressEventArgs.cs new file mode 100644 index 0000000..0e14c30 --- /dev/null +++ b/src/SharpSync/Core/FileProgressEventArgs.cs @@ -0,0 +1,69 @@ +namespace Oire.SharpSync.Core; + +/// +/// Event arguments for per-file transfer progress during sync operations. +/// +/// +/// +/// This event provides byte-level progress for individual file transfers, +/// allowing UI applications to display detailed progress bars for large files. +/// +/// +/// This complements which reports item-level progress +/// (number of files processed). Use both events together for comprehensive progress reporting: +/// +/// : Overall sync progress (X of Y files) +/// : Current file progress (X of Y bytes) +/// +/// +/// +/// +/// +/// engine.FileProgressChanged += (sender, e) => { +/// // Update per-file progress bar +/// FileProgressBar.Value = e.PercentComplete; +/// FileProgressLabel.Text = $"{e.Path}: {e.BytesTransferred / 1024}KB / {e.TotalBytes / 1024}KB"; +/// OperationLabel.Text = e.Operation == FileTransferOperation.Upload ? "Uploading" : "Downloading"; +/// }; +/// +/// +public class FileProgressEventArgs: EventArgs { + /// + /// Gets the relative path of the file being transferred. + /// + public string Path { get; } + + /// + /// Gets the number of bytes transferred so far. + /// + public long BytesTransferred { get; } + + /// + /// Gets the total number of bytes to transfer. + /// + public long TotalBytes { get; } + + /// + /// Gets the type of transfer operation (upload or download). + /// + public FileTransferOperation Operation { get; } + + /// + /// Gets the percentage complete (0-100). + /// + public int PercentComplete => TotalBytes > 0 ? (int)(BytesTransferred * 100 / TotalBytes) : 0; + + /// + /// Initializes a new instance of the class. + /// + /// The relative path of the file being transferred + /// The number of bytes transferred so far + /// The total number of bytes to transfer + /// The type of transfer operation + public FileProgressEventArgs(string path, long bytesTransferred, long totalBytes, FileTransferOperation operation) { + Path = path; + BytesTransferred = bytesTransferred; + TotalBytes = totalBytes; + Operation = operation; + } +} diff --git a/src/SharpSync/Core/FileTransferOperation.cs b/src/SharpSync/Core/FileTransferOperation.cs new file mode 100644 index 0000000..f7062f8 --- /dev/null +++ b/src/SharpSync/Core/FileTransferOperation.cs @@ -0,0 +1,16 @@ +namespace Oire.SharpSync.Core; + +/// +/// Type of file transfer operation for per-file progress reporting. +/// +public enum FileTransferOperation { + /// + /// Uploading a file to remote storage. + /// + Upload, + + /// + /// Downloading a file from remote storage. + /// + Download +} diff --git a/src/SharpSync/Core/ISyncEngine.cs b/src/SharpSync/Core/ISyncEngine.cs index de5c729..98fa024 100644 --- a/src/SharpSync/Core/ISyncEngine.cs +++ b/src/SharpSync/Core/ISyncEngine.cs @@ -26,10 +26,42 @@ namespace Oire.SharpSync.Core; /// public interface ISyncEngine: IDisposable { /// - /// Event raised to report synchronization progress + /// Event raised to report synchronization progress at the item level. /// + /// + /// This event reports overall sync progress (number of files processed out of total). + /// For per-file byte-level progress during large file transfers, subscribe to + /// instead. + /// event EventHandler? ProgressChanged; + /// + /// Event raised to report per-file transfer progress during uploads and downloads. + /// + /// + /// + /// This event provides byte-level progress for individual file transfers, + /// allowing UI applications to display detailed progress for large files. + /// + /// + /// This complements which reports item-level progress + /// (X of Y files). Use both events for comprehensive progress reporting: + /// + /// : Overall sync progress (X of Y files) + /// : Current file progress (X of Y bytes) + /// + /// + /// + /// + /// + /// engine.FileProgressChanged += (sender, e) => { + /// FileProgressBar.Value = e.PercentComplete; + /// FileProgressLabel.Text = $"{e.Path}: {e.BytesTransferred / 1024}KB / {e.TotalBytes / 1024}KB"; + /// }; + /// + /// + event EventHandler? FileProgressChanged; + /// /// Event raised when a file conflict is detected /// diff --git a/src/SharpSync/Core/ISyncStorage.cs b/src/SharpSync/Core/ISyncStorage.cs index e1bd184..a455f01 100644 --- a/src/SharpSync/Core/ISyncStorage.cs +++ b/src/SharpSync/Core/ISyncStorage.cs @@ -1,9 +1,20 @@ +using Oire.SharpSync.Storage; + namespace Oire.SharpSync.Core; /// /// Represents a storage backend for synchronization (local filesystem, WebDAV, etc.) /// public interface ISyncStorage { + /// + /// Event raised to report transfer progress for large files. + /// + /// + /// Storage implementations should raise this event during file uploads and downloads + /// to report byte-level progress. This enables UIs to display per-file progress bars. + /// + event EventHandler? ProgressChanged; + /// /// Gets the storage type /// diff --git a/src/SharpSync/Storage/LocalFileStorage.cs b/src/SharpSync/Storage/LocalFileStorage.cs index 5619e30..efccf28 100644 --- a/src/SharpSync/Storage/LocalFileStorage.cs +++ b/src/SharpSync/Storage/LocalFileStorage.cs @@ -7,6 +7,23 @@ namespace Oire.SharpSync.Storage; /// Local filesystem storage implementation /// public class LocalFileStorage: ISyncStorage { + /// + /// Event raised to report transfer progress for file operations. + /// + /// + /// + /// This event is not typically raised by local file storage because local + /// filesystem operations are very fast compared to network transfers. + /// + /// + /// The event is implemented to satisfy the interface, + /// allowing consistent handling across all storage types. + /// + /// +#pragma warning disable CS0067 // Event is never used (intentional - local storage doesn't report progress) + public event EventHandler? ProgressChanged; +#pragma warning restore CS0067 + /// /// Gets the storage type (always returns ) /// diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index 1efbfa9..0d0fead 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -96,6 +96,21 @@ public class SyncEngine: ISyncEngine { /// public event EventHandler? ProgressChanged; + /// + /// Event raised to report per-file transfer progress during uploads and downloads. + /// + /// + /// + /// This event provides byte-level progress for individual file transfers, + /// allowing UI applications to display detailed progress for large files. + /// + /// + /// This complements which reports item-level progress + /// (X of Y files). Use both events for comprehensive progress reporting. + /// + /// + public event EventHandler? FileProgressChanged; + /// /// Event raised when a conflict is detected /// @@ -142,6 +157,10 @@ public SyncEngine( _changeDetectionWindow = changeDetectionWindow ?? TimeSpan.FromSeconds(2); _syncSemaphore = new SemaphoreSlim(1, 1); + + // Subscribe to storage progress events for per-file progress reporting + _localStorage.ProgressChanged += OnStorageProgressChanged; + _remoteStorage.ProgressChanged += OnStorageProgressChanged; } @@ -1375,6 +1394,21 @@ private void RaiseProgress(SyncProgress progress, SyncOperation operation) { ProgressChanged?.Invoke(this, new SyncProgressEventArgs(progress, progress.CurrentItem, operation)); } + private void RaiseFileProgress(string path, long bytesTransferred, long totalBytes, FileTransferOperation operation) { + FileProgressChanged?.Invoke(this, new FileProgressEventArgs(path, bytesTransferred, totalBytes, operation)); + } + + /// + /// Handles storage progress events and forwards them to the FileProgressChanged event. + /// + private void OnStorageProgressChanged(object? sender, StorageProgressEventArgs e) { + var operation = e.Operation == StorageOperation.Upload + ? FileTransferOperation.Upload + : FileTransferOperation.Download; + + RaiseFileProgress(e.Path, e.BytesTransferred, e.TotalBytes, operation); + } + /// /// Gets synchronization database statistics including file counts and sizes. /// @@ -2169,6 +2203,10 @@ public async Task ClearOperationHistoryAsync(DateTime olderThan, Cancellati /// public void Dispose() { if (!_disposed) { + // Unsubscribe from storage progress events + _localStorage.ProgressChanged -= OnStorageProgressChanged; + _remoteStorage.ProgressChanged -= OnStorageProgressChanged; + // Resume any paused operation so it can exit cleanly _pauseEvent.Set(); _currentSyncCts?.Cancel(); diff --git a/tests/SharpSync.Tests/Sync/SyncEngineTests.cs b/tests/SharpSync.Tests/Sync/SyncEngineTests.cs index 93d3851..ba3744e 100644 --- a/tests/SharpSync.Tests/Sync/SyncEngineTests.cs +++ b/tests/SharpSync.Tests/Sync/SyncEngineTests.cs @@ -1637,4 +1637,164 @@ public async Task GetSyncPlanAsync_PendingChangesNotDuplicated() { } #endregion + + #region FileProgressChanged Tests + + [Fact] + public void FileProgressChanged_SubscribesToStorageProgressEvents() { + // Arrange - Create a storage wrapper that fires progress events + using var progressStorage = new ProgressFiringStorage(_remoteRootPath); + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var engine = new SyncEngine(_localStorage, progressStorage, _database, filter, conflictResolver); + + var receivedEvents = new List(); + engine.FileProgressChanged += (sender, e) => receivedEvents.Add(e); + + // Act - Simulate storage raising a progress event + progressStorage.SimulateProgress("test.txt", 512, 1024, StorageOperation.Upload); + + // Assert + Assert.Single(receivedEvents); + Assert.Equal("test.txt", receivedEvents[0].Path); + Assert.Equal(512, receivedEvents[0].BytesTransferred); + Assert.Equal(1024, receivedEvents[0].TotalBytes); + Assert.Equal(FileTransferOperation.Upload, receivedEvents[0].Operation); + Assert.Equal(50, receivedEvents[0].PercentComplete); + } + + [Fact] + public void FileProgressChanged_MapsDownloadOperationCorrectly() { + // Arrange + using var progressStorage = new ProgressFiringStorage(_remoteRootPath); + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var engine = new SyncEngine(_localStorage, progressStorage, _database, filter, conflictResolver); + + var receivedEvents = new List(); + engine.FileProgressChanged += (sender, e) => receivedEvents.Add(e); + + // Act + progressStorage.SimulateProgress("large.zip", 750, 1000, StorageOperation.Download); + + // Assert + Assert.Single(receivedEvents); + Assert.Equal(FileTransferOperation.Download, receivedEvents[0].Operation); + Assert.Equal(75, receivedEvents[0].PercentComplete); + } + + [Fact] + public void FileProgressChanged_Dispose_UnsubscribesFromStorageEvents() { + // Arrange + using var progressStorage = new ProgressFiringStorage(_remoteRootPath); + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + var engine = new SyncEngine(_localStorage, progressStorage, _database, filter, conflictResolver); + + var receivedEvents = new List(); + engine.FileProgressChanged += (sender, e) => receivedEvents.Add(e); + + // Act - Dispose should unsubscribe + engine.Dispose(); + progressStorage.SimulateProgress("test.txt", 100, 100, StorageOperation.Upload); + + // Assert - No events after dispose + Assert.Empty(receivedEvents); + } + + [Fact] + public void FileProgressChanged_BothStorages_ReceivesEventsFromBoth() { + // Arrange + using var localProgress = new ProgressFiringStorage(_localRootPath); + using var remoteProgress = new ProgressFiringStorage(_remoteRootPath); + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var engine = new SyncEngine(localProgress, remoteProgress, _database, filter, conflictResolver); + + var receivedEvents = new List(); + engine.FileProgressChanged += (sender, e) => receivedEvents.Add(e); + + // Act - Fire from both storages + localProgress.SimulateProgress("local.txt", 100, 200, StorageOperation.Download); + remoteProgress.SimulateProgress("remote.txt", 300, 400, StorageOperation.Upload); + + // Assert + Assert.Equal(2, receivedEvents.Count); + Assert.Equal("local.txt", receivedEvents[0].Path); + Assert.Equal("remote.txt", receivedEvents[1].Path); + } + + [Fact] + public void FileProgressChanged_NoSubscribers_DoesNotThrow() { + // Arrange + using var progressStorage = new ProgressFiringStorage(_remoteRootPath); + var filter = new SyncFilter(); + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseLocal); + using var engine = new SyncEngine(_localStorage, progressStorage, _database, filter, conflictResolver); + + // Act & Assert - Should not throw when no subscribers + progressStorage.SimulateProgress("test.txt", 100, 100, StorageOperation.Upload); + } + + [Fact] + public void FileProgressEventArgs_ZeroTotalBytes_ReturnsZeroPercent() { + // Arrange & Act + var args = new FileProgressEventArgs("test.txt", 0, 0, FileTransferOperation.Upload); + + // Assert + Assert.Equal(0, args.PercentComplete); + } + + [Fact] + public void FileProgressEventArgs_FullTransfer_Returns100Percent() { + // Arrange & Act + var args = new FileProgressEventArgs("test.txt", 1024, 1024, FileTransferOperation.Download); + + // Assert + Assert.Equal(100, args.PercentComplete); + } + + /// + /// A test-only storage wrapper that delegates to LocalFileStorage and allows + /// simulating storage progress events. + /// + private sealed class ProgressFiringStorage: ISyncStorage, IDisposable { + private readonly LocalFileStorage _inner; + + public event EventHandler? ProgressChanged; + public StorageType StorageType => _inner.StorageType; + public string RootPath => _inner.RootPath; + + public ProgressFiringStorage(string rootPath) { + _inner = new LocalFileStorage(rootPath); + } + + public void SimulateProgress(string path, long bytesTransferred, long totalBytes, StorageOperation operation) { + ProgressChanged?.Invoke(this, new StorageProgressEventArgs { + Path = path, + BytesTransferred = bytesTransferred, + TotalBytes = totalBytes, + Operation = operation, + PercentComplete = totalBytes > 0 ? (int)(bytesTransferred * 100 / totalBytes) : 0 + }); + } + + public Task> ListItemsAsync(string path, CancellationToken ct = default) => _inner.ListItemsAsync(path, ct); + public Task GetItemAsync(string path, CancellationToken ct = default) => _inner.GetItemAsync(path, ct); + public Task ReadFileAsync(string path, CancellationToken ct = default) => _inner.ReadFileAsync(path, ct); + public Task WriteFileAsync(string path, Stream content, CancellationToken ct = default) => _inner.WriteFileAsync(path, content, ct); + public Task CreateDirectoryAsync(string path, CancellationToken ct = default) => _inner.CreateDirectoryAsync(path, ct); + public Task DeleteAsync(string path, CancellationToken ct = default) => _inner.DeleteAsync(path, ct); + public Task MoveAsync(string source, string target, CancellationToken ct = default) => _inner.MoveAsync(source, target, ct); + public Task ExistsAsync(string path, CancellationToken ct = default) => _inner.ExistsAsync(path, ct); + public Task GetStorageInfoAsync(CancellationToken ct = default) => _inner.GetStorageInfoAsync(ct); + public Task ComputeHashAsync(string path, CancellationToken ct = default) => _inner.ComputeHashAsync(path, ct); + public Task TestConnectionAsync(CancellationToken ct = default) => _inner.TestConnectionAsync(ct); + + public void Dispose() { + // LocalFileStorage does not implement IDisposable, nothing to dispose + } + } + + #endregion }