Skip to content

feat: wire up file sync window #64

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 2, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 42 additions & 7 deletions App/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Coder.Desktop.App.Models;
Expand Down Expand Up @@ -73,6 +74,8 @@ public async Task ExitApplication()
{
_handleWindowClosed = false;
Exit();
var syncController = _services.GetRequiredService<ISyncSessionController>();
await syncController.DisposeAsync();
var rpcController = _services.GetRequiredService<IRpcController>();
// TODO: send a StopRequest if we're connected???
await rpcController.DisposeAsync();
Expand All @@ -86,20 +89,52 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
// Passing in a CT with no cancellation is desired here, because
// the named pipe open will block until the pipe comes up.
_ = rpcController.Reconnect(CancellationToken.None);
// TODO: log
_ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t =>
{
#if DEBUG
if (t.Exception != null)
{
Debug.WriteLine(t.Exception);
Debugger.Break();
}
#endif
});

// Load the credentials in the background. Even though we pass a CT
// with no cancellation, the method itself will impose a timeout on the
// HTTP portion.
// Load the credentials in the background.
var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var credentialManager = _services.GetRequiredService<ICredentialManager>();
_ = credentialManager.LoadCredentials(CancellationToken.None);
_ = credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t =>
{
// TODO: log
#if DEBUG
if (t.Exception != null)
{
Debug.WriteLine(t.Exception);
Debugger.Break();
}
#endif
credentialManagerCts.Dispose();
}, CancellationToken.None);

// Initialize file sync.
var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var syncSessionController = _services.GetRequiredService<ISyncSessionController>();
_ = syncSessionController.Initialize(syncSessionCts.Token).ContinueWith(t =>
{
// TODO: log
#if DEBUG
if (t.IsCanceled || t.Exception != null) Debugger.Break();
#endif
syncSessionCts.Dispose();
}, CancellationToken.None);

// Prevent the TrayWindow from closing, just hide it.
var trayWindow = _services.GetRequiredService<TrayWindow>();
trayWindow.Closed += (sender, args) =>
trayWindow.Closed += (_, closedArgs) =>
{
if (!_handleWindowClosed) return;
args.Handled = true;
closedArgs.Handled = true;
trayWindow.AppWindow.Hide();
};
}
Expand Down
127 changes: 88 additions & 39 deletions App/Models/SyncSessionModel.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Coder.Desktop.App.Converters;
using Coder.Desktop.MutagenSdk.Proto.Synchronization;
using Coder.Desktop.MutagenSdk.Proto.Synchronization.Core;
using Coder.Desktop.MutagenSdk.Proto.Url;

namespace Coder.Desktop.App.Models;
Expand Down Expand Up @@ -48,7 +51,6 @@ public string Description(string linePrefix = "")
public class SyncSessionModel
{
public readonly string Identifier;
public readonly string Name;

public readonly string AlphaName;
public readonly string AlphaPath;
Expand All @@ -62,14 +64,24 @@ public class SyncSessionModel
public readonly SyncSessionModelEndpointSize AlphaSize;
public readonly SyncSessionModelEndpointSize BetaSize;

public readonly string[] Errors = [];
public readonly IReadOnlyList<string> Conflicts; // Conflict descriptions
public readonly ulong OmittedConflicts;
public readonly IReadOnlyList<string> Errors;

// If Paused is true, the session can be resumed. If false, the session can
// be paused.
public bool Paused => StatusCategory is SyncSessionStatusCategory.Paused or SyncSessionStatusCategory.Halted;

public string StatusDetails
{
get
{
var str = $"{StatusString} ({StatusCategory})\n\n{StatusDescription}";
foreach (var err in Errors) str += $"\n\n{err}";
var str = StatusString;
if (StatusCategory.ToString() != StatusString) str += $" ({StatusCategory})";
str += $"\n\n{StatusDescription}";
foreach (var err in Errors) str += $"\n\n-----\n\n{err}";
foreach (var conflict in Conflicts) str += $"\n\n-----\n\n{conflict}";
if (OmittedConflicts > 0) str += $"\n\n-----\n\n{OmittedConflicts:N0} conflicts omitted";
return str;
}
}
Expand All @@ -84,41 +96,9 @@ public string SizeDetails
}
}

// TODO: remove once we process sessions from the mutagen RPC
public SyncSessionModel(string alphaPath, string betaName, string betaPath,
SyncSessionStatusCategory statusCategory,
string statusString, string statusDescription, string[] errors)
{
Identifier = "TODO";
Name = "TODO";

AlphaName = "Local";
AlphaPath = alphaPath;
BetaName = betaName;
BetaPath = betaPath;
StatusCategory = statusCategory;
StatusString = statusString;
StatusDescription = statusDescription;
AlphaSize = new SyncSessionModelEndpointSize
{
SizeBytes = (ulong)new Random().Next(0, 1000000000),
FileCount = (ulong)new Random().Next(0, 10000),
DirCount = (ulong)new Random().Next(0, 10000),
};
BetaSize = new SyncSessionModelEndpointSize
{
SizeBytes = (ulong)new Random().Next(0, 1000000000),
FileCount = (ulong)new Random().Next(0, 10000),
DirCount = (ulong)new Random().Next(0, 10000),
};

Errors = errors;
}

public SyncSessionModel(State state)
{
Identifier = state.Session.Identifier;
Name = state.Session.Name;

(AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha);
(BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta);
Expand Down Expand Up @@ -220,6 +200,9 @@ public SyncSessionModel(State state)
StatusDescription = "The session has conflicts that need to be resolved.";
}

Conflicts = state.Conflicts.Select(ConflictToString).ToList();
OmittedConflicts = state.ExcludedConflicts;

AlphaSize = new SyncSessionModelEndpointSize
{
SizeBytes = state.AlphaState.TotalFileSize,
Expand All @@ -235,9 +218,24 @@ public SyncSessionModel(State state)
SymlinkCount = state.BetaState.SymbolicLinks,
};

// TODO: accumulate errors, there seems to be multiple fields they can
// come from
if (!string.IsNullOrWhiteSpace(state.LastError)) Errors = [state.LastError];
List<string> errors = [];
if (!string.IsNullOrWhiteSpace(state.LastError)) errors.Add($"Last error:\n {state.LastError}");
// TODO: scan problems + transition problems + omissions should probably be fields
foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Alpha scan problem: {scanProblem}");
if (state.AlphaState.ExcludedScanProblems > 0)
errors.Add($"Alpha scan problems omitted: {state.AlphaState.ExcludedScanProblems}");
foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Beta scan problem: {scanProblem}");
if (state.BetaState.ExcludedScanProblems > 0)
errors.Add($"Beta scan problems omitted: {state.BetaState.ExcludedScanProblems}");
foreach (var transitionProblem in state.AlphaState.TransitionProblems)
errors.Add($"Alpha transition problem: {transitionProblem}");
if (state.AlphaState.ExcludedTransitionProblems > 0)
errors.Add($"Alpha transition problems omitted: {state.AlphaState.ExcludedTransitionProblems}");
foreach (var transitionProblem in state.AlphaState.TransitionProblems)
errors.Add($"Beta transition problem: {transitionProblem}");
if (state.BetaState.ExcludedTransitionProblems > 0)
errors.Add($"Beta transition problems omitted: {state.BetaState.ExcludedTransitionProblems}");
Errors = errors;
}

private static (string, string) NameAndPathFromUrl(URL url)
Expand All @@ -251,4 +249,55 @@ private static (string, string) NameAndPathFromUrl(URL url)

return (name, path);
}

private static string ConflictToString(Conflict conflict)
{
string? friendlyProblem = null;
if (conflict.AlphaChanges.Count == 1 && conflict.BetaChanges.Count == 1 &&
conflict.AlphaChanges[0].Old == null &&
conflict.BetaChanges[0].Old == null &&
conflict.AlphaChanges[0].New != null &&
conflict.BetaChanges[0].New != null)
friendlyProblem =
"An entry was created on both endpoints and they do not match. You can resolve this conflict by deleting one of the entries on either side.";

var str = $"Conflict at path '{conflict.Root}':";
foreach (var change in conflict.AlphaChanges)
str += $"\n (alpha) {ChangeToString(change)}";
foreach (var change in conflict.BetaChanges)
str += $"\n (beta) {ChangeToString(change)}";
if (friendlyProblem != null)
str += $"\n\n{friendlyProblem}";

return str;
}

private static string ChangeToString(Change change)
{
return $"{change.Path} ({EntryToString(change.Old)} -> {EntryToString(change.New)})";
}

private static string EntryToString(Entry? entry)
{
if (entry == null) return "<non-existent>";
var str = entry.Kind.ToString();
switch (entry.Kind)
{
case EntryKind.Directory:
str += $" ({entry.Contents.Count} entries)";
break;
case EntryKind.File:
var digest = BitConverter.ToString(entry.Digest.ToByteArray()).Replace("-", "").ToLower();
str += $" ({digest}, executable: {entry.Executable})";
break;
case EntryKind.SymbolicLink:
str += $" (target: {entry.Target})";
break;
case EntryKind.Problematic:
str += $" ({entry.Problem})";
break;
}

return str;
}
}
Loading
Loading