Skip to content

feat: add mock UI for file syncing listing #60

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
Mar 25, 2025
Merged
Changes from all 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
1 change: 1 addition & 0 deletions App/App.csproj
Original file line number Diff line number Diff line change
@@ -65,6 +65,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.1" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.250108002" />
<PackageReference Include="WinUIEx" Version="2.5.1" />
</ItemGroup>

<ItemGroup>
8 changes: 7 additions & 1 deletion App/App.xaml
Original file line number Diff line number Diff line change
@@ -3,12 +3,18 @@
<Application
x:Class="Coder.Desktop.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:Coder.Desktop.App.Converters">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>

<converters:InverseBoolConverter x:Key="InverseBoolConverter" />
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<converters:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisibilityConverter" />
<converters:FriendlyByteConverter x:Key="FriendlyByteConverter" />
</ResourceDictionary>
</Application.Resources>
</Application>
5 changes: 5 additions & 0 deletions App/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -47,6 +47,11 @@ public App()
services.AddTransient<SignInViewModel>();
services.AddTransient<SignInWindow>();

// FileSyncListWindow views and view models
services.AddTransient<FileSyncListViewModel>();
// FileSyncListMainPage is created by FileSyncListWindow.
services.AddTransient<FileSyncListWindow>();

// TrayWindow views and view models
services.AddTransient<TrayWindowLoadingPage>();
services.AddTransient<TrayWindowDisconnectedViewModel>();
5 changes: 2 additions & 3 deletions App/Controls/SizedFrame.cs
Original file line number Diff line number Diff line change
@@ -12,9 +12,8 @@ public class SizedFrameEventArgs : EventArgs

/// <summary>
/// SizedFrame extends Frame by adding a SizeChanged event, which will be triggered when:
/// - The contained Page's content's size changes
/// - We switch to a different page.
///
/// - The contained Page's content's size changes
/// - We switch to a different page.
/// Sadly this is necessary because Window.Content.SizeChanged doesn't trigger when the Page's content changes.
/// </summary>
public class SizedFrame : Frame
33 changes: 0 additions & 33 deletions App/Converters/AgentStatusToColorConverter.cs

This file was deleted.

188 changes: 188 additions & 0 deletions App/Converters/DependencyObjectSelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
using System;
using System.Linq;
using Windows.Foundation.Collections;
using Windows.UI.Xaml.Markup;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Media;

namespace Coder.Desktop.App.Converters;

// This file uses manual DependencyProperty properties rather than
// DependencyPropertyGenerator since it doesn't seem to work properly with
// generics.

/// <summary>
/// An item in a DependencyObjectSelector. Each item has a key and a value.
/// The default item in a DependencyObjectSelector will be the only item
/// with a null key.
/// </summary>
/// <typeparam name="TK">Key type</typeparam>
/// <typeparam name="TV">Value type</typeparam>
public class DependencyObjectSelectorItem<TK, TV> : DependencyObject
where TK : IEquatable<TK>
{
public static readonly DependencyProperty KeyProperty =
DependencyProperty.Register(nameof(Key),
typeof(TK?),
typeof(DependencyObjectSelectorItem<TK, TV>),
new PropertyMetadata(null));

public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value),
typeof(TV?),
typeof(DependencyObjectSelectorItem<TK, TV>),
new PropertyMetadata(null));

public TK? Key
{
get => (TK?)GetValue(KeyProperty);
set => SetValue(KeyProperty, value);
}

public TV? Value
{
get => (TV?)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
}

/// <summary>
/// Allows selecting between multiple value references based on a selected
/// key. This allows for dynamic mapping of model values to other objects.
/// The main use case is for selecting between other bound values, which
/// you cannot do with a simple ValueConverter.
/// </summary>
/// <typeparam name="TK">Key type</typeparam>
/// <typeparam name="TV">Value type</typeparam>
[ContentProperty(Name = nameof(References))]
public class DependencyObjectSelector<TK, TV> : DependencyObject
where TK : IEquatable<TK>
{
public static readonly DependencyProperty ReferencesProperty =
DependencyProperty.Register(nameof(References),
typeof(DependencyObjectCollection),
typeof(DependencyObjectSelector<TK, TV>),
new PropertyMetadata(null, ReferencesPropertyChanged));

public static readonly DependencyProperty SelectedKeyProperty =
DependencyProperty.Register(nameof(SelectedKey),
typeof(TK?),
typeof(DependencyObjectSelector<TK, TV>),
new PropertyMetadata(null, SelectedKeyPropertyChanged));

public static readonly DependencyProperty SelectedObjectProperty =
DependencyProperty.Register(nameof(SelectedObject),
typeof(TV?),
typeof(DependencyObjectSelector<TK, TV>),
new PropertyMetadata(null));

public DependencyObjectCollection? References
{
get => (DependencyObjectCollection?)GetValue(ReferencesProperty);
set
{
// Ensure unique keys and that the values are DependencyObjectSelectorItem<K, V>.
if (value != null)
{
var items = value.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray();
var keys = items.Select(i => i.Key).Distinct().ToArray();
if (keys.Length != value.Count)
throw new ArgumentException("ObservableCollection Keys must be unique.");
}

SetValue(ReferencesProperty, value);
}
}

/// <summary>
/// The key of the selected item. This should be bound to a property on
/// the model.
/// </summary>
public TK? SelectedKey
{
get => (TK?)GetValue(SelectedKeyProperty);
set => SetValue(SelectedKeyProperty, value);
}

/// <summary>
/// The selected object. This can be read from to get the matching
/// object for the selected key. If the selected key doesn't match any
/// object, this will be the value of the null key. If there is no null
/// key, this will be null.
/// </summary>
public TV? SelectedObject
{
get => (TV?)GetValue(SelectedObjectProperty);
set => SetValue(SelectedObjectProperty, value);
}

public DependencyObjectSelector()
{
References = [];
}

private void UpdateSelectedObject()
{
if (References != null)
{
// Look for a matching item a matching key, or fallback to the null
// key.
var references = References.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray();
var item = references
.FirstOrDefault(i =>
(i.Key == null && SelectedKey == null) ||
(i.Key != null && SelectedKey != null && i.Key!.Equals(SelectedKey!)))
?? references.FirstOrDefault(i => i.Key == null);
if (item is not null)
{
// Bind the SelectedObject property to the reference's Value.
// If the underlying Value changes, it will propagate to the
// SelectedObject.
BindingOperations.SetBinding
(
this,
SelectedObjectProperty,
new Binding
{
Source = item,
Path = new PropertyPath(nameof(DependencyObjectSelectorItem<TK, TV>.Value)),
}
);
return;
}
}

ClearValue(SelectedObjectProperty);
}

// Called when the References property is replaced.
private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
{
var self = obj as DependencyObjectSelector<TK, TV>;
if (self == null) return;
var oldValue = args.OldValue as DependencyObjectCollection;
if (oldValue != null)
oldValue.VectorChanged -= self.OnVectorChangedReferences;
var newValue = args.NewValue as DependencyObjectCollection;
if (newValue != null)
newValue.VectorChanged += self.OnVectorChangedReferences;
}

// Called when the References collection changes without being replaced.
private void OnVectorChangedReferences(IObservableVector<DependencyObject> sender, IVectorChangedEventArgs args)
{
UpdateSelectedObject();
}

// Called when SelectedKey changes.
private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
{
var self = obj as DependencyObjectSelector<TK, TV>;
self?.UpdateSelectedObject();
}
}

public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem<string, Brush>;

public sealed class StringToBrushSelector : DependencyObjectSelector<string, Brush>;
43 changes: 43 additions & 0 deletions App/Converters/FriendlyByteConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using Microsoft.UI.Xaml.Data;

namespace Coder.Desktop.App.Converters;

public class FriendlyByteConverter : IValueConverter
{
private static readonly string[] Suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];

public object Convert(object value, Type targetType, object parameter, string language)
{
switch (value)
{
case int i:
if (i < 0) i = 0;
return FriendlyBytes((ulong)i);
case uint ui:
return FriendlyBytes(ui);
case long l:
if (l < 0) l = 0;
return FriendlyBytes((ulong)l);
case ulong ul:
return FriendlyBytes(ul);
default:
return FriendlyBytes(0);
}
}

public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotImplementedException();
}

public static string FriendlyBytes(ulong bytes)
{
if (bytes == 0)
return $"0 {Suffixes[0]}";

var place = System.Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
var num = Math.Round(bytes / Math.Pow(1024, place), 1);
return $"{num} {Suffixes[place]}";
}
}
17 changes: 17 additions & 0 deletions App/Converters/InverseBoolConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;
using Microsoft.UI.Xaml.Data;

namespace Coder.Desktop.App.Converters;

public class InverseBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
return value is false;
}

public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotImplementedException();
}
}
12 changes: 12 additions & 0 deletions App/Converters/InverseBoolToVisibilityConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Microsoft.UI.Xaml;

namespace Coder.Desktop.App.Converters;

public partial class InverseBoolToVisibilityConverter : BoolToObjectConverter
{
public InverseBoolToVisibilityConverter()
{
TrueValue = Visibility.Collapsed;
FalseValue = Visibility.Visible;
}
}
254 changes: 254 additions & 0 deletions App/Models/SyncSessionModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
using System;
using Coder.Desktop.App.Converters;
using Coder.Desktop.MutagenSdk.Proto.Synchronization;
using Coder.Desktop.MutagenSdk.Proto.Url;

namespace Coder.Desktop.App.Models;

// This is a much slimmer enum than the original enum from Mutagen and only
// contains the overarching states that we care about from a code perspective.
// We still store the original state in the model for rendering purposes.
public enum SyncSessionStatusCategory
{
Unknown,
Paused,

// Halted is a combination of Error and Paused. If the session
// automatically pauses due to a safety check, we want to show it as an
// error, but also show that it can be resumed.
Halted,
Error,

// If there are any conflicts, the state will be set to Conflicts,
// overriding Working and Ok.
Conflicts,
Working,
Ok,
}

