From b73ebe07dd5754fc32e8c8bd672364d444505bd7 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 19 Mar 2025 21:09:42 +1100 Subject: [PATCH 1/5] feat: add mock UI for file syncing listing --- App/App.csproj | 1 + App/App.xaml | 8 +- App/Controls/SizedFrame.cs | 5 +- App/Converters/AgentStatusToColorConverter.cs | 33 -- App/Converters/DependencyObjectSelector.cs | 155 +++++++++ App/Converters/FriendlyByteConverter.cs | 43 +++ App/Converters/InverseBoolConverter.cs | 17 + .../InverseBoolToVisibilityConverter.cs | 12 + App/Models/MutagenSessionModel.cs | 310 ++++++++++++++++++ App/ViewModels/FileSyncListViewModel.cs | 188 +++++++++++ App/ViewModels/TrayWindowViewModel.cs | 21 +- App/Views/FileSyncListWindow.xaml | 20 ++ App/Views/FileSyncListWindow.xaml.cs | 33 ++ App/Views/Pages/FileSyncListMainPage.xaml | 269 +++++++++++++++ App/Views/Pages/FileSyncListMainPage.xaml.cs | 40 +++ App/Views/Pages/TrayWindowMainPage.xaml | 35 +- App/packages.lock.json | 9 + 17 files changed, 1155 insertions(+), 44 deletions(-) delete mode 100644 App/Converters/AgentStatusToColorConverter.cs create mode 100644 App/Converters/DependencyObjectSelector.cs create mode 100644 App/Converters/FriendlyByteConverter.cs create mode 100644 App/Converters/InverseBoolConverter.cs create mode 100644 App/Converters/InverseBoolToVisibilityConverter.cs create mode 100644 App/Models/MutagenSessionModel.cs create mode 100644 App/ViewModels/FileSyncListViewModel.cs create mode 100644 App/Views/FileSyncListWindow.xaml create mode 100644 App/Views/FileSyncListWindow.xaml.cs create mode 100644 App/Views/Pages/FileSyncListMainPage.xaml create mode 100644 App/Views/Pages/FileSyncListMainPage.xaml.cs diff --git a/App/App.csproj b/App/App.csproj index 8b7e810..2a15166 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -65,6 +65,7 @@ + diff --git a/App/App.xaml b/App/App.xaml index a5b6d8b..c614e0e 100644 --- a/App/App.xaml +++ b/App/App.xaml @@ -3,12 +3,18 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:converters="using:Coder.Desktop.App.Converters"> + + + + + diff --git a/App/Controls/SizedFrame.cs b/App/Controls/SizedFrame.cs index a666c55..bd2462b 100644 --- a/App/Controls/SizedFrame.cs +++ b/App/Controls/SizedFrame.cs @@ -12,9 +12,8 @@ public class SizedFrameEventArgs : EventArgs /// /// 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. /// public class SizedFrame : Frame diff --git a/App/Converters/AgentStatusToColorConverter.cs b/App/Converters/AgentStatusToColorConverter.cs deleted file mode 100644 index ebcabdd..0000000 --- a/App/Converters/AgentStatusToColorConverter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using Windows.UI; -using Coder.Desktop.App.ViewModels; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Media; - -namespace Coder.Desktop.App.Converters; - -public class AgentStatusToColorConverter : IValueConverter -{ - private static readonly SolidColorBrush Green = new(Color.FromArgb(255, 52, 199, 89)); - private static readonly SolidColorBrush Yellow = new(Color.FromArgb(255, 255, 204, 1)); - private static readonly SolidColorBrush Red = new(Color.FromArgb(255, 255, 59, 48)); - private static readonly SolidColorBrush Gray = new(Color.FromArgb(255, 142, 142, 147)); - - public object Convert(object value, Type targetType, object parameter, string language) - { - if (value is not AgentConnectionStatus status) return Gray; - - return status switch - { - AgentConnectionStatus.Green => Green, - AgentConnectionStatus.Yellow => Yellow, - AgentConnectionStatus.Red => Red, - _ => Gray, - }; - } - - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - throw new NotImplementedException(); - } -} diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs new file mode 100644 index 0000000..740c7a6 --- /dev/null +++ b/App/Converters/DependencyObjectSelector.cs @@ -0,0 +1,155 @@ +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. + +public class DependencyObjectSelectorItem : DependencyObject + where TK : IEquatable +{ + public static readonly DependencyProperty KeyProperty = + DependencyProperty.Register(nameof(Key), + typeof(TK?), + typeof(DependencyObjectSelectorItem), + new PropertyMetadata(null)); + + public static readonly DependencyProperty ValueProperty = + DependencyProperty.Register(nameof(Value), + typeof(TV?), + typeof(DependencyObjectSelectorItem), + 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); + } +} + +[ContentProperty(Name = nameof(References))] +public class DependencyObjectSelector : DependencyObject + where TK : IEquatable +{ + public static readonly DependencyProperty ReferencesProperty = + DependencyProperty.Register(nameof(References), + typeof(DependencyObjectCollection), + typeof(DependencyObjectSelector), + new PropertyMetadata(null, ReferencesPropertyChanged)); + + public static readonly DependencyProperty SelectedKeyProperty = + DependencyProperty.Register(nameof(SelectedKey), + typeof(TK?), + typeof(DependencyObjectSelector), + new PropertyMetadata(null, SelectedPropertyChanged)); + + public static readonly DependencyProperty SelectedObjectProperty = + DependencyProperty.Register(nameof(SelectedObject), + typeof(TV?), + typeof(DependencyObjectSelector), + new PropertyMetadata(null)); + + public DependencyObjectCollection? References + { + get => (DependencyObjectCollection?)GetValue(ReferencesProperty); + set + { + // Ensure unique keys and that the values are DependencyObjectSelectorItem. + if (value != null) + { + var items = value.OfType>().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); + } + } + + public TK? SelectedKey + { + get => (TK?)GetValue(SelectedKeyProperty); + set => SetValue(SelectedKeyProperty, value); + } + + public TV? SelectedObject + { + get => (TV?)GetValue(SelectedObjectProperty); + set => SetValue(SelectedObjectProperty, value); + } + + public DependencyObjectSelector() + { + References = []; + } + + private void OnVectorChangedReferences(IObservableVector sender, IVectorChangedEventArgs args) + { + UpdateSelectedObject(); + } + + private void UpdateSelectedObject() + { + if (References != null) + { + var references = References.OfType>().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) + { + BindingOperations.SetBinding + ( + this, + SelectedObjectProperty, + new Binding + { + Source = item, + Path = new PropertyPath(nameof(DependencyObjectSelectorItem.Value)), + } + ); + return; + } + } + + ClearValue(SelectedObjectProperty); + } + + private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + var self = obj as DependencyObjectSelector; + 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; + } + + private static void SelectedPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + var self = obj as DependencyObjectSelector; + self?.UpdateSelectedObject(); + } +} + +public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem; + +public sealed class StringToBrushSelector : DependencyObjectSelector; diff --git a/App/Converters/FriendlyByteConverter.cs b/App/Converters/FriendlyByteConverter.cs new file mode 100644 index 0000000..c2bce4e --- /dev/null +++ b/App/Converters/FriendlyByteConverter.cs @@ -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]}"; + } +} diff --git a/App/Converters/InverseBoolConverter.cs b/App/Converters/InverseBoolConverter.cs new file mode 100644 index 0000000..927b420 --- /dev/null +++ b/App/Converters/InverseBoolConverter.cs @@ -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(); + } +} diff --git a/App/Converters/InverseBoolToVisibilityConverter.cs b/App/Converters/InverseBoolToVisibilityConverter.cs new file mode 100644 index 0000000..dd9c864 --- /dev/null +++ b/App/Converters/InverseBoolToVisibilityConverter.cs @@ -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; + } +} diff --git a/App/Models/MutagenSessionModel.cs b/App/Models/MutagenSessionModel.cs new file mode 100644 index 0000000..5e1dc37 --- /dev/null +++ b/App/Models/MutagenSessionModel.cs @@ -0,0 +1,310 @@ +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 MutagenSessionStatus +{ + Unknown, + Paused, + Error, + NeedsAttention, + Working, + Ok, +} + +public sealed class MutagenSessionModelEndpointSize +{ + 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 bool Equals(MutagenSessionModelEndpointSize other) + { + return SizeBytes == other.SizeBytes && + FileCount == other.FileCount && + DirCount == other.DirCount && + SymlinkCount == other.SymlinkCount; + } +} + +public class MutagenSessionModel +{ + public readonly string Identifier; + public readonly string Name; + + public readonly string LocalPath = "Unknown"; + public readonly string RemoteName = "unknown"; + public readonly string RemotePath = "Unknown"; + + public readonly MutagenSessionStatus Status; + public readonly string StatusString; + public readonly string StatusDescription; + + public readonly MutagenSessionModelEndpointSize MaxSize; + public readonly MutagenSessionModelEndpointSize LocalSize; + public readonly MutagenSessionModelEndpointSize RemoteSize; + + public readonly string[] Errors = []; + + public string StatusDetails + { + get + { + var str = $"{StatusString} ({Status})\n\n{StatusDescription}"; + foreach (var err in Errors) str += $"\n\n{err}"; + return str; + } + } + + public string SizeDetails + { + get + { + var str = ""; + if (!LocalSize.Equals(RemoteSize)) str = "Maximum:\n" + MaxSize.Description(" ") + "\n\n"; + + str += "Local:\n" + LocalSize.Description(" ") + "\n\n" + + "Remote:\n" + RemoteSize.Description(" "); + return str; + } + } + + // TODO: remove once we process sessions from the mutagen RPC + public MutagenSessionModel(string localPath, string remoteName, string remotePath, MutagenSessionStatus status, + string statusString, string statusDescription, string[] errors) + { + Identifier = "TODO"; + Name = "TODO"; + + LocalPath = localPath; + RemoteName = remoteName; + RemotePath = remotePath; + Status = status; + StatusString = statusString; + StatusDescription = statusDescription; + LocalSize = new MutagenSessionModelEndpointSize + { + SizeBytes = (ulong)new Random().Next(0, 1000000000), + FileCount = (ulong)new Random().Next(0, 10000), + DirCount = (ulong)new Random().Next(0, 10000), + }; + RemoteSize = new MutagenSessionModelEndpointSize + { + SizeBytes = (ulong)new Random().Next(0, 1000000000), + FileCount = (ulong)new Random().Next(0, 10000), + DirCount = (ulong)new Random().Next(0, 10000), + }; + MaxSize = new MutagenSessionModelEndpointSize + { + SizeBytes = ulong.Max(LocalSize.SizeBytes, RemoteSize.SizeBytes), + FileCount = ulong.Max(LocalSize.FileCount, RemoteSize.FileCount), + DirCount = ulong.Max(LocalSize.DirCount, RemoteSize.DirCount), + SymlinkCount = ulong.Max(LocalSize.SymlinkCount, RemoteSize.SymlinkCount), + }; + + Errors = errors; + } + + public MutagenSessionModel(State state) + { + Identifier = state.Session.Identifier; + Name = state.Session.Name; + + // If the protocol isn't what we expect for alpha or beta, show + // "unknown". + if (state.Session.Alpha.Protocol == Protocol.Local && !string.IsNullOrWhiteSpace(state.Session.Alpha.Path)) + LocalPath = state.Session.Alpha.Path; + if (state.Session.Beta.Protocol == Protocol.Ssh) + { + if (string.IsNullOrWhiteSpace(state.Session.Beta.Host)) + { + var name = state.Session.Beta.Host; + // TODO: this will need to be compatible with custom hostname + // suffixes + if (name.EndsWith(".coder")) name = name[..^6]; + RemoteName = name; + } + + if (string.IsNullOrWhiteSpace(state.Session.Beta.Path)) RemotePath = state.Session.Beta.Path; + } + + if (state.Session.Paused) + { + // Disregard any status if it's paused. + Status = MutagenSessionStatus.Paused; + StatusString = "Paused"; + StatusDescription = "The session is paused."; + } + else + { + Status = MutagenSessionModelUtils.StatusFromProtoStatus(state.Status); + StatusString = MutagenSessionModelUtils.ProtoStatusToDisplayString(state.Status); + StatusDescription = MutagenSessionModelUtils.ProtoStatusToDescription(state.Status); + } + + // If there are any conflicts, set the status to NeedsAttention. + if (state.Conflicts.Count > 0 && Status > MutagenSessionStatus.NeedsAttention) + { + Status = MutagenSessionStatus.NeedsAttention; + StatusString = "Conflicts"; + StatusDescription = "The session has conflicts that need to be resolved."; + } + + LocalSize = new MutagenSessionModelEndpointSize + { + SizeBytes = state.AlphaState.TotalFileSize, + FileCount = state.AlphaState.Files, + DirCount = state.AlphaState.Directories, + SymlinkCount = state.AlphaState.SymbolicLinks, + }; + RemoteSize = new MutagenSessionModelEndpointSize + { + SizeBytes = state.BetaState.TotalFileSize, + FileCount = state.BetaState.Files, + DirCount = state.BetaState.Directories, + SymlinkCount = state.BetaState.SymbolicLinks, + }; + MaxSize = new MutagenSessionModelEndpointSize + { + SizeBytes = ulong.Max(LocalSize.SizeBytes, RemoteSize.SizeBytes), + FileCount = ulong.Max(LocalSize.FileCount, RemoteSize.FileCount), + DirCount = ulong.Max(LocalSize.DirCount, RemoteSize.DirCount), + SymlinkCount = ulong.Max(LocalSize.SymlinkCount, RemoteSize.SymlinkCount), + }; + + // TODO: accumulate errors, there seems to be multiple fields they can + // come from + if (!string.IsNullOrWhiteSpace(state.LastError)) Errors = [state.LastError]; + } +} + +public static class MutagenSessionModelUtils +{ + public static MutagenSessionStatus StatusFromProtoStatus(Status protoStatus) + { + switch (protoStatus) + { + case Status.Disconnected: + case Status.HaltedOnRootEmptied: + case Status.HaltedOnRootDeletion: + case Status.HaltedOnRootTypeChange: + case Status.WaitingForRescan: + return MutagenSessionStatus.Error; + case Status.ConnectingAlpha: + case Status.ConnectingBeta: + case Status.Scanning: + case Status.Reconciling: + case Status.StagingAlpha: + case Status.StagingBeta: + case Status.Transitioning: + case Status.Saving: + return MutagenSessionStatus.Working; + case Status.Watching: + return MutagenSessionStatus.Ok; + default: + return MutagenSessionStatus.Unknown; + } + } + + public static string ProtoStatusToDisplayString(Status protoStatus) + { + switch (protoStatus) + { + case Status.Disconnected: + return "Disconnected"; + case Status.HaltedOnRootEmptied: + return "Halted on root emptied"; + case Status.HaltedOnRootDeletion: + return "Halted on root deletion"; + case Status.HaltedOnRootTypeChange: + return "Halted on root type change"; + case Status.ConnectingAlpha: + // This string was changed from "alpha" to "local". + return "Connecting (local)"; + case Status.ConnectingBeta: + // This string was changed from "beta" to "remote". + return "Connecting (remote)"; + case Status.Watching: + return "Watching"; + case Status.Scanning: + return "Scanning"; + case Status.WaitingForRescan: + return "Waiting for rescan"; + case Status.Reconciling: + return "Reconciling"; + case Status.StagingAlpha: + // This string was changed from "alpha" to "local". + return "Staging (local)"; + case Status.StagingBeta: + // This string was changed from "beta" to "remote". + return "Staging (remote)"; + case Status.Transitioning: + return "Transitioning"; + case Status.Saving: + return "Saving"; + default: + return protoStatus.ToString(); + } + } + + public static string ProtoStatusToDescription(Status protoStatus) + { + // These descriptions were mostly taken from the protobuf. + switch (protoStatus) + { + case Status.Disconnected: + return "The session is unpaused but not currently connected or connecting to either endpoint."; + case Status.HaltedOnRootEmptied: + return "The session is halted due to the root emptying safety check."; + case Status.HaltedOnRootDeletion: + return "The session is halted due to the root deletion safety check."; + case Status.HaltedOnRootTypeChange: + return "The session is halted due to the root type change safety check."; + case Status.ConnectingAlpha: + // This string was changed from "alpha" to "local". + return "The session is attempting to connect to the local endpoint."; + case Status.ConnectingBeta: + // This string was changed from "beta" to "remote". + return "The session is attempting to connect to the remote endpoint."; + case Status.Watching: + return "The session is watching for filesystem changes."; + case Status.Scanning: + return "The session is scanning the filesystem on each endpoint."; + case Status.WaitingForRescan: + return + "The session is waiting to retry scanning after an error during the previous scanning operation."; + case Status.Reconciling: + return "The session is performing reconciliation."; + case Status.StagingAlpha: + // This string was changed from "on alpha" to "locally". + return "The session is staging files locally."; + case Status.StagingBeta: + // This string was changed from "beta" to "the remote". + return "The session is staging files on the remote."; + case Status.Transitioning: + return "The session is performing transition operations on each endpoint."; + case Status.Saving: + return "The session is recording synchronization history to disk."; + default: + return "Unknown status message."; + } + } +} diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs new file mode 100644 index 0000000..6de170e --- /dev/null +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +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 +{ + public delegate void OnFileSyncListStaleDelegate(); + + // Triggered when the window should be closed. + public event OnFileSyncListStaleDelegate? OnFileSyncListStale; + + private DispatcherQueue? _dispatcherQueue; + + private readonly IRpcController _rpcController; + private readonly ICredentialManager _credentialManager; + + [ObservableProperty] public partial List 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; + } + } + + public FileSyncListViewModel(IRpcController rpcController, ICredentialManager credentialManager) + { + _rpcController = rpcController; + _credentialManager = credentialManager; + + Sessions = + [ + new MutagenSessionModel(@"C:\Users\dean\git\coder-desktop-windows", "pog", "~/repos/coder-desktop-windows", + MutagenSessionStatus.Ok, "Watching", "Some description", []), + new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Paused, "Paused", + "Some description", []), + new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.NeedsAttention, + "Conflicts", "Some description", []), + new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Error, + "Halted on root emptied", "Some description", []), + new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Unknown, + "Unknown", "Some description", []), + new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Working, + "Reconciling", "Some description", []), + ]; + } + + public void Initialize(DispatcherQueue dispatcherQueue) + { + _dispatcherQueue = dispatcherQueue; + + _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel); + _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel); + + var rpcModel = _rpcController.GetState(); + var credentialModel = _credentialManager.GetCachedCredentials(); + MaybeSendStaleEvent(rpcModel, credentialModel); + } + + private void UpdateFromRpcModel(RpcModel rpcModel) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => UpdateFromRpcModel(rpcModel)); + return; + } + + var credentialModel = _credentialManager.GetCachedCredentials(); + MaybeSendStaleEvent(rpcModel, credentialModel); + } + + private void UpdateFromCredentialsModel(CredentialModel credentialModel) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel)); + return; + } + + var rpcModel = _rpcController.GetState(); + MaybeSendStaleEvent(rpcModel, credentialModel); + } + + private void MaybeSendStaleEvent(RpcModel rpcModel, CredentialModel credentialModel) + { + var ok = rpcModel.RpcLifecycle is RpcLifecycle.Connected + && rpcModel.VpnLifecycle is VpnLifecycle.Started + && credentialModel.State == CredentialState.Valid; + + if (!ok) OnFileSyncListStale?.Invoke(); + } + + private void ClearNewForm() + { + CreatingNewSession = false; + NewSessionLocalPath = ""; + // TODO: close the dialog somehow + NewSessionRemoteName = ""; + NewSessionRemotePath = ""; + } + + [RelayCommand] + private void StartCreatingNewSession() + { + ClearNewForm(); + CreatingNewSession = true; + } + + public async Task OpenLocalPathSelectDialog(Window window) + { + var picker = new FolderPicker + { + SuggestedStartLocation = PickerLocationId.ComputerFolder, + // TODO: Needed? + //FileTypeFilter = { "*" }, + }; + + 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(); + } +} diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 62cf692..f4c4484 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -4,6 +4,7 @@ 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; @@ -204,6 +205,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 +243,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 +255,7 @@ private async Task StopVpn() } catch (Exception e) { - VpnFailedMessage = "Failed to stop Coder Connect: " + MaybeUnwrapTunnelError(e); + VpnFailedMessage = "Failed to stop CoderVPN: " + MaybeUnwrapTunnelError(e); } } @@ -265,6 +274,14 @@ public void ToggleShowAllAgents() [RelayCommand] public void SignOut() { + // TODO: Remove this debug workaround once we have a real UI to open + // the sync window. This lets us open the file sync list window + // in debug builds. +#if DEBUG + new FileSyncListWindow(new FileSyncListViewModel(_rpcController, _credentialManager)).Activate(); + return; +#endif + if (VpnLifecycle is not VpnLifecycle.Stopped) return; _credentialManager.ClearCredentials(); diff --git a/App/Views/FileSyncListWindow.xaml b/App/Views/FileSyncListWindow.xaml new file mode 100644 index 0000000..ae95e8b --- /dev/null +++ b/App/Views/FileSyncListWindow.xaml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs new file mode 100644 index 0000000..0e784dc --- /dev/null +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -0,0 +1,33 @@ +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; + ViewModel.OnFileSyncListStale += ViewModel_OnFileSyncListStale; + + InitializeComponent(); + SystemBackdrop = new DesktopAcrylicBackdrop(); + + ViewModel.Initialize(DispatcherQueue); + RootFrame.Content = new FileSyncListMainPage(ViewModel, this); + + this.CenterOnScreen(); + } + + private void ViewModel_OnFileSyncListStale() + { + // TODO: Fix this. I got a weird memory corruption exception when it + // fired immediately on start. Maybe we should schedule it for + // next frame or something. + //Close() + } +} diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml new file mode 100644 index 0000000..e6b7db3 --- /dev/null +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -0,0 +1,269 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App/Views/Pages/FileSyncListMainPage.xaml.cs b/App/Views/Pages/FileSyncListMainPage.xaml.cs new file mode 100644 index 0000000..c54c29e --- /dev/null +++ b/App/Views/Pages/FileSyncListMainPage.xaml.cs @@ -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); + } +} diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml index cedf006..94c80b3 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -12,14 +12,11 @@ mc:Ignorable="d"> - - - @@ -118,6 +115,34 @@ HorizontalAlignment="Stretch" Spacing="10"> + + + + + + + + + + + + + + + + + + + + + + + + + + Date: Mon, 24 Mar 2025 19:23:10 +1100 Subject: [PATCH 2/5] PR comments --- App/App.xaml.cs | 5 + App/Converters/DependencyObjectSelector.cs | 47 +- App/Models/MutagenSessionModel.cs | 310 ------------ App/Models/SyncSessionModel.cs | 249 ++++++++++ App/Services/MutagenController.cs | 37 +- App/ViewModels/FileSyncListViewModel.cs | 85 +++- App/ViewModels/TrayWindowViewModel.cs | 32 +- App/Views/FileSyncListWindow.xaml | 2 +- App/Views/Pages/FileSyncListMainPage.xaml | 516 +++++++++++--------- App/Views/Pages/TrayWindowMainPage.xaml | 12 + Tests.App/Services/MutagenControllerTest.cs | 8 +- 11 files changed, 699 insertions(+), 604 deletions(-) delete mode 100644 App/Models/MutagenSessionModel.cs create mode 100644 App/Models/SyncSessionModel.cs diff --git a/App/App.xaml.cs b/App/App.xaml.cs index e1c5cb4..0b159a9 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -47,6 +47,11 @@ public App() services.AddTransient(); services.AddTransient(); + // FileSyncListWindow views and view models + services.AddTransient(); + // FileSyncListMainPage is created by FileSyncListWindow. + services.AddTransient(); + // TrayWindow views and view models services.AddTransient(); services.AddTransient(); diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs index 740c7a6..8c1570f 100644 --- a/App/Converters/DependencyObjectSelector.cs +++ b/App/Converters/DependencyObjectSelector.cs @@ -12,6 +12,13 @@ namespace Coder.Desktop.App.Converters; // DependencyPropertyGenerator since it doesn't seem to work properly with // generics. +/// +/// 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. +/// +/// Key type +/// Value type public class DependencyObjectSelectorItem : DependencyObject where TK : IEquatable { @@ -40,6 +47,14 @@ public TV? Value } } +/// +/// 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. +/// +/// Key type +/// Value type [ContentProperty(Name = nameof(References))] public class DependencyObjectSelector : DependencyObject where TK : IEquatable @@ -54,7 +69,7 @@ public class DependencyObjectSelector : DependencyObject DependencyProperty.Register(nameof(SelectedKey), typeof(TK?), typeof(DependencyObjectSelector), - new PropertyMetadata(null, SelectedPropertyChanged)); + new PropertyMetadata(null, SelectedKeyPropertyChanged)); public static readonly DependencyProperty SelectedObjectProperty = DependencyProperty.Register(nameof(SelectedObject), @@ -80,12 +95,22 @@ public DependencyObjectCollection? References } } + /// + /// The key of the selected item. This should be bound to a property on + /// the model. + /// public TK? SelectedKey { get => (TK?)GetValue(SelectedKeyProperty); set => SetValue(SelectedKeyProperty, value); } + /// + /// 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. + /// public TV? SelectedObject { get => (TV?)GetValue(SelectedObjectProperty); @@ -97,15 +122,12 @@ public DependencyObjectSelector() References = []; } - private void OnVectorChangedReferences(IObservableVector sender, IVectorChangedEventArgs args) - { - UpdateSelectedObject(); - } - private void UpdateSelectedObject() { if (References != null) { + // Look for a matching item a matching key, or fallback to the null + // key. var references = References.OfType>().ToArray(); var item = references .FirstOrDefault(i => @@ -114,6 +136,9 @@ private void UpdateSelectedObject() ?? 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, @@ -131,6 +156,7 @@ private void UpdateSelectedObject() ClearValue(SelectedObjectProperty); } + // Called when the References property is replaced. private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) { var self = obj as DependencyObjectSelector; @@ -143,7 +169,14 @@ private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPr newValue.VectorChanged += self.OnVectorChangedReferences; } - private static void SelectedPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + // Called when the References collection changes without being replaced. + private void OnVectorChangedReferences(IObservableVector sender, IVectorChangedEventArgs args) + { + UpdateSelectedObject(); + } + + // Called when SelectedKey changes. + private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) { var self = obj as DependencyObjectSelector; self?.UpdateSelectedObject(); diff --git a/App/Models/MutagenSessionModel.cs b/App/Models/MutagenSessionModel.cs deleted file mode 100644 index 5e1dc37..0000000 --- a/App/Models/MutagenSessionModel.cs +++ /dev/null @@ -1,310 +0,0 @@ -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 MutagenSessionStatus -{ - Unknown, - Paused, - Error, - NeedsAttention, - Working, - Ok, -} - -public sealed class MutagenSessionModelEndpointSize -{ - 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 bool Equals(MutagenSessionModelEndpointSize other) - { - return SizeBytes == other.SizeBytes && - FileCount == other.FileCount && - DirCount == other.DirCount && - SymlinkCount == other.SymlinkCount; - } -} - -public class MutagenSessionModel -{ - public readonly string Identifier; - public readonly string Name; - - public readonly string LocalPath = "Unknown"; - public readonly string RemoteName = "unknown"; - public readonly string RemotePath = "Unknown"; - - public readonly MutagenSessionStatus Status; - public readonly string StatusString; - public readonly string StatusDescription; - - public readonly MutagenSessionModelEndpointSize MaxSize; - public readonly MutagenSessionModelEndpointSize LocalSize; - public readonly MutagenSessionModelEndpointSize RemoteSize; - - public readonly string[] Errors = []; - - public string StatusDetails - { - get - { - var str = $"{StatusString} ({Status})\n\n{StatusDescription}"; - foreach (var err in Errors) str += $"\n\n{err}"; - return str; - } - } - - public string SizeDetails - { - get - { - var str = ""; - if (!LocalSize.Equals(RemoteSize)) str = "Maximum:\n" + MaxSize.Description(" ") + "\n\n"; - - str += "Local:\n" + LocalSize.Description(" ") + "\n\n" + - "Remote:\n" + RemoteSize.Description(" "); - return str; - } - } - - // TODO: remove once we process sessions from the mutagen RPC - public MutagenSessionModel(string localPath, string remoteName, string remotePath, MutagenSessionStatus status, - string statusString, string statusDescription, string[] errors) - { - Identifier = "TODO"; - Name = "TODO"; - - LocalPath = localPath; - RemoteName = remoteName; - RemotePath = remotePath; - Status = status; - StatusString = statusString; - StatusDescription = statusDescription; - LocalSize = new MutagenSessionModelEndpointSize - { - SizeBytes = (ulong)new Random().Next(0, 1000000000), - FileCount = (ulong)new Random().Next(0, 10000), - DirCount = (ulong)new Random().Next(0, 10000), - }; - RemoteSize = new MutagenSessionModelEndpointSize - { - SizeBytes = (ulong)new Random().Next(0, 1000000000), - FileCount = (ulong)new Random().Next(0, 10000), - DirCount = (ulong)new Random().Next(0, 10000), - }; - MaxSize = new MutagenSessionModelEndpointSize - { - SizeBytes = ulong.Max(LocalSize.SizeBytes, RemoteSize.SizeBytes), - FileCount = ulong.Max(LocalSize.FileCount, RemoteSize.FileCount), - DirCount = ulong.Max(LocalSize.DirCount, RemoteSize.DirCount), - SymlinkCount = ulong.Max(LocalSize.SymlinkCount, RemoteSize.SymlinkCount), - }; - - Errors = errors; - } - - public MutagenSessionModel(State state) - { - Identifier = state.Session.Identifier; - Name = state.Session.Name; - - // If the protocol isn't what we expect for alpha or beta, show - // "unknown". - if (state.Session.Alpha.Protocol == Protocol.Local && !string.IsNullOrWhiteSpace(state.Session.Alpha.Path)) - LocalPath = state.Session.Alpha.Path; - if (state.Session.Beta.Protocol == Protocol.Ssh) - { - if (string.IsNullOrWhiteSpace(state.Session.Beta.Host)) - { - var name = state.Session.Beta.Host; - // TODO: this will need to be compatible with custom hostname - // suffixes - if (name.EndsWith(".coder")) name = name[..^6]; - RemoteName = name; - } - - if (string.IsNullOrWhiteSpace(state.Session.Beta.Path)) RemotePath = state.Session.Beta.Path; - } - - if (state.Session.Paused) - { - // Disregard any status if it's paused. - Status = MutagenSessionStatus.Paused; - StatusString = "Paused"; - StatusDescription = "The session is paused."; - } - else - { - Status = MutagenSessionModelUtils.StatusFromProtoStatus(state.Status); - StatusString = MutagenSessionModelUtils.ProtoStatusToDisplayString(state.Status); - StatusDescription = MutagenSessionModelUtils.ProtoStatusToDescription(state.Status); - } - - // If there are any conflicts, set the status to NeedsAttention. - if (state.Conflicts.Count > 0 && Status > MutagenSessionStatus.NeedsAttention) - { - Status = MutagenSessionStatus.NeedsAttention; - StatusString = "Conflicts"; - StatusDescription = "The session has conflicts that need to be resolved."; - } - - LocalSize = new MutagenSessionModelEndpointSize - { - SizeBytes = state.AlphaState.TotalFileSize, - FileCount = state.AlphaState.Files, - DirCount = state.AlphaState.Directories, - SymlinkCount = state.AlphaState.SymbolicLinks, - }; - RemoteSize = new MutagenSessionModelEndpointSize - { - SizeBytes = state.BetaState.TotalFileSize, - FileCount = state.BetaState.Files, - DirCount = state.BetaState.Directories, - SymlinkCount = state.BetaState.SymbolicLinks, - }; - MaxSize = new MutagenSessionModelEndpointSize - { - SizeBytes = ulong.Max(LocalSize.SizeBytes, RemoteSize.SizeBytes), - FileCount = ulong.Max(LocalSize.FileCount, RemoteSize.FileCount), - DirCount = ulong.Max(LocalSize.DirCount, RemoteSize.DirCount), - SymlinkCount = ulong.Max(LocalSize.SymlinkCount, RemoteSize.SymlinkCount), - }; - - // TODO: accumulate errors, there seems to be multiple fields they can - // come from - if (!string.IsNullOrWhiteSpace(state.LastError)) Errors = [state.LastError]; - } -} - -public static class MutagenSessionModelUtils -{ - public static MutagenSessionStatus StatusFromProtoStatus(Status protoStatus) - { - switch (protoStatus) - { - case Status.Disconnected: - case Status.HaltedOnRootEmptied: - case Status.HaltedOnRootDeletion: - case Status.HaltedOnRootTypeChange: - case Status.WaitingForRescan: - return MutagenSessionStatus.Error; - case Status.ConnectingAlpha: - case Status.ConnectingBeta: - case Status.Scanning: - case Status.Reconciling: - case Status.StagingAlpha: - case Status.StagingBeta: - case Status.Transitioning: - case Status.Saving: - return MutagenSessionStatus.Working; - case Status.Watching: - return MutagenSessionStatus.Ok; - default: - return MutagenSessionStatus.Unknown; - } - } - - public static string ProtoStatusToDisplayString(Status protoStatus) - { - switch (protoStatus) - { - case Status.Disconnected: - return "Disconnected"; - case Status.HaltedOnRootEmptied: - return "Halted on root emptied"; - case Status.HaltedOnRootDeletion: - return "Halted on root deletion"; - case Status.HaltedOnRootTypeChange: - return "Halted on root type change"; - case Status.ConnectingAlpha: - // This string was changed from "alpha" to "local". - return "Connecting (local)"; - case Status.ConnectingBeta: - // This string was changed from "beta" to "remote". - return "Connecting (remote)"; - case Status.Watching: - return "Watching"; - case Status.Scanning: - return "Scanning"; - case Status.WaitingForRescan: - return "Waiting for rescan"; - case Status.Reconciling: - return "Reconciling"; - case Status.StagingAlpha: - // This string was changed from "alpha" to "local". - return "Staging (local)"; - case Status.StagingBeta: - // This string was changed from "beta" to "remote". - return "Staging (remote)"; - case Status.Transitioning: - return "Transitioning"; - case Status.Saving: - return "Saving"; - default: - return protoStatus.ToString(); - } - } - - public static string ProtoStatusToDescription(Status protoStatus) - { - // These descriptions were mostly taken from the protobuf. - switch (protoStatus) - { - case Status.Disconnected: - return "The session is unpaused but not currently connected or connecting to either endpoint."; - case Status.HaltedOnRootEmptied: - return "The session is halted due to the root emptying safety check."; - case Status.HaltedOnRootDeletion: - return "The session is halted due to the root deletion safety check."; - case Status.HaltedOnRootTypeChange: - return "The session is halted due to the root type change safety check."; - case Status.ConnectingAlpha: - // This string was changed from "alpha" to "local". - return "The session is attempting to connect to the local endpoint."; - case Status.ConnectingBeta: - // This string was changed from "beta" to "remote". - return "The session is attempting to connect to the remote endpoint."; - case Status.Watching: - return "The session is watching for filesystem changes."; - case Status.Scanning: - return "The session is scanning the filesystem on each endpoint."; - case Status.WaitingForRescan: - return - "The session is waiting to retry scanning after an error during the previous scanning operation."; - case Status.Reconciling: - return "The session is performing reconciliation."; - case Status.StagingAlpha: - // This string was changed from "on alpha" to "locally". - return "The session is staging files locally."; - case Status.StagingBeta: - // This string was changed from "beta" to "the remote". - return "The session is staging files on the remote."; - case Status.Transitioning: - return "The session is performing transition operations on each endpoint."; - case Status.Saving: - return "The session is recording synchronization history to disk."; - default: - return "Unknown status message."; - } - } -} diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs new file mode 100644 index 0000000..7953720 --- /dev/null +++ b/App/Models/SyncSessionModel.cs @@ -0,0 +1,249 @@ +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, + Error, + 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 LocalPath = "Unknown"; + public readonly string RemoteName = "Unknown"; + public readonly string RemotePath = "Unknown"; + + public readonly SyncSessionStatusCategory StatusCategory; + public readonly string StatusString; + public readonly string StatusDescription; + + public readonly SyncSessionModelEndpointSize LocalSize; + public readonly SyncSessionModelEndpointSize RemoteSize; + + 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 = "Local:\n" + LocalSize.Description(" ") + "\n\n" + + "Remote:\n" + RemoteSize.Description(" "); + return str; + } + } + + // TODO: remove once we process sessions from the mutagen RPC + public SyncSessionModel(string localPath, string remoteName, string remotePath, + SyncSessionStatusCategory statusCategory, + string statusString, string statusDescription, string[] errors) + { + Identifier = "TODO"; + Name = "TODO"; + + LocalPath = localPath; + RemoteName = remoteName; + RemotePath = remotePath; + StatusCategory = statusCategory; + StatusString = statusString; + StatusDescription = statusDescription; + LocalSize = new SyncSessionModelEndpointSize + { + SizeBytes = (ulong)new Random().Next(0, 1000000000), + FileCount = (ulong)new Random().Next(0, 10000), + DirCount = (ulong)new Random().Next(0, 10000), + }; + RemoteSize = 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; + + // If the protocol isn't what we expect for alpha or beta, show + // "unknown". + if (state.Session.Alpha.Protocol == Protocol.Local && !string.IsNullOrWhiteSpace(state.Session.Alpha.Path)) + LocalPath = state.Session.Alpha.Path; + if (state.Session.Beta.Protocol == Protocol.Ssh) + { + if (string.IsNullOrWhiteSpace(state.Session.Beta.Host)) + { + var name = state.Session.Beta.Host; + // TODO: this will need to be compatible with custom hostname + // suffixes + if (name.EndsWith(".coder")) name = name[..^6]; + RemoteName = name; + } + + if (string.IsNullOrWhiteSpace(state.Session.Beta.Path)) RemotePath = state.Session.Beta.Path; + } + + if (state.Session.Paused) + { + // Disregard any status if it's paused. + StatusCategory = SyncSessionStatusCategory.Paused; + StatusString = "Paused"; + StatusDescription = "The session is paused."; + } + else + { + 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.Error; + StatusString = "Halted on root emptied"; + StatusDescription = "The session is halted due to the root emptying safety check."; + break; + case Status.HaltedOnRootDeletion: + StatusCategory = SyncSessionStatusCategory.Error; + StatusString = "Halted on root deletion"; + StatusDescription = "The session is halted due to the root deletion safety check."; + break; + case Status.HaltedOnRootTypeChange: + StatusCategory = SyncSessionStatusCategory.Error; + 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 there are any conflicts, set the status to Conflicts. + if (state.Conflicts.Count > 0 && StatusCategory > SyncSessionStatusCategory.Conflicts) + { + StatusCategory = SyncSessionStatusCategory.Conflicts; + StatusString = "Conflicts"; + StatusDescription = "The session has conflicts that need to be resolved."; + } + + LocalSize = new SyncSessionModelEndpointSize + { + SizeBytes = state.AlphaState.TotalFileSize, + FileCount = state.AlphaState.Files, + DirCount = state.AlphaState.Directories, + SymlinkCount = state.AlphaState.SymbolicLinks, + }; + RemoteSize = 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]; + } +} diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index 7f48426..fc6546e 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -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; -// -// A file synchronization session to a Coder workspace agent. -// -// -// This implementation is a placeholder while implementing the daemon lifecycle. It's implementation -// will be backed by the MutagenSDK eventually. -// -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> ListSyncSessions(CancellationToken ct); - Task CreateSyncSession(SyncSession session, CancellationToken ct); + Task> ListSyncSessions(CancellationToken ct); + Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct); - Task TerminateSyncSession(SyncSession session, CancellationToken ct); + Task TerminateSyncSession(string identifier, CancellationToken ct); // // Initializes the controller; running the daemon if there are any saved sessions. Must be called and @@ -121,7 +111,7 @@ public async ValueTask DisposeAsync() } - public async Task CreateSyncSession(SyncSession session, CancellationToken ct) + public async Task 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,10 @@ public async Task CreateSyncSession(SyncSession session, Cancellati _sessionCount += 1; } - return session; + throw new NotImplementedException(); } - - public async Task> ListSyncSessions(CancellationToken ct) + public async Task> ListSyncSessions(CancellationToken ct) { // reads of _sessionCount are atomic, so don't bother locking for this quick check. switch (_sessionCount) @@ -146,12 +135,10 @@ public async Task> ListSyncSessions(CancellationToken ct) case 0: // If we already know there are no sessions, don't start up the daemon // again. - return new List(); + return []; } - var client = await EnsureDaemon(ct); - // TODO: implement - return new List(); + throw new NotImplementedException(); } public async Task Initialize(CancellationToken ct) @@ -190,7 +177,7 @@ public async Task Initialize(CancellationToken ct) } } - 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); diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 6de170e..0521e48 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -1,5 +1,7 @@ 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; @@ -21,10 +23,23 @@ public partial class FileSyncListViewModel : ObservableObject private DispatcherQueue? _dispatcherQueue; + private readonly ISyncSessionController _syncSessionController; private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; - [ObservableProperty] public partial List Sessions { get; set; } = []; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoading))] + [NotifyPropertyChangedFor(nameof(ShowError))] + [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 Sessions { get; set; } = []; [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false; @@ -57,24 +72,31 @@ public bool NewSessionCreateEnabled } } - public FileSyncListViewModel(IRpcController rpcController, ICredentialManager credentialManager) + public bool ShowLoading => Loading && Error == null; + public bool ShowError => Error != null; + public bool ShowSessions => !Loading && Error == null; + + public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController, + ICredentialManager credentialManager) { + _syncSessionController = syncSessionController; _rpcController = rpcController; _credentialManager = credentialManager; Sessions = [ - new MutagenSessionModel(@"C:\Users\dean\git\coder-desktop-windows", "pog", "~/repos/coder-desktop-windows", - MutagenSessionStatus.Ok, "Watching", "Some description", []), - new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Paused, "Paused", + 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 MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.NeedsAttention, + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Conflicts, "Conflicts", "Some description", []), - new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Error, + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Error, "Halted on root emptied", "Some description", []), - new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Unknown, + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Unknown, "Unknown", "Some description", []), - new MutagenSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", MutagenSessionStatus.Working, + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Working, "Reconciling", "Some description", []), ]; } @@ -88,7 +110,11 @@ public void Initialize(DispatcherQueue dispatcherQueue) var rpcModel = _rpcController.GetState(); var credentialModel = _credentialManager.GetCachedCredentials(); - MaybeSendStaleEvent(rpcModel, credentialModel); + // TODO: fix this + //if (MaybeSendStaleEvent(rpcModel, credentialModel)) return; + + // TODO: Simulate loading until we have real data. + Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ => _dispatcherQueue.TryEnqueue(() => Loading = false)); } private void UpdateFromRpcModel(RpcModel rpcModel) @@ -119,24 +145,57 @@ private void UpdateFromCredentialsModel(CredentialModel credentialModel) MaybeSendStaleEvent(rpcModel, credentialModel); } - private void MaybeSendStaleEvent(RpcModel rpcModel, CredentialModel credentialModel) + private bool MaybeSendStaleEvent(RpcModel rpcModel, CredentialModel credentialModel) { var ok = rpcModel.RpcLifecycle is RpcLifecycle.Connected && rpcModel.VpnLifecycle is VpnLifecycle.Started && credentialModel.State == CredentialState.Valid; if (!ok) OnFileSyncListStale?.Invoke(); + return !ok; } private void ClearNewForm() { CreatingNewSession = false; NewSessionLocalPath = ""; - // TODO: close the dialog somehow 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> 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() { @@ -149,8 +208,6 @@ public async Task OpenLocalPathSelectDialog(Window window) var picker = new FolderPicker { SuggestedStartLocation = PickerLocationId.ComputerFolder, - // TODO: Needed? - //FileTypeFilter = { "*" }, }; var hwnd = WindowNative.GetWindowHandle(window); diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index f4c4484..532bfe4 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -9,6 +9,7 @@ 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; @@ -21,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] @@ -74,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; } @@ -272,16 +278,24 @@ public void ToggleShowAllAgents() } [RelayCommand] - public void SignOut() + public void ShowFileSyncListWindow() { - // TODO: Remove this debug workaround once we have a real UI to open - // the sync window. This lets us open the file sync list window - // in debug builds. -#if DEBUG - new FileSyncListWindow(new FileSyncListViewModel(_rpcController, _credentialManager)).Activate(); - return; -#endif + // This is safe against concurrent access since it all happens in the + // UI thread. + if (_fileSyncListWindow != null) + { + _fileSyncListWindow.Activate(); + return; + } + _fileSyncListWindow = _services.GetRequiredService(); + _fileSyncListWindow.Closed += (_, _) => _fileSyncListWindow = null; + _fileSyncListWindow.Activate(); + } + + [RelayCommand] + public void SignOut() + { if (VpnLifecycle is not VpnLifecycle.Stopped) return; _credentialManager.ClearCredentials(); diff --git a/App/Views/FileSyncListWindow.xaml b/App/Views/FileSyncListWindow.xaml index ae95e8b..070efd2 100644 --- a/App/Views/FileSyncListWindow.xaml +++ b/App/Views/FileSyncListWindow.xaml @@ -8,7 +8,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:winuiex="using:WinUIEx" mc:Ignorable="d" - Title="Coder Desktop" + Title="Coder File Sync" Width="1000" Height="300" MinWidth="1000" MinHeight="300"> diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml index e6b7db3..8080b79 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -11,259 +11,307 @@ mc:Ignorable="d" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> - - - - - - - - + - - - - - - - - - - + + - - - - - - - - - - - - - - - - + - + - - - - + + - - - - - - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - + - - + This unfortunately means we need to copy the resources and the + column definitions to each Grid. + --> + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + - - - + + - - - - - - + + + + - - + + + + + + - + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + - - - - - - - - - - - - + + + + diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml index 94c80b3..b208020 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -228,6 +228,18 @@ + + + + + + + + Date: Mon, 24 Mar 2025 19:33:26 +1100 Subject: [PATCH 3/5] FriendlyByteConverterTest.cs --- .../Converters/FriendlyByteConverterTest.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 Tests.App/Converters/FriendlyByteConverterTest.cs diff --git a/Tests.App/Converters/FriendlyByteConverterTest.cs b/Tests.App/Converters/FriendlyByteConverterTest.cs new file mode 100644 index 0000000..e75d275 --- /dev/null +++ b/Tests.App/Converters/FriendlyByteConverterTest.cs @@ -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); + Assert.That(actual, Is.EqualTo(expected), $"case ({input.GetType()}){input}"); + } + } +} From 171c9e54d6253ad70e106cc74e22e5a60989d3b7 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 24 Mar 2025 19:55:34 +1100 Subject: [PATCH 4/5] Unavailable state --- App/ViewModels/FileSyncListViewModel.cs | 58 +++++++++++++---------- App/Views/FileSyncListWindow.xaml.cs | 10 ---- App/Views/Pages/FileSyncListMainPage.xaml | 11 +++++ 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 0521e48..a790bbd 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -16,11 +16,6 @@ namespace Coder.Desktop.App.ViewModels; public partial class FileSyncListViewModel : ObservableObject { - public delegate void OnFileSyncListStaleDelegate(); - - // Triggered when the window should be closed. - public event OnFileSyncListStaleDelegate? OnFileSyncListStale; - private DispatcherQueue? _dispatcherQueue; private readonly ISyncSessionController _syncSessionController; @@ -28,12 +23,21 @@ public partial class FileSyncListViewModel : ObservableObject private readonly ICredentialManager _credentialManager; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowUnavailable))] [NotifyPropertyChangedFor(nameof(ShowLoading))] [NotifyPropertyChangedFor(nameof(ShowError))] [NotifyPropertyChangedFor(nameof(ShowSessions))] public partial bool Loading { get; set; } = true; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowUnavailable))] + [NotifyPropertyChangedFor(nameof(ShowLoading))] + [NotifyPropertyChangedFor(nameof(ShowError))] + [NotifyPropertyChangedFor(nameof(ShowSessions))] + public partial string? UnavailableMessage { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowUnavailable))] [NotifyPropertyChangedFor(nameof(ShowLoading))] [NotifyPropertyChangedFor(nameof(ShowError))] [NotifyPropertyChangedFor(nameof(ShowSessions))] @@ -72,9 +76,11 @@ public bool NewSessionCreateEnabled } } - public bool ShowLoading => Loading && Error == null; - public bool ShowError => Error != null; - public bool ShowSessions => !Loading && Error == null; + // 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) @@ -105,54 +111,56 @@ public void Initialize(DispatcherQueue dispatcherQueue) { _dispatcherQueue = dispatcherQueue; - _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel); - _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel); + _rpcController.StateChanged += RpcControllerStateChanged; + _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged; var rpcModel = _rpcController.GetState(); var credentialModel = _credentialManager.GetCachedCredentials(); - // TODO: fix this - //if (MaybeSendStaleEvent(rpcModel, credentialModel)) return; + MaybeSetUnavailableMessage(rpcModel, credentialModel); // TODO: Simulate loading until we have real data. Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ => _dispatcherQueue.TryEnqueue(() => Loading = false)); } - private void UpdateFromRpcModel(RpcModel rpcModel) + private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) { // Ensure we're on the UI thread. if (_dispatcherQueue == null) return; if (!_dispatcherQueue.HasThreadAccess) { - _dispatcherQueue.TryEnqueue(() => UpdateFromRpcModel(rpcModel)); + _dispatcherQueue.TryEnqueue(() => RpcControllerStateChanged(sender, rpcModel)); return; } var credentialModel = _credentialManager.GetCachedCredentials(); - MaybeSendStaleEvent(rpcModel, credentialModel); + MaybeSetUnavailableMessage(rpcModel, credentialModel); } - private void UpdateFromCredentialsModel(CredentialModel credentialModel) + private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel) { // Ensure we're on the UI thread. if (_dispatcherQueue == null) return; if (!_dispatcherQueue.HasThreadAccess) { - _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel)); + _dispatcherQueue.TryEnqueue(() => CredentialManagerCredentialsChanged(sender, credentialModel)); return; } var rpcModel = _rpcController.GetState(); - MaybeSendStaleEvent(rpcModel, credentialModel); + MaybeSetUnavailableMessage(rpcModel, credentialModel); } - private bool MaybeSendStaleEvent(RpcModel rpcModel, CredentialModel credentialModel) + private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel) { - var ok = rpcModel.RpcLifecycle is RpcLifecycle.Connected - && rpcModel.VpnLifecycle is VpnLifecycle.Started - && credentialModel.State == CredentialState.Valid; - - if (!ok) OnFileSyncListStale?.Invoke(); - return !ok; + 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() diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index 0e784dc..27d386d 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -12,8 +12,6 @@ public sealed partial class FileSyncListWindow : WindowEx public FileSyncListWindow(FileSyncListViewModel viewModel) { ViewModel = viewModel; - ViewModel.OnFileSyncListStale += ViewModel_OnFileSyncListStale; - InitializeComponent(); SystemBackdrop = new DesktopAcrylicBackdrop(); @@ -22,12 +20,4 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) this.CenterOnScreen(); } - - private void ViewModel_OnFileSyncListStale() - { - // TODO: Fix this. I got a weird memory corruption exception when it - // fired immediately on start. Maybe we should schedule it for - // next frame or something. - //Close() - } } diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml index 8080b79..82d99e6 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -12,6 +12,17 @@ Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> + + + + + Date: Tue, 25 Mar 2025 22:12:16 +1100 Subject: [PATCH 5/5] Comments --- App/Models/SyncSessionModel.cs | 237 +++++++++++----------- App/Services/MutagenController.cs | 7 +- App/ViewModels/FileSyncListViewModel.cs | 13 +- App/Views/Pages/FileSyncListMainPage.xaml | 11 +- 4 files changed, 140 insertions(+), 128 deletions(-) diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs index 7953720..d8d261d 100644 --- a/App/Models/SyncSessionModel.cs +++ b/App/Models/SyncSessionModel.cs @@ -12,7 +12,15 @@ 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, @@ -42,16 +50,17 @@ public class SyncSessionModel public readonly string Identifier; public readonly string Name; - public readonly string LocalPath = "Unknown"; - public readonly string RemoteName = "Unknown"; - public readonly string RemotePath = "Unknown"; + 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 LocalSize; - public readonly SyncSessionModelEndpointSize RemoteSize; + public readonly SyncSessionModelEndpointSize AlphaSize; + public readonly SyncSessionModelEndpointSize BetaSize; public readonly string[] Errors = []; @@ -69,33 +78,34 @@ public string SizeDetails { get { - var str = "Local:\n" + LocalSize.Description(" ") + "\n\n" + - "Remote:\n" + RemoteSize.Description(" "); + 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 localPath, string remoteName, string remotePath, + public SyncSessionModel(string alphaPath, string betaName, string betaPath, SyncSessionStatusCategory statusCategory, string statusString, string statusDescription, string[] errors) { Identifier = "TODO"; Name = "TODO"; - LocalPath = localPath; - RemoteName = remoteName; - RemotePath = remotePath; + AlphaName = "Local"; + AlphaPath = alphaPath; + BetaName = betaName; + BetaPath = betaPath; StatusCategory = statusCategory; StatusString = statusString; StatusDescription = statusDescription; - LocalSize = new SyncSessionModelEndpointSize + AlphaSize = new SyncSessionModelEndpointSize { SizeBytes = (ulong)new Random().Next(0, 1000000000), FileCount = (ulong)new Random().Next(0, 10000), DirCount = (ulong)new Random().Next(0, 10000), }; - RemoteSize = new SyncSessionModelEndpointSize + BetaSize = new SyncSessionModelEndpointSize { SizeBytes = (ulong)new Random().Next(0, 1000000000), FileCount = (ulong)new Random().Next(0, 10000), @@ -110,116 +120,99 @@ public SyncSessionModel(State state) Identifier = state.Session.Identifier; Name = state.Session.Name; - // If the protocol isn't what we expect for alpha or beta, show - // "unknown". - if (state.Session.Alpha.Protocol == Protocol.Local && !string.IsNullOrWhiteSpace(state.Session.Alpha.Path)) - LocalPath = state.Session.Alpha.Path; - if (state.Session.Beta.Protocol == Protocol.Ssh) + (AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha); + (BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta); + + switch (state.Status) { - if (string.IsNullOrWhiteSpace(state.Session.Beta.Host)) - { - var name = state.Session.Beta.Host; - // TODO: this will need to be compatible with custom hostname - // suffixes - if (name.EndsWith(".coder")) name = name[..^6]; - RemoteName = name; - } - - if (string.IsNullOrWhiteSpace(state.Session.Beta.Path)) RemotePath = state.Session.Beta.Path; + 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 (state.Session.Paused) + // If the session is paused, override all other statuses except Halted. + if (state.Session.Paused && StatusCategory is not SyncSessionStatusCategory.Halted) { - // Disregard any status if it's paused. StatusCategory = SyncSessionStatusCategory.Paused; StatusString = "Paused"; StatusDescription = "The session is paused."; } - else - { - 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.Error; - StatusString = "Halted on root emptied"; - StatusDescription = "The session is halted due to the root emptying safety check."; - break; - case Status.HaltedOnRootDeletion: - StatusCategory = SyncSessionStatusCategory.Error; - StatusString = "Halted on root deletion"; - StatusDescription = "The session is halted due to the root deletion safety check."; - break; - case Status.HaltedOnRootTypeChange: - StatusCategory = SyncSessionStatusCategory.Error; - 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 there are any conflicts, set the status to Conflicts. + // If there are any conflicts, override Working and Ok. if (state.Conflicts.Count > 0 && StatusCategory > SyncSessionStatusCategory.Conflicts) { StatusCategory = SyncSessionStatusCategory.Conflicts; @@ -227,14 +220,14 @@ public SyncSessionModel(State state) StatusDescription = "The session has conflicts that need to be resolved."; } - LocalSize = new SyncSessionModelEndpointSize + AlphaSize = new SyncSessionModelEndpointSize { SizeBytes = state.AlphaState.TotalFileSize, FileCount = state.AlphaState.Files, DirCount = state.AlphaState.Directories, SymlinkCount = state.AlphaState.SymbolicLinks, }; - RemoteSize = new SyncSessionModelEndpointSize + BetaSize = new SyncSessionModelEndpointSize { SizeBytes = state.BetaState.TotalFileSize, FileCount = state.BetaState.Files, @@ -246,4 +239,16 @@ public SyncSessionModel(State state) // 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); + } } diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index fc6546e..4bd5688 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -122,7 +122,9 @@ public async Task CreateSyncSession(CreateSyncSessionRequest r _sessionCount += 1; } - throw new NotImplementedException(); + // TODO: implement this + return new SyncSessionModel(@"C:\path", "remote", "~/path", SyncSessionStatusCategory.Ok, "Watching", + "Description", []); } public async Task> ListSyncSessions(CancellationToken ct) @@ -138,7 +140,8 @@ public async Task> ListSyncSessions(CancellationTo return []; } - throw new NotImplementedException(); + // TODO: implement this + return []; } public async Task Initialize(CancellationToken ct) diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index a790bbd..45ca318 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -27,17 +27,14 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ShowLoading))] [NotifyPropertyChangedFor(nameof(ShowError))] [NotifyPropertyChangedFor(nameof(ShowSessions))] - public partial bool Loading { get; set; } = true; + public partial string? UnavailableMessage { get; set; } = null; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowUnavailable))] [NotifyPropertyChangedFor(nameof(ShowLoading))] - [NotifyPropertyChangedFor(nameof(ShowError))] [NotifyPropertyChangedFor(nameof(ShowSessions))] - public partial string? UnavailableMessage { get; set; } = null; + public partial bool Loading { get; set; } = true; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowUnavailable))] [NotifyPropertyChangedFor(nameof(ShowLoading))] [NotifyPropertyChangedFor(nameof(ShowError))] [NotifyPropertyChangedFor(nameof(ShowSessions))] @@ -98,8 +95,10 @@ public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcC "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.Error, + 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, @@ -110,6 +109,8 @@ public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcC 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; diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml index 82d99e6..768e396 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -157,19 +157,19 @@ @@ -184,6 +184,9 @@ + @@ -206,7 +209,7 @@ pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy