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 2 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
224 changes: 188 additions & 36 deletions App/Models/SyncSessionModel.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
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 @@ -45,6 +49,159 @@
}
}

public enum SyncSessionModelEntryKind
{
Unknown,
Directory,
File,
SymbolicLink,
Untracked,
Problematic,
PhantomDirectory,
}

public sealed class SyncSessionModelEntry
{
public readonly SyncSessionModelEntryKind Kind;

// For Kind == Directory only.
public readonly ReadOnlyDictionary<string, SyncSessionModelEntry> Contents;

// For Kind == File only.
public readonly string Digest = "";
public readonly bool Executable;

// For Kind = SymbolicLink only.
public readonly string Target = "";

// For Kind = Problematic only.
public readonly string Problem = "";

public SyncSessionModelEntry(Entry protoEntry)

Check warning on line 80 in App/Models/SyncSessionModel.cs

View workflow job for this annotation

GitHub Actions / test

Non-nullable field 'Contents' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 80 in App/Models/SyncSessionModel.cs

View workflow job for this annotation

GitHub Actions / test

Non-nullable field 'Contents' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 80 in App/Models/SyncSessionModel.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field 'Contents' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 80 in App/Models/SyncSessionModel.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field 'Contents' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
{
Kind = protoEntry.Kind switch
{
EntryKind.Directory => SyncSessionModelEntryKind.Directory,
EntryKind.File => SyncSessionModelEntryKind.File,
EntryKind.SymbolicLink => SyncSessionModelEntryKind.SymbolicLink,
EntryKind.Untracked => SyncSessionModelEntryKind.Untracked,
EntryKind.Problematic => SyncSessionModelEntryKind.Problematic,
EntryKind.PhantomDirectory => SyncSessionModelEntryKind.PhantomDirectory,
_ => SyncSessionModelEntryKind.Unknown,
};

switch (Kind)
{
case SyncSessionModelEntryKind.Directory:
{
var contents = new Dictionary<string, SyncSessionModelEntry>();
foreach (var (key, value) in protoEntry.Contents)
contents[key] = new SyncSessionModelEntry(value);
Contents = new ReadOnlyDictionary<string, SyncSessionModelEntry>(contents);
break;
}
case SyncSessionModelEntryKind.File:
Digest = BitConverter.ToString(protoEntry.Digest.ToByteArray()).Replace("-", "").ToLower();
Executable = protoEntry.Executable;
break;
case SyncSessionModelEntryKind.SymbolicLink:
Target = protoEntry.Target;
break;
case SyncSessionModelEntryKind.Problematic:
Problem = protoEntry.Problem;
break;
}
}

public new string ToString()
{
var str = Kind.ToString();
switch (Kind)
{
case SyncSessionModelEntryKind.Directory:
str += $" ({Contents.Count} entries)";
break;
case SyncSessionModelEntryKind.File:
str += $" ({Digest}, executable: {Executable})";
break;
case SyncSessionModelEntryKind.SymbolicLink:
str += $" (target: {Target})";
break;
case SyncSessionModelEntryKind.Problematic:
str += $" ({Problem})";
break;
}

return str;
}
}

public sealed class SyncSessionModelConflictChange
{
public readonly string Path; // relative to sync root

// null means non-existent:
public readonly SyncSessionModelEntry? Old;
public readonly SyncSessionModelEntry? New;

public SyncSessionModelConflictChange(Change protoChange)
{
Path = protoChange.Path;
Old = protoChange.Old != null ? new SyncSessionModelEntry(protoChange.Old) : null;
New = protoChange.New != null ? new SyncSessionModelEntry(protoChange.New) : null;
}

public new string ToString()
{
const string nonExistent = "<non-existent>";
var oldStr = Old != null ? Old.ToString() : nonExistent;
var newStr = New != null ? New.ToString() : nonExistent;
return $"{Path} ({oldStr} -> {newStr})";
}
}

public sealed class SyncSessionModelConflict
{
public readonly string Root; // relative to sync root
public readonly List<SyncSessionModelConflictChange> AlphaChanges;
public readonly List<SyncSessionModelConflictChange> BetaChanges;

public SyncSessionModelConflict(Conflict protoConflict)
{
Root = protoConflict.Root;
AlphaChanges = protoConflict.AlphaChanges.Select(change => new SyncSessionModelConflictChange(change)).ToList();
BetaChanges = protoConflict.BetaChanges.Select(change => new SyncSessionModelConflictChange(change)).ToList();
}

private string? FriendlyProblem()
{
// If the change is <non-existent> -> !<non-existent>.
if (AlphaChanges.Count == 1 && BetaChanges.Count == 1 &&
AlphaChanges[0].Old == null &&
BetaChanges[0].Old == null &&
AlphaChanges[0].New != null &&
BetaChanges[0].New != null)
return
"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.";

return null;
}

public string Description()
{
// This formatting is very similar to Mutagen.
var str = $"Conflict at path '{Root}':";
foreach (var change in AlphaChanges)
str += $"\n (alpha) {change.ToString()}";
foreach (var change in AlphaChanges)
str += $"\n (beta) {change.ToString()}";
if (FriendlyProblem() is { } friendlyProblem)
str += $"\n\n {friendlyProblem}";

return str;
}
}

public class SyncSessionModel
{
public readonly string Identifier;
Expand All @@ -62,14 +219,22 @@
public readonly SyncSessionModelEndpointSize AlphaSize;
public readonly SyncSessionModelEndpointSize BetaSize;

public readonly string[] Errors = [];
public readonly IReadOnlyList<SyncSessionModelConflict> Conflicts;
public 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}";
foreach (var err in Errors) str += $"\n\nError: {err}";
foreach (var conflict in Conflicts) str += $"\n\n{conflict.Description()}";
if (OmittedConflicts > 0) str += $"\n\n{OmittedConflicts:N0} conflicts omitted";
return str;
}
}
Expand All @@ -84,37 +249,6 @@
}
}

// 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;
Expand Down Expand Up @@ -220,6 +354,9 @@
StatusDescription = "The session has conflicts that need to be resolved.";
}

Conflicts = state.Conflicts.Select(c => new SyncSessionModelConflict(c)).ToList();
OmittedConflicts = state.ExcludedConflicts;

AlphaSize = new SyncSessionModelEndpointSize
{
SizeBytes = state.AlphaState.TotalFileSize,
Expand All @@ -235,9 +372,24 @@
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 Down
Loading
Loading