public sealed class SyncSessionModelEndpointSize
{
public ulong SizeBytes { get; init; }
public ulong FileCount { get; init; }
public ulong DirCount { get; init; }
public ulong SymlinkCount { get; init; }

public string Description(string linePrefix = "")
{
var str =
$"{linePrefix}{FriendlyByteConverter.FriendlyBytes(SizeBytes)}\n" +
$"{linePrefix}{FileCount:N0} files\n" +
$"{linePrefix}{DirCount:N0} directories";
if (SymlinkCount > 0) str += $"\n{linePrefix} {SymlinkCount:N0} symlinks";

return str;
}
}

public class SyncSessionModel
{
public readonly string Identifier;
public readonly string Name;

public readonly string AlphaName;
public readonly string AlphaPath;
public readonly string BetaName;
public readonly string BetaPath;

public readonly SyncSessionStatusCategory StatusCategory;
public readonly string StatusString;
public readonly string StatusDescription;

public readonly SyncSessionModelEndpointSize AlphaSize;
public readonly SyncSessionModelEndpointSize BetaSize;

public readonly string[] Errors = [];

public string StatusDetails
{
get
{
var str = $"{StatusString} ({StatusCategory})\n\n{StatusDescription}";
foreach (var err in Errors) str += $"\n\n{err}";
return str;
}
}

public string SizeDetails
{
get
{
var str = "Alpha:\n" + AlphaSize.Description(" ") + "\n\n" +
"Remote:\n" + BetaSize.Description(" ");
return str;
}
}

// 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);

switch (state.Status)
{
case Status.Disconnected:
StatusCategory = SyncSessionStatusCategory.Error;
StatusString = "Disconnected";
StatusDescription =
"The session is unpaused but not currently connected or connecting to either endpoint.";
break;
case Status.HaltedOnRootEmptied:
StatusCategory = SyncSessionStatusCategory.Halted;
StatusString = "Halted on root emptied";
StatusDescription = "The session is halted due to the root emptying safety check.";
break;
case Status.HaltedOnRootDeletion:
StatusCategory = SyncSessionStatusCategory.Halted;
StatusString = "Halted on root deletion";
StatusDescription = "The session is halted due to the root deletion safety check.";
break;
case Status.HaltedOnRootTypeChange:
StatusCategory = SyncSessionStatusCategory.Halted;
StatusString = "Halted on root type change";
StatusDescription = "The session is halted due to the root type change safety check.";
break;
case Status.ConnectingAlpha:
StatusCategory = SyncSessionStatusCategory.Working;
StatusString = "Connecting (alpha)";
StatusDescription = "The session is attempting to connect to the alpha endpoint.";
break;
case Status.ConnectingBeta:
StatusCategory = SyncSessionStatusCategory.Working;
StatusString = "Connecting (beta)";
StatusDescription = "The session is attempting to connect to the beta endpoint.";
break;
case Status.Watching:
StatusCategory = SyncSessionStatusCategory.Ok;
StatusString = "Watching";
StatusDescription = "The session is watching for filesystem changes.";
break;
case Status.Scanning:
StatusCategory = SyncSessionStatusCategory.Working;
StatusString = "Scanning";
StatusDescription = "The session is scanning the filesystem on each endpoint.";
break;
case Status.WaitingForRescan:
StatusCategory = SyncSessionStatusCategory.Working;
StatusString = "Waiting for rescan";
StatusDescription =
"The session is waiting to retry scanning after an error during the previous scanning operation.";
break;
case Status.Reconciling:
StatusCategory = SyncSessionStatusCategory.Working;
StatusString = "Reconciling";
StatusDescription = "The session is performing reconciliation.";
break;
case Status.StagingAlpha:
StatusCategory = SyncSessionStatusCategory.Working;
StatusString = "Staging (alpha)";
StatusDescription = "The session is staging files on alpha.";
break;
case Status.StagingBeta:
StatusCategory = SyncSessionStatusCategory.Working;
StatusString = "Staging (beta)";
StatusDescription = "The session is staging files on beta.";
break;
case Status.Transitioning:
StatusCategory = SyncSessionStatusCategory.Working;
StatusString = "Transitioning";
StatusDescription = "The session is performing transition operations on each endpoint.";
break;
case Status.Saving:
StatusCategory = SyncSessionStatusCategory.Working;
StatusString = "Saving";
StatusDescription = "The session is recording synchronization history to disk.";
break;
default:
StatusCategory = SyncSessionStatusCategory.Unknown;
StatusString = state.Status.ToString();
StatusDescription = "Unknown status message.";
break;
}

// If the session is paused, override all other statuses except Halted.
if (state.Session.Paused && StatusCategory is not SyncSessionStatusCategory.Halted)
{
StatusCategory = SyncSessionStatusCategory.Paused;
StatusString = "Paused";
StatusDescription = "The session is paused.";
}

// If there are any conflicts, override Working and Ok.
if (state.Conflicts.Count > 0 && StatusCategory > SyncSessionStatusCategory.Conflicts)
{
StatusCategory = SyncSessionStatusCategory.Conflicts;
StatusString = "Conflicts";
StatusDescription = "The session has conflicts that need to be resolved.";
}

AlphaSize = new SyncSessionModelEndpointSize
{
SizeBytes = state.AlphaState.TotalFileSize,
FileCount = state.AlphaState.Files,
DirCount = state.AlphaState.Directories,
SymlinkCount = state.AlphaState.SymbolicLinks,
};
BetaSize = new SyncSessionModelEndpointSize
{
SizeBytes = state.BetaState.TotalFileSize,
FileCount = state.BetaState.Files,
DirCount = state.BetaState.Directories,
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];
}

private static (string, string) NameAndPathFromUrl(URL url)
{
var name = "Local";
var path = !string.IsNullOrWhiteSpace(url.Path) ? url.Path : "Unknown";

if (url.Protocol is not Protocol.Local)
name = !string.IsNullOrWhiteSpace(url.Host) ? url.Host : "Unknown";
if (string.IsNullOrWhiteSpace(url.Host)) name = url.Host;

return (name, path);
}
}
40 changes: 15 additions & 25 deletions App/Services/MutagenController.cs
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Coder.Desktop.App.Models;
using Coder.Desktop.MutagenSdk;
using Coder.Desktop.MutagenSdk.Proto.Selection;
using Coder.Desktop.MutagenSdk.Proto.Service.Daemon;
@@ -15,28 +16,17 @@

namespace Coder.Desktop.App.Services;

// <summary>
// A file synchronization session to a Coder workspace agent.
// </summary>
// <remarks>
// This implementation is a placeholder while implementing the daemon lifecycle. It's implementation
// will be backed by the MutagenSDK eventually.
// </remarks>
public class SyncSession
public class CreateSyncSessionRequest
{
public string name { get; init; } = "";
public string localPath { get; init; } = "";
public string workspace { get; init; } = "";
public string agent { get; init; } = "";
public string remotePath { get; init; } = "";
// TODO: this
}

public interface ISyncSessionController
{
Task<List<SyncSession>> ListSyncSessions(CancellationToken ct);
Task<SyncSession> CreateSyncSession(SyncSession session, CancellationToken ct);
Task<IEnumerable<SyncSessionModel>> ListSyncSessions(CancellationToken ct);
Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct);

Task TerminateSyncSession(SyncSession session, CancellationToken ct);
Task TerminateSyncSession(string identifier, CancellationToken ct);

// <summary>
// Initializes the controller; running the daemon if there are any saved sessions. Must be called and
@@ -121,7 +111,7 @@
}


public async Task<SyncSession> CreateSyncSession(SyncSession session, CancellationToken ct)
public async Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct)
{
// reads of _sessionCount are atomic, so don't bother locking for this quick check.
if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first");
@@ -132,11 +122,12 @@
_sessionCount += 1;
}

return session;
// TODO: implement this
return new SyncSessionModel(@"C:\path", "remote", "~/path", SyncSessionStatusCategory.Ok, "Watching",
"Description", []);
}


public async Task<List<SyncSession>> ListSyncSessions(CancellationToken ct)
public async Task<IEnumerable<SyncSessionModel>> ListSyncSessions(CancellationToken ct)

Check warning on line 130 in App/Services/MutagenController.cs

GitHub Actions / build

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check warning on line 130 in App/Services/MutagenController.cs

GitHub Actions / build

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check warning on line 130 in App/Services/MutagenController.cs

GitHub Actions / test

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check warning on line 130 in App/Services/MutagenController.cs

GitHub Actions / test

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
{
// reads of _sessionCount are atomic, so don't bother locking for this quick check.
switch (_sessionCount)
@@ -146,12 +137,11 @@
case 0:
// If we already know there are no sessions, don't start up the daemon
// again.
return new List<SyncSession>();
return [];
}

var client = await EnsureDaemon(ct);
// TODO: implement
return new List<SyncSession>();
// TODO: implement this
return [];
}

public async Task Initialize(CancellationToken ct)
@@ -190,7 +180,7 @@
}
}

public async Task TerminateSyncSession(SyncSession session, CancellationToken ct)
public async Task TerminateSyncSession(string identifier, CancellationToken ct)
{
if (_sessionCount < 0) throw new InvalidOperationException("Controller must be Initialized first");
var client = await EnsureDaemon(ct);
@@ -274,7 +264,7 @@
// it up to 5 times x 100ms. Those issues should resolve themselves quickly if they are
// going to at all.
const int maxAttempts = 5;
ListResponse? sessions = null;

Check warning on line 267 in App/Services/MutagenController.cs

GitHub Actions / build

The variable 'sessions' is assigned but its value is never used

Check warning on line 267 in App/Services/MutagenController.cs

GitHub Actions / build

The variable 'sessions' is assigned but its value is never used

Check warning on line 267 in App/Services/MutagenController.cs

GitHub Actions / test

The variable 'sessions' is assigned but its value is never used

Check warning on line 267 in App/Services/MutagenController.cs

GitHub Actions / test

The variable 'sessions' is assigned but its value is never used
for (var attempts = 1; attempts <= maxAttempts; attempts++)
{
ct.ThrowIfCancellationRequested();
254 changes: 254 additions & 0 deletions App/ViewModels/FileSyncListViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Windows.Storage.Pickers;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using WinRT.Interop;

namespace Coder.Desktop.App.ViewModels;

public partial class FileSyncListViewModel : ObservableObject
{
private DispatcherQueue? _dispatcherQueue;

private readonly ISyncSessionController _syncSessionController;
private readonly IRpcController _rpcController;
private readonly ICredentialManager _credentialManager;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowUnavailable))]
[NotifyPropertyChangedFor(nameof(ShowLoading))]
[NotifyPropertyChangedFor(nameof(ShowError))]
[NotifyPropertyChangedFor(nameof(ShowSessions))]
public partial string? UnavailableMessage { get; set; } = null;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowLoading))]
[NotifyPropertyChangedFor(nameof(ShowSessions))]
public partial bool Loading { get; set; } = true;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowLoading))]
[NotifyPropertyChangedFor(nameof(ShowError))]
[NotifyPropertyChangedFor(nameof(ShowSessions))]
public partial string? Error { get; set; } = null;

[ObservableProperty] public partial List<SyncSessionModel> Sessions { get; set; } = [];

[ObservableProperty] public partial bool CreatingNewSession { get; set; } = false;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
public partial string NewSessionLocalPath { get; set; } = "";

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
public partial bool NewSessionLocalPathDialogOpen { get; set; } = false;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
public partial string NewSessionRemoteName { get; set; } = "";

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
public partial string NewSessionRemotePath { get; set; } = "";
// TODO: NewSessionRemotePathDialogOpen for remote path

public bool NewSessionCreateEnabled
{
get
{
if (string.IsNullOrWhiteSpace(NewSessionLocalPath)) return false;
if (NewSessionLocalPathDialogOpen) return false;
if (string.IsNullOrWhiteSpace(NewSessionRemoteName)) return false;
if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false;
return true;
}
}

// TODO: this could definitely be improved
public bool ShowUnavailable => UnavailableMessage != null;
public bool ShowLoading => Loading && UnavailableMessage == null && Error == null;
public bool ShowError => UnavailableMessage == null && Error != null;
public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null;

public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController,
ICredentialManager credentialManager)
{
_syncSessionController = syncSessionController;
_rpcController = rpcController;
_credentialManager = credentialManager;

Sessions =
[
new SyncSessionModel(@"C:\Users\dean\git\coder-desktop-windows", "pog", "~/repos/coder-desktop-windows",
SyncSessionStatusCategory.Ok, "Watching", "Some description", []),
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Paused,
"Paused",
"Some description", []),
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Conflicts,
"Conflicts", "Some description", []),
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Halted,
"Halted on root emptied", "Some description", []),
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Error,
"Some error", "Some description", []),
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Unknown,
"Unknown", "Some description", []),
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Working,
"Reconciling", "Some description", []),
];
}

public void Initialize(DispatcherQueue dispatcherQueue)
{
_dispatcherQueue = dispatcherQueue;
if (!_dispatcherQueue.HasThreadAccess)
throw new InvalidOperationException("Initialize must be called from the UI thread");

_rpcController.StateChanged += RpcControllerStateChanged;
_credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged;

var rpcModel = _rpcController.GetState();
var credentialModel = _credentialManager.GetCachedCredentials();
MaybeSetUnavailableMessage(rpcModel, credentialModel);

// TODO: Simulate loading until we have real data.
Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ => _dispatcherQueue.TryEnqueue(() => Loading = false));
}

private void RpcControllerStateChanged(object? sender, RpcModel rpcModel)
{
// Ensure we're on the UI thread.
if (_dispatcherQueue == null) return;
if (!_dispatcherQueue.HasThreadAccess)
{
_dispatcherQueue.TryEnqueue(() => RpcControllerStateChanged(sender, rpcModel));
return;
}

var credentialModel = _credentialManager.GetCachedCredentials();
MaybeSetUnavailableMessage(rpcModel, credentialModel);
}

private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel)
{
// Ensure we're on the UI thread.
if (_dispatcherQueue == null) return;
if (!_dispatcherQueue.HasThreadAccess)
{
_dispatcherQueue.TryEnqueue(() => CredentialManagerCredentialsChanged(sender, credentialModel));
return;
}

var rpcModel = _rpcController.GetState();
MaybeSetUnavailableMessage(rpcModel, credentialModel);
}

private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel)
{
if (rpcModel.RpcLifecycle != RpcLifecycle.Connected)
UnavailableMessage =
"Disconnected from the Windows service. Please see the tray window for more information.";
else if (credentialModel.State != CredentialState.Valid)
UnavailableMessage = "Please sign in to access file sync.";
else if (rpcModel.VpnLifecycle != VpnLifecycle.Started)
UnavailableMessage = "Please start Coder Connect from the tray window to access file sync.";
else
UnavailableMessage = null;
}

private void ClearNewForm()
{
CreatingNewSession = false;
NewSessionLocalPath = "";
NewSessionRemoteName = "";
NewSessionRemotePath = "";
}

[RelayCommand]
private void ReloadSessions()
{
Loading = true;
Error = null;
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
_ = _syncSessionController.ListSyncSessions(cts.Token).ContinueWith(HandleList, cts.Token);
}

private void HandleList(Task<IEnumerable<SyncSessionModel>> t)
{
// Ensure we're on the UI thread.
if (_dispatcherQueue == null) return;
if (!_dispatcherQueue.HasThreadAccess)
{
_dispatcherQueue.TryEnqueue(() => HandleList(t));
return;
}

if (t.IsCompletedSuccessfully)
{
Sessions = t.Result.ToList();
Loading = false;
return;
}

Error = "Could not list sync sessions: ";
if (t.IsCanceled) Error += new TaskCanceledException();
else if (t.IsFaulted) Error += t.Exception;
else Error += "no successful result or error";
Loading = false;
}

[RelayCommand]
private void StartCreatingNewSession()
{
ClearNewForm();
CreatingNewSession = true;
}

public async Task OpenLocalPathSelectDialog(Window window)
{
var picker = new FolderPicker
{
SuggestedStartLocation = PickerLocationId.ComputerFolder,
};

var hwnd = WindowNative.GetWindowHandle(window);
InitializeWithWindow.Initialize(picker, hwnd);

NewSessionLocalPathDialogOpen = true;
try
{
var path = await picker.PickSingleFolderAsync();
if (path == null) return;
NewSessionLocalPath = path.Path;
}
catch
{
// ignored
}
finally
{
NewSessionLocalPathDialogOpen = false;
}
}

[RelayCommand]
private void CancelNewSession()
{
ClearNewForm();
}

[RelayCommand]
private void ConfirmNewSession()
{
// TODO: implement
ClearNewForm();
}
}
37 changes: 34 additions & 3 deletions App/ViewModels/TrayWindowViewModel.cs
Original file line number Diff line number Diff line change
@@ -4,10 +4,12 @@
using System.Threading.Tasks;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
using Coder.Desktop.App.Views;
using Coder.Desktop.Vpn.Proto;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Google.Protobuf;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -20,9 +22,12 @@ public partial class TrayWindowViewModel : ObservableObject
private const int MaxAgents = 5;
private const string DefaultDashboardUrl = "https://coder.com";

private readonly IServiceProvider _services;
private readonly IRpcController _rpcController;
private readonly ICredentialManager _credentialManager;

private FileSyncListWindow? _fileSyncListWindow;

private DispatcherQueue? _dispatcherQueue;

[ObservableProperty]
@@ -73,8 +78,10 @@ public partial class TrayWindowViewModel : ObservableObject

[ObservableProperty] public partial string DashboardUrl { get; set; } = "https://coder.com";

public TrayWindowViewModel(IRpcController rpcController, ICredentialManager credentialManager)
public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController,
ICredentialManager credentialManager)
{
_services = services;
_rpcController = rpcController;
_credentialManager = credentialManager;
}
@@ -204,6 +211,14 @@ private string WorkspaceUri(Uri? baseUri, string? workspaceName)

private void UpdateFromCredentialsModel(CredentialModel credentialModel)
{
// Ensure we're on the UI thread.
if (_dispatcherQueue == null) return;
if (!_dispatcherQueue.HasThreadAccess)
{
_dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel));
return;
}

// HACK: the HyperlinkButton crashes the whole app if the initial URI
// or this URI is invalid. CredentialModel.CoderUrl should never be
// null while the Page is active as the Page is only displayed when
@@ -234,7 +249,7 @@ private async Task StartVpn()
}
catch (Exception e)
{
VpnFailedMessage = "Failed to start Coder Connect: " + MaybeUnwrapTunnelError(e);
VpnFailedMessage = "Failed to start CoderVPN: " + MaybeUnwrapTunnelError(e);
}
}

@@ -246,7 +261,7 @@ private async Task StopVpn()
}
catch (Exception e)
{
VpnFailedMessage = "Failed to stop Coder Connect: " + MaybeUnwrapTunnelError(e);
VpnFailedMessage = "Failed to stop CoderVPN: " + MaybeUnwrapTunnelError(e);
}
}

@@ -262,6 +277,22 @@ public void ToggleShowAllAgents()
ShowAllAgents = !ShowAllAgents;
}

[RelayCommand]
public void ShowFileSyncListWindow()
{
// This is safe against concurrent access since it all happens in the
// UI thread.
if (_fileSyncListWindow != null)
{
_fileSyncListWindow.Activate();
return;
}

_fileSyncListWindow = _services.GetRequiredService<FileSyncListWindow>();
_fileSyncListWindow.Closed += (_, _) => _fileSyncListWindow = null;
_fileSyncListWindow.Activate();
}

[RelayCommand]
public void SignOut()
{
20 changes: 20 additions & 0 deletions App/Views/FileSyncListWindow.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>

<winuiex:WindowEx
x:Class="Coder.Desktop.App.Views.FileSyncListWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:winuiex="using:WinUIEx"
mc:Ignorable="d"
Title="Coder File Sync"
Width="1000" Height="300"
MinWidth="1000" MinHeight="300">

<Window.SystemBackdrop>
<DesktopAcrylicBackdrop />
</Window.SystemBackdrop>

<Frame x:Name="RootFrame" />
</winuiex:WindowEx>
23 changes: 23 additions & 0 deletions App/Views/FileSyncListWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Coder.Desktop.App.ViewModels;
using Coder.Desktop.App.Views.Pages;
using Microsoft.UI.Xaml.Media;
using WinUIEx;

namespace Coder.Desktop.App.Views;

public sealed partial class FileSyncListWindow : WindowEx
{
public readonly FileSyncListViewModel ViewModel;

public FileSyncListWindow(FileSyncListViewModel viewModel)
{
ViewModel = viewModel;
InitializeComponent();
SystemBackdrop = new DesktopAcrylicBackdrop();

ViewModel.Initialize(DispatcherQueue);
RootFrame.Content = new FileSyncListMainPage(ViewModel, this);

this.CenterOnScreen();
}
}
331 changes: 331 additions & 0 deletions App/Views/Pages/FileSyncListMainPage.xaml

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions App/Views/Pages/FileSyncListMainPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Threading.Tasks;
using Coder.Desktop.App.ViewModels;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace Coder.Desktop.App.Views.Pages;

public sealed partial class FileSyncListMainPage : Page
{
public FileSyncListViewModel ViewModel;

private readonly Window _window;

public FileSyncListMainPage(FileSyncListViewModel viewModel, Window window)
{
ViewModel = viewModel; // already initialized
_window = window;
InitializeComponent();
}

// Adds a tooltip with the full text when it's ellipsized.
private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedChangedEventArgs e)
{
ToolTipService.SetToolTip(sender, null);
if (!sender.IsTextTrimmed) return;

var toolTip = new ToolTip
{
Content = sender.Text,
};
ToolTipService.SetToolTip(sender, toolTip);
}

[RelayCommand]
public async Task OpenLocalPathSelectDialog()
{
await ViewModel.OpenLocalPathSelectDialog(_window);
}
}
47 changes: 42 additions & 5 deletions App/Views/Pages/TrayWindowMainPage.xaml
Original file line number Diff line number Diff line change
@@ -12,14 +12,11 @@
mc:Ignorable="d">

<Page.Resources>
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />

<converters:VpnLifecycleToBoolConverter x:Key="ConnectingBoolConverter" Unknown="true" Starting="true"
Stopping="true" />
<converters:VpnLifecycleToBoolConverter x:Key="NotConnectingBoolConverter" Started="true" Stopped="true" />
<converters:VpnLifecycleToBoolConverter x:Key="StoppedBoolConverter" Stopped="true" />

<converters:AgentStatusToColorConverter x:Key="AgentStatusToColorConverter" />
<converters:BoolToObjectConverter x:Key="ShowMoreLessTextConverter" TrueValue="Show less"
FalseValue="Show more" />
</Page.Resources>
@@ -118,22 +115,50 @@
HorizontalAlignment="Stretch"
Spacing="10">

<StackPanel.Resources>
<converters:StringToBrushSelector
x:Key="StatusColor"
SelectedKey="{x:Bind Path=ConnectionStatus, Mode=OneWay}">

<converters:StringToBrushSelectorItem>
<converters:StringToBrushSelectorItem.Value>
<SolidColorBrush Color="#8e8e93" />
</converters:StringToBrushSelectorItem.Value>
</converters:StringToBrushSelectorItem>
<converters:StringToBrushSelectorItem Key="Red">
<converters:StringToBrushSelectorItem.Value>
<SolidColorBrush Color="#ff3b30" />
</converters:StringToBrushSelectorItem.Value>
</converters:StringToBrushSelectorItem>
<converters:StringToBrushSelectorItem Key="Yellow">
<converters:StringToBrushSelectorItem.Value>
<SolidColorBrush Color="#ffcc01" />
</converters:StringToBrushSelectorItem.Value>
</converters:StringToBrushSelectorItem>
<converters:StringToBrushSelectorItem Key="Green">
<converters:StringToBrushSelectorItem.Value>
<SolidColorBrush Color="#34c759" />
</converters:StringToBrushSelectorItem.Value>
</converters:StringToBrushSelectorItem>
</converters:StringToBrushSelector>
</StackPanel.Resources>

<Canvas
HorizontalAlignment="Center"
VerticalAlignment="Center"
Height="14" Width="14"
Margin="0,1,0,0">

<Ellipse
Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}"
Fill="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}"
Opacity="0.2"
Width="14"
Height="14"
Canvas.Left="0"
Canvas.Top="0" />

<Ellipse
Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}"
Fill="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}"
Width="8"
Height="8"
VerticalAlignment="Center"
@@ -203,6 +228,18 @@

<controls:HorizontalRule />

<HyperlinkButton
Command="{x:Bind ViewModel.ShowFileSyncListWindowCommand, Mode=OneWay}"
Margin="-12,0"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left">

<!-- TODO: status icon if there is a problem -->
<TextBlock Text="File sync" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
</HyperlinkButton>

<controls:HorizontalRule />

<HyperlinkButton
Command="{x:Bind ViewModel.SignOutCommand, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource StoppedBoolConverter}, Mode=OneWay}"
9 changes: 9 additions & 0 deletions App/packages.lock.json
Original file line number Diff line number Diff line change
@@ -85,6 +85,15 @@
"Microsoft.Windows.SDK.BuildTools": "10.0.22621.756"
}
},
"WinUIEx": {
"type": "Direct",
"requested": "[2.5.1, )",
"resolved": "2.5.1",
"contentHash": "ihW4bA2quKbwWBOl5Uu80jBZagc4cT4E6CdExmvSZ05Qwz0jgoGyZuSTKcU9Uz7lZlQij3KxNor0dGXNUyIV9Q==",
"dependencies": {
"Microsoft.WindowsAppSDK": "1.6.240829007"
}
},
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.29.3",
36 changes: 36 additions & 0 deletions Tests.App/Converters/FriendlyByteConverterTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Coder.Desktop.App.Converters;

namespace Coder.Desktop.Tests.App.Converters;

[TestFixture]
public class FriendlyByteConverterTest
{
[Test]
public void EndToEnd()
{
var cases = new List<(object, string)>
{
(0, "0 B"),
((uint)0, "0 B"),
((long)0, "0 B"),
((ulong)0, "0 B"),

(1, "1 B"),
(1024, "1 KB"),
((ulong)(1.1 * 1024), "1.1 KB"),
(1024 * 1024, "1 MB"),
(1024 * 1024 * 1024, "1 GB"),
((ulong)1024 * 1024 * 1024 * 1024, "1 TB"),
((ulong)1024 * 1024 * 1024 * 1024 * 1024, "1 PB"),
((ulong)1024 * 1024 * 1024 * 1024 * 1024 * 1024, "1 EB"),
(ulong.MaxValue, "16 EB"),
};

var converter = new FriendlyByteConverter();
foreach (var (input, expected) in cases)
{
var actual = converter.Convert(input, typeof(string), null, null);

Check warning on line 32 in Tests.App/Converters/FriendlyByteConverterTest.cs

GitHub Actions / test

Cannot convert null literal to non-nullable reference type.

Check warning on line 32 in Tests.App/Converters/FriendlyByteConverterTest.cs

GitHub Actions / test

Cannot convert null literal to non-nullable reference type.
Assert.That(actual, Is.EqualTo(expected), $"case ({input.GetType()}){input}");
}
}
}
8 changes: 4 additions & 4 deletions Tests.App/Services/MutagenControllerTest.cs
Original file line number Diff line number Diff line change
@@ -90,12 +90,12 @@ public async Task CreateRestartsDaemon(CancellationToken ct)
await using (var controller = new MutagenController(_mutagenBinaryPath, dataDirectory))
{
await controller.Initialize(ct);
await controller.CreateSyncSession(new SyncSession(), ct);
await controller.CreateSyncSession(new CreateSyncSessionRequest(), ct);
}

var logPath = Path.Combine(dataDirectory, "daemon.log");
Assert.That(File.Exists(logPath));
var logLines = File.ReadAllLines(logPath);
var logLines = await File.ReadAllLinesAsync(logPath, ct);

// Here we're going to use the log to verify the daemon was started 2 times.
// slightly brittle, but unlikely this log line will change.
@@ -114,7 +114,7 @@ public async Task Orphaned(CancellationToken ct)
{
controller1 = new MutagenController(_mutagenBinaryPath, dataDirectory);
await controller1.Initialize(ct);
await controller1.CreateSyncSession(new SyncSession(), ct);
await controller1.CreateSyncSession(new CreateSyncSessionRequest(), ct);

controller2 = new MutagenController(_mutagenBinaryPath, dataDirectory);
await controller2.Initialize(ct);
@@ -127,7 +127,7 @@ public async Task Orphaned(CancellationToken ct)

var logPath = Path.Combine(dataDirectory, "daemon.log");
Assert.That(File.Exists(logPath));
var logLines = File.ReadAllLines(logPath);
var logLines = await File.ReadAllLinesAsync(logPath, ct);

// Here we're going to use the log to verify the daemon was started 3 times.
// slightly brittle, but unlikely this log line will change.