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
}