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 @@