From b73ebe07dd5754fc32e8c8bd672364d444505bd7 Mon Sep 17 00:00:00 2001 From: Dean Sheather <dean@deansheather.com> 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 @@ <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.1" /> <PackageReference Include="Microsoft.Extensions.Options" Version="9.0.1" /> <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.250108002" /> + <PackageReference Include="WinUIEx" Version="2.5.1" /> </ItemGroup> <ItemGroup> 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 @@ <Application x:Class="Coder.Desktop.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:converters="using:Coder.Desktop.App.Converters"> <Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> </ResourceDictionary.MergedDictionaries> + + <converters:InverseBoolConverter x:Key="InverseBoolConverter" /> + <converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> + <converters:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisibilityConverter" /> + <converters:FriendlyByteConverter x:Key="FriendlyByteConverter" /> </ResourceDictionary> </Application.Resources> </Application> 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 /// <summary> /// SizedFrame extends Frame by adding a SizeChanged event, which will be triggered when: -/// - The contained Page's content's size changes -/// - We switch to a different page. -/// +/// - The contained Page's content's size changes +/// - We switch to a different page. /// Sadly this is necessary because Window.Content.SizeChanged doesn't trigger when the Page's content changes. /// </summary> public class SizedFrame : Frame 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<TK, TV> : DependencyObject + where TK : IEquatable<TK> +{ + public static readonly DependencyProperty KeyProperty = + DependencyProperty.Register(nameof(Key), + typeof(TK?), + typeof(DependencyObjectSelectorItem<TK, TV>), + new PropertyMetadata(null)); + + public static readonly DependencyProperty ValueProperty = + DependencyProperty.Register(nameof(Value), + typeof(TV?), + typeof(DependencyObjectSelectorItem<TK, TV>), + new PropertyMetadata(null)); + + public TK? Key + { + get => (TK?)GetValue(KeyProperty); + set => SetValue(KeyProperty, value); + } + + public TV? Value + { + get => (TV?)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } +} + +[ContentProperty(Name = nameof(References))] +public class DependencyObjectSelector<TK, TV> : DependencyObject + where TK : IEquatable<TK> +{ + public static readonly DependencyProperty ReferencesProperty = + DependencyProperty.Register(nameof(References), + typeof(DependencyObjectCollection), + typeof(DependencyObjectSelector<TK, TV>), + new PropertyMetadata(null, ReferencesPropertyChanged)); + + public static readonly DependencyProperty SelectedKeyProperty = + DependencyProperty.Register(nameof(SelectedKey), + typeof(TK?), + typeof(DependencyObjectSelector<TK, TV>), + new PropertyMetadata(null, SelectedPropertyChanged)); + + public static readonly DependencyProperty SelectedObjectProperty = + DependencyProperty.Register(nameof(SelectedObject), + typeof(TV?), + typeof(DependencyObjectSelector<TK, TV>), + new PropertyMetadata(null)); + + public DependencyObjectCollection? References + { + get => (DependencyObjectCollection?)GetValue(ReferencesProperty); + set + { + // Ensure unique keys and that the values are DependencyObjectSelectorItem<K, V>. + if (value != null) + { + var items = value.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray(); + var keys = items.Select(i => i.Key).Distinct().ToArray(); + if (keys.Length != value.Count) + throw new ArgumentException("ObservableCollection Keys must be unique."); + } + + SetValue(ReferencesProperty, value); + } + } + + 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<DependencyObject> sender, IVectorChangedEventArgs args) + { + UpdateSelectedObject(); + } + + private void UpdateSelectedObject() + { + if (References != null) + { + var references = References.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray(); + var item = references + .FirstOrDefault(i => + (i.Key == null && SelectedKey == null) || + (i.Key != null && SelectedKey != null && i.Key!.Equals(SelectedKey!))) + ?? references.FirstOrDefault(i => i.Key == null); + if (item is not null) + { + BindingOperations.SetBinding + ( + this, + SelectedObjectProperty, + new Binding + { + Source = item, + Path = new PropertyPath(nameof(DependencyObjectSelectorItem<TK, TV>.Value)), + } + ); + return; + } + } + + ClearValue(SelectedObjectProperty); + } + + private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + var self = obj as DependencyObjectSelector<TK, TV>; + if (self == null) return; + var oldValue = args.OldValue as DependencyObjectCollection; + if (oldValue != null) + oldValue.VectorChanged -= self.OnVectorChangedReferences; + var newValue = args.NewValue as DependencyObjectCollection; + if (newValue != null) + newValue.VectorChanged += self.OnVectorChangedReferences; + } + + private static void SelectedPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + var self = obj as DependencyObjectSelector<TK, TV>; + self?.UpdateSelectedObject(); + } +} + +public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem<string, Brush>; + +public sealed class StringToBrushSelector : DependencyObjectSelector<string, Brush>; 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<MutagenSessionModel> 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 @@ +<?xml version="1.0" encoding="utf-8"?> + +<winuiex:WindowEx + x:Class="Coder.Desktop.App.Views.FileSyncListWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:winuiex="using:WinUIEx" + mc:Ignorable="d" + Title="Coder Desktop" + Width="1000" Height="300" + MinWidth="1000" MinHeight="300"> + + <Window.SystemBackdrop> + <DesktopAcrylicBackdrop /> + </Window.SystemBackdrop> + + <Frame x:Name="RootFrame" /> +</winuiex:WindowEx> 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 @@ +<?xml version="1.0" encoding="utf-8"?> + +<Page + x:Class="Coder.Desktop.App.Views.Pages.FileSyncListMainPage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:models="using:Coder.Desktop.App.Models" + xmlns:converters="using:Coder.Desktop.App.Converters" + mc:Ignorable="d" + Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> + + <ScrollView> + <StackPanel Orientation="Vertical" Padding="30,15"> + <!-- + We use separate grids for the header and each child because WinUI 3 + doesn't support having a dynamic row count. + + This unfortunately means we need to copy the resources and the + column definitions to each Grid. + --> + <Grid Margin="0,0,0,5"> + <Grid.Resources> + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" /> + </Style> + <Style TargetType="Border"> + <Setter Property="Padding" Value="40,0,0,0" /> + </Style> + </Grid.Resources> + + <!-- Cannot use "Auto" as it won't work for multiple Grids. --> + <Grid.ColumnDefinitions> + <!-- Icon column: 14 + 5 padding + 14 + 10 padding --> + <ColumnDefinition Width="43" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="120" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> + </Grid.ColumnDefinitions> + + <Border Grid.Column="1" Padding="10,0,0,0"> + <TextBlock Text="Local Path" /> + </Border> + <Border Grid.Column="2"> + <TextBlock Text="Workspace" /> + </Border> + <Border Grid.Column="3"> + <TextBlock Text="Remote Path" /> + </Border> + <Border Grid.Column="4"> + <TextBlock Text="Status" /> + </Border> + <Border Grid.Column="5"> + <TextBlock Text="Size" /> + </Border> + </Grid> + + <Border + Height="1" + Margin="-30,0,-30,5" + Background="{ThemeResource ControlElevationBorderBrush}" /> + + <ItemsRepeater ItemsSource="{x:Bind ViewModel.Sessions, Mode=OneWay}"> + <ItemsRepeater.Layout> + <StackLayout Orientation="Vertical" /> + </ItemsRepeater.Layout> + + <ItemsRepeater.ItemTemplate> + <DataTemplate x:DataType="models:MutagenSessionModel"> + <Grid Margin="0,10"> + <!-- These are (mostly) from the header Grid and should be copied here --> + <Grid.Resources> + <Style TargetType="Border"> + <Setter Property="Padding" Value="40,0,0,0" /> + </Style> + </Grid.Resources> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="43" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="120" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> + </Grid.ColumnDefinitions> + + <Border Grid.Column="0" Padding="0" HorizontalAlignment="Right"> + <StackPanel Orientation="Horizontal"> + <HyperlinkButton Padding="0" Margin="0,0,5,0"> + <FontIcon Glyph="" FontSize="15" + Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" /> + </HyperlinkButton> + <HyperlinkButton Padding="0"> + <FontIcon Glyph="" FontSize="15" + Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" /> + </HyperlinkButton> + </StackPanel> + </Border> + <Border Grid.Column="1" Padding="10,0,0,0"> + <TextBlock + Text="{x:Bind LocalPath}" + TextTrimming="CharacterEllipsis" + IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> + </Border> + <Border Grid.Column="2"> + <TextBlock + Text="{x:Bind RemoteName}" + TextTrimming="CharacterEllipsis" + IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> + </Border> + <Border Grid.Column="3"> + <TextBlock + Text="{x:Bind RemotePath}" + TextTrimming="CharacterEllipsis" + IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> + </Border> + <Border Grid.Column="4"> + <Border.Resources> + <converters:StringToBrushSelector + x:Key="StatusColor" + SelectedKey="{x:Bind Path=Status}"> + + <converters:StringToBrushSelectorItem + Value="{ThemeResource SystemFillColorCriticalBrush}" /> + <converters:StringToBrushSelectorItem + Key="Paused" + Value="{ThemeResource SystemControlForegroundBaseMediumBrush}" /> + <converters:StringToBrushSelectorItem + Key="Error" + Value="{ThemeResource SystemFillColorCriticalBrush}" /> + <converters:StringToBrushSelectorItem + Key="NeedsAttention" + Value="{ThemeResource SystemFillColorCautionBrush}" /> + <converters:StringToBrushSelectorItem + Key="Working" + Value="{ThemeResource SystemFillColorAttentionBrush}" /> + <converters:StringToBrushSelectorItem + Key="Ok" + Value="{ThemeResource DefaultTextForegroundThemeBrush}" /> + </converters:StringToBrushSelector> + </Border.Resources> + <TextBlock + Text="{x:Bind StatusString}" + TextTrimming="CharacterEllipsis" + Foreground="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}" + ToolTipService.ToolTip="{x:Bind StatusDetails}" /> + </Border> + <Border Grid.Column="5"> + <TextBlock + Text="{x:Bind MaxSize.SizeBytes, Converter={StaticResource FriendlyByteConverter}}" + ToolTipService.ToolTip="{x:Bind SizeDetails}" /> + </Border> + </Grid> + </DataTemplate> + </ItemsRepeater.ItemTemplate> + </ItemsRepeater> + + <!-- "New Sync" button --> + <!-- + HACK: this has some random numbers for padding and margins. Since + we need to align the icon and the text to the two grid columns + above (but still have it be within the same button), this is the + best solution I could come up with. + --> + <HyperlinkButton + Margin="13,5,0,0" + Command="{x:Bind ViewModel.StartCreatingNewSessionCommand}" + Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource InverseBoolToVisibilityConverter}, Mode=OneWay}"> + + <StackPanel Orientation="Horizontal"> + <FontIcon + FontSize="18" + Margin="0,0,10,0" + Glyph="" + Foreground="{ThemeResource SystemFillColorSuccessBrush}" /> + <TextBlock + Text="New Sync" + Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" /> + </StackPanel> + </HyperlinkButton> + + <!-- New item Grid --> + <Grid + Margin="0,10" + Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> + + <!-- These are (mostly) from the header Grid and should be copied here --> + <Grid.Resources> + <Style TargetType="Border"> + <Setter Property="Padding" Value="40,0,0,0" /> + </Style> + </Grid.Resources> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="43" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="120" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> + </Grid.ColumnDefinitions> + + <Border Grid.Column="0" Padding="0"> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> + <!-- TODO: gray out the button if the form is not filled out correctly --> + <HyperlinkButton + Padding="0" + Margin="0,0,5,0" + Command="{x:Bind ViewModel.ConfirmNewSessionCommand}"> + + <FontIcon Glyph="" FontSize="15" + Foreground="{ThemeResource SystemFillColorSuccessBrush}" /> + </HyperlinkButton> + <HyperlinkButton + Padding="0" + Command="{x:Bind ViewModel.CancelNewSessionCommand}"> + + <FontIcon Glyph="" FontSize="15" + Foreground="{ThemeResource SystemFillColorCriticalBrush}" /> + </HyperlinkButton> + </StackPanel> + </Border> + <Border Grid.Column="1" Padding="10,0,0,0"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + + <TextBox + Grid.Column="0" + Margin="0,0,5,0" + VerticalAlignment="Stretch" + Text="{x:Bind ViewModel.NewSessionLocalPath, Mode=TwoWay}" /> + + <Button + Grid.Column="1" + IsEnabled="{x:Bind ViewModel.NewSessionLocalPathDialogOpen, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}" + Command="{x:Bind OpenLocalPathSelectDialogCommand}" + VerticalAlignment="Stretch"> + + <FontIcon Glyph="" FontSize="13" /> + </Button> + </Grid> + </Border> + <Border Grid.Column="2"> + <!-- TODO: use a combo box for workspace agents --> + <!-- + <ComboBox + ItemsSource="{x:Bind WorkspaceAgents}" + VerticalAlignment="Stretch" + HorizontalAlignment="Stretch" /> + --> + <TextBox + VerticalAlignment="Stretch" + HorizontalAlignment="Stretch" + Text="{x:Bind ViewModel.NewSessionRemoteName, Mode=TwoWay}" /> + </Border> + <Border Grid.Column="3"> + <TextBox + VerticalAlignment="Stretch" + HorizontalAlignment="Stretch" + Text="{x:Bind ViewModel.NewSessionRemotePath, Mode=TwoWay}" /> + </Border> + </Grid> + </StackPanel> + </ScrollView> +</Page> 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"> <Page.Resources> - <converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> - <converters:VpnLifecycleToBoolConverter x:Key="ConnectingBoolConverter" Unknown="true" Starting="true" Stopping="true" /> <converters:VpnLifecycleToBoolConverter x:Key="NotConnectingBoolConverter" Started="true" Stopped="true" /> <converters:VpnLifecycleToBoolConverter x:Key="StoppedBoolConverter" Stopped="true" /> - <converters:AgentStatusToColorConverter x:Key="AgentStatusToColorConverter" /> <converters:BoolToObjectConverter x:Key="ShowMoreLessTextConverter" TrueValue="Show less" FalseValue="Show more" /> </Page.Resources> @@ -118,6 +115,34 @@ HorizontalAlignment="Stretch" Spacing="10"> + <StackPanel.Resources> + <converters:StringToBrushSelector + x:Key="StatusColor" + SelectedKey="{x:Bind Path=ConnectionStatus, Mode=OneWay}"> + + <converters:StringToBrushSelectorItem> + <converters:StringToBrushSelectorItem.Value> + <SolidColorBrush Color="#8e8e93" /> + </converters:StringToBrushSelectorItem.Value> + </converters:StringToBrushSelectorItem> + <converters:StringToBrushSelectorItem Key="Red"> + <converters:StringToBrushSelectorItem.Value> + <SolidColorBrush Color="#ff3b30" /> + </converters:StringToBrushSelectorItem.Value> + </converters:StringToBrushSelectorItem> + <converters:StringToBrushSelectorItem Key="Yellow"> + <converters:StringToBrushSelectorItem.Value> + <SolidColorBrush Color="#ffcc01" /> + </converters:StringToBrushSelectorItem.Value> + </converters:StringToBrushSelectorItem> + <converters:StringToBrushSelectorItem Key="Green"> + <converters:StringToBrushSelectorItem.Value> + <SolidColorBrush Color="#34c759" /> + </converters:StringToBrushSelectorItem.Value> + </converters:StringToBrushSelectorItem> + </converters:StringToBrushSelector> + </StackPanel.Resources> + <Canvas HorizontalAlignment="Center" VerticalAlignment="Center" @@ -125,7 +150,7 @@ Margin="0,1,0,0"> <Ellipse - Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}" + Fill="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}" Opacity="0.2" Width="14" Height="14" @@ -133,7 +158,7 @@ Canvas.Top="0" /> <Ellipse - Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}" + Fill="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}" Width="8" Height="8" VerticalAlignment="Center" diff --git a/App/packages.lock.json b/App/packages.lock.json index 8988638..405ea61 100644 --- a/App/packages.lock.json +++ b/App/packages.lock.json @@ -85,6 +85,15 @@ "Microsoft.Windows.SDK.BuildTools": "10.0.22621.756" } }, + "WinUIEx": { + "type": "Direct", + "requested": "[2.5.1, )", + "resolved": "2.5.1", + "contentHash": "ihW4bA2quKbwWBOl5Uu80jBZagc4cT4E6CdExmvSZ05Qwz0jgoGyZuSTKcU9Uz7lZlQij3KxNor0dGXNUyIV9Q==", + "dependencies": { + "Microsoft.WindowsAppSDK": "1.6.240829007" + } + }, "Google.Protobuf": { "type": "Transitive", "resolved": "3.29.3", From 5ddda6f7f6a9b64d58be22debde1e5f3361fafc2 Mon Sep 17 00:00:00 2001 From: Dean Sheather <dean@deansheather.com> 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<SignInViewModel>(); services.AddTransient<SignInWindow>(); + // FileSyncListWindow views and view models + services.AddTransient<FileSyncListViewModel>(); + // FileSyncListMainPage is created by FileSyncListWindow. + services.AddTransient<FileSyncListWindow>(); + // TrayWindow views and view models services.AddTransient<TrayWindowLoadingPage>(); services.AddTransient<TrayWindowDisconnectedViewModel>(); 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. +/// <summary> +/// An item in a DependencyObjectSelector. Each item has a key and a value. +/// The default item in a DependencyObjectSelector will be the only item +/// with a null key. +/// </summary> +/// <typeparam name="TK">Key type</typeparam> +/// <typeparam name="TV">Value type</typeparam> public class DependencyObjectSelectorItem<TK, TV> : DependencyObject where TK : IEquatable<TK> { @@ -40,6 +47,14 @@ public TV? Value } } +/// <summary> +/// Allows selecting between multiple value references based on a selected +/// key. This allows for dynamic mapping of model values to other objects. +/// The main use case is for selecting between other bound values, which +/// you cannot do with a simple ValueConverter. +/// </summary> +/// <typeparam name="TK">Key type</typeparam> +/// <typeparam name="TV">Value type</typeparam> [ContentProperty(Name = nameof(References))] public class DependencyObjectSelector<TK, TV> : DependencyObject where TK : IEquatable<TK> @@ -54,7 +69,7 @@ public class DependencyObjectSelector<TK, TV> : DependencyObject DependencyProperty.Register(nameof(SelectedKey), typeof(TK?), typeof(DependencyObjectSelector<TK, TV>), - new PropertyMetadata(null, SelectedPropertyChanged)); + new PropertyMetadata(null, SelectedKeyPropertyChanged)); public static readonly DependencyProperty SelectedObjectProperty = DependencyProperty.Register(nameof(SelectedObject), @@ -80,12 +95,22 @@ public DependencyObjectCollection? References } } + /// <summary> + /// The key of the selected item. This should be bound to a property on + /// the model. + /// </summary> public TK? SelectedKey { get => (TK?)GetValue(SelectedKeyProperty); set => SetValue(SelectedKeyProperty, value); } + /// <summary> + /// The selected object. This can be read from to get the matching + /// object for the selected key. If the selected key doesn't match any + /// object, this will be the value of the null key. If there is no null + /// key, this will be null. + /// </summary> public TV? SelectedObject { get => (TV?)GetValue(SelectedObjectProperty); @@ -97,15 +122,12 @@ public DependencyObjectSelector() References = []; } - private void OnVectorChangedReferences(IObservableVector<DependencyObject> 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<DependencyObjectSelectorItem<TK, TV>>().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<TK, TV>; @@ -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<DependencyObject> sender, IVectorChangedEventArgs args) + { + UpdateSelectedObject(); + } + + // Called when SelectedKey changes. + private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) { var self = obj as DependencyObjectSelector<TK, TV>; self?.UpdateSelectedObject(); 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; -// <summary> -// A file synchronization session to a Coder workspace agent. -// </summary> -// <remarks> -// This implementation is a placeholder while implementing the daemon lifecycle. It's implementation -// will be backed by the MutagenSDK eventually. -// </remarks> -public class SyncSession +public class CreateSyncSessionRequest { - public string name { get; init; } = ""; - public string localPath { get; init; } = ""; - public string workspace { get; init; } = ""; - public string agent { get; init; } = ""; - public string remotePath { get; init; } = ""; + // TODO: this } public interface ISyncSessionController { - Task<List<SyncSession>> ListSyncSessions(CancellationToken ct); - Task<SyncSession> CreateSyncSession(SyncSession session, CancellationToken ct); + Task<IEnumerable<SyncSessionModel>> ListSyncSessions(CancellationToken ct); + Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct); - Task TerminateSyncSession(SyncSession session, CancellationToken ct); + Task TerminateSyncSession(string identifier, CancellationToken ct); // <summary> // Initializes the controller; running the daemon if there are any saved sessions. Must be called and @@ -121,7 +111,7 @@ public async ValueTask DisposeAsync() } - public async Task<SyncSession> CreateSyncSession(SyncSession session, CancellationToken ct) + public async Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct) { // reads of _sessionCount are atomic, so don't bother locking for this quick check. if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first"); @@ -132,11 +122,10 @@ public async Task<SyncSession> CreateSyncSession(SyncSession session, Cancellati _sessionCount += 1; } - return session; + throw new NotImplementedException(); } - - public async Task<List<SyncSession>> ListSyncSessions(CancellationToken ct) + public async Task<IEnumerable<SyncSessionModel>> 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<List<SyncSession>> ListSyncSessions(CancellationToken ct) case 0: // If we already know there are no sessions, don't start up the daemon // again. - return new List<SyncSession>(); + return []; } - var client = await EnsureDaemon(ct); - // TODO: implement - return new List<SyncSession>(); + 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<MutagenSessionModel> 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<SyncSessionModel> 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<IEnumerable<SyncSessionModel>> t) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => HandleList(t)); + return; + } + + if (t.IsCompletedSuccessfully) + { + Sessions = t.Result.ToList(); + Loading = false; + return; + } + + Error = "Could not list sync sessions: "; + if (t.IsCanceled) Error += new TaskCanceledException(); + else if (t.IsFaulted) Error += t.Exception; + else Error += "no successful result or error"; + Loading = false; + } + [RelayCommand] private void StartCreatingNewSession() { @@ -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>(); + _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}"> - <ScrollView> - <StackPanel Orientation="Vertical" Padding="30,15"> - <!-- - We use separate grids for the header and each child because WinUI 3 - doesn't support having a dynamic row count. + <Grid> + <Grid + Visibility="{x:Bind ViewModel.ShowLoading, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" + Padding="60,60" + HorizontalAlignment="Center" + VerticalAlignment="Center"> - This unfortunately means we need to copy the resources and the - column definitions to each Grid. - --> - <Grid Margin="0,0,0,5"> - <Grid.Resources> - <Style TargetType="TextBlock"> - <Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" /> - </Style> - <Style TargetType="Border"> - <Setter Property="Padding" Value="40,0,0,0" /> - </Style> - </Grid.Resources> + <ProgressRing + Width="32" + Height="32" + Margin="0,30" + HorizontalAlignment="Center" /> - <!-- Cannot use "Auto" as it won't work for multiple Grids. --> - <Grid.ColumnDefinitions> - <!-- Icon column: 14 + 5 padding + 14 + 10 padding --> - <ColumnDefinition Width="43" /> - <ColumnDefinition Width="2*" MinWidth="200" /> - <ColumnDefinition Width="1*" MinWidth="120" /> - <ColumnDefinition Width="2*" MinWidth="200" /> - <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> - <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> - </Grid.ColumnDefinitions> + <TextBlock HorizontalAlignment="Center" Text="Loading sync sessions..." /> + </Grid> - <Border Grid.Column="1" Padding="10,0,0,0"> - <TextBlock Text="Local Path" /> - </Border> - <Border Grid.Column="2"> - <TextBlock Text="Workspace" /> - </Border> - <Border Grid.Column="3"> - <TextBlock Text="Remote Path" /> - </Border> - <Border Grid.Column="4"> - <TextBlock Text="Status" /> - </Border> - <Border Grid.Column="5"> - <TextBlock Text="Size" /> - </Border> - </Grid> + <StackPanel + Visibility="{x:Bind ViewModel.ShowError, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" + Orientation="Vertical" + Padding="20"> - <Border - Height="1" - Margin="-30,0,-30,5" - Background="{ThemeResource ControlElevationBorderBrush}" /> + <TextBlock + Margin="0,0,0,20" + Foreground="Red" + TextWrapping="Wrap" + Text="{x:Bind ViewModel.Error, Mode=OneWay}" /> - <ItemsRepeater ItemsSource="{x:Bind ViewModel.Sessions, Mode=OneWay}"> - <ItemsRepeater.Layout> - <StackLayout Orientation="Vertical" /> - </ItemsRepeater.Layout> + <Button Command="{x:Bind ViewModel.ReloadSessionsCommand, Mode=OneWay}"> + <TextBlock Text="Reload" /> + </Button> + </StackPanel> - <ItemsRepeater.ItemTemplate> - <DataTemplate x:DataType="models:MutagenSessionModel"> - <Grid Margin="0,10"> - <!-- These are (mostly) from the header Grid and should be copied here --> - <Grid.Resources> - <Style TargetType="Border"> - <Setter Property="Padding" Value="40,0,0,0" /> - </Style> - </Grid.Resources> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="43" /> - <ColumnDefinition Width="2*" MinWidth="200" /> - <ColumnDefinition Width="1*" MinWidth="120" /> - <ColumnDefinition Width="2*" MinWidth="200" /> - <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> - <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> - </Grid.ColumnDefinitions> + <!-- This grid lets us fix the header and only scroll the content. --> + <Grid + Visibility="{x:Bind ViewModel.ShowSessions, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> - <Border Grid.Column="0" Padding="0" HorizontalAlignment="Right"> - <StackPanel Orientation="Horizontal"> - <HyperlinkButton Padding="0" Margin="0,0,5,0"> - <FontIcon Glyph="" FontSize="15" - Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" /> - </HyperlinkButton> - <HyperlinkButton Padding="0"> - <FontIcon Glyph="" FontSize="15" - Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" /> - </HyperlinkButton> - </StackPanel> - </Border> - <Border Grid.Column="1" Padding="10,0,0,0"> - <TextBlock - Text="{x:Bind LocalPath}" - TextTrimming="CharacterEllipsis" - IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> - </Border> - <Border Grid.Column="2"> - <TextBlock - Text="{x:Bind RemoteName}" - TextTrimming="CharacterEllipsis" - IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> - </Border> - <Border Grid.Column="3"> - <TextBlock - Text="{x:Bind RemotePath}" - TextTrimming="CharacterEllipsis" - IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> - </Border> - <Border Grid.Column="4"> - <Border.Resources> - <converters:StringToBrushSelector - x:Key="StatusColor" - SelectedKey="{x:Bind Path=Status}"> + <StackPanel + Grid.Row="0" + Orientation="Vertical" + Padding="30,15,30,0"> - <converters:StringToBrushSelectorItem - Value="{ThemeResource SystemFillColorCriticalBrush}" /> - <converters:StringToBrushSelectorItem - Key="Paused" - Value="{ThemeResource SystemControlForegroundBaseMediumBrush}" /> - <converters:StringToBrushSelectorItem - Key="Error" - Value="{ThemeResource SystemFillColorCriticalBrush}" /> - <converters:StringToBrushSelectorItem - Key="NeedsAttention" - Value="{ThemeResource SystemFillColorCautionBrush}" /> - <converters:StringToBrushSelectorItem - Key="Working" - Value="{ThemeResource SystemFillColorAttentionBrush}" /> - <converters:StringToBrushSelectorItem - Key="Ok" - Value="{ThemeResource DefaultTextForegroundThemeBrush}" /> - </converters:StringToBrushSelector> - </Border.Resources> - <TextBlock - Text="{x:Bind StatusString}" - TextTrimming="CharacterEllipsis" - Foreground="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}" - ToolTipService.ToolTip="{x:Bind StatusDetails}" /> - </Border> - <Border Grid.Column="5"> - <TextBlock - Text="{x:Bind MaxSize.SizeBytes, Converter={StaticResource FriendlyByteConverter}}" - ToolTipService.ToolTip="{x:Bind SizeDetails}" /> - </Border> - </Grid> - </DataTemplate> - </ItemsRepeater.ItemTemplate> - </ItemsRepeater> + <!-- + We use separate grids for the header and each child because WinUI 3 + doesn't support having a dynamic row count. - <!-- "New Sync" button --> - <!-- - HACK: this has some random numbers for padding and margins. Since - we need to align the icon and the text to the two grid columns - above (but still have it be within the same button), this is the - best solution I could come up with. - --> - <HyperlinkButton - Margin="13,5,0,0" - Command="{x:Bind ViewModel.StartCreatingNewSessionCommand}" - Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource InverseBoolToVisibilityConverter}, Mode=OneWay}"> + This unfortunately means we need to copy the resources and the + column definitions to each Grid. + --> + <Grid Margin="0,0,0,5"> + <Grid.Resources> + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" /> + </Style> + <Style TargetType="Border"> + <Setter Property="Padding" Value="40,0,0,0" /> + </Style> + </Grid.Resources> - <StackPanel Orientation="Horizontal"> - <FontIcon - FontSize="18" - Margin="0,0,10,0" - Glyph="" - Foreground="{ThemeResource SystemFillColorSuccessBrush}" /> - <TextBlock - Text="New Sync" - Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" /> - </StackPanel> - </HyperlinkButton> + <!-- Cannot use "Auto" as it won't work for multiple Grids. --> + <Grid.ColumnDefinitions> + <!-- Icon column: 14 + 5 padding + 14 + 10 padding --> + <ColumnDefinition Width="43" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="120" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> + </Grid.ColumnDefinitions> + + <Border Grid.Column="1" Padding="10,0,0,0"> + <TextBlock Text="Local Path" /> + </Border> + <Border Grid.Column="2"> + <TextBlock Text="Workspace" /> + </Border> + <Border Grid.Column="3"> + <TextBlock Text="Remote Path" /> + </Border> + <Border Grid.Column="4"> + <TextBlock Text="Status" /> + </Border> + <Border Grid.Column="5"> + <TextBlock Text="Size" /> + </Border> + </Grid> + + <Border + Height="1" + Margin="-30,0,-30,5" + Background="{ThemeResource ControlElevationBorderBrush}" /> + </StackPanel> - <!-- New item Grid --> - <Grid - Margin="0,10" - Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> + <ScrollView Grid.Row="1"> + <StackPanel Orientation="Vertical" Padding="30,0,30,15"> + <ItemsRepeater ItemsSource="{x:Bind ViewModel.Sessions, Mode=OneWay}"> + <ItemsRepeater.Layout> + <StackLayout Orientation="Vertical" /> + </ItemsRepeater.Layout> - <!-- These are (mostly) from the header Grid and should be copied here --> - <Grid.Resources> - <Style TargetType="Border"> - <Setter Property="Padding" Value="40,0,0,0" /> - </Style> - </Grid.Resources> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="43" /> - <ColumnDefinition Width="2*" MinWidth="200" /> - <ColumnDefinition Width="1*" MinWidth="120" /> - <ColumnDefinition Width="2*" MinWidth="200" /> - <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> - <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> - </Grid.ColumnDefinitions> + <ItemsRepeater.ItemTemplate> + <DataTemplate x:DataType="models:SyncSessionModel"> + <Grid Margin="0,10"> + <!-- These are (mostly) from the header Grid and should be copied here --> + <Grid.Resources> + <Style TargetType="Border"> + <Setter Property="Padding" Value="40,0,0,0" /> + </Style> + </Grid.Resources> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="43" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="120" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> + </Grid.ColumnDefinitions> + + <Border Grid.Column="0" Padding="0" HorizontalAlignment="Right"> + <StackPanel Orientation="Horizontal"> + <HyperlinkButton Padding="0" Margin="0,0,5,0"> + <FontIcon Glyph="" FontSize="15" + Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" /> + </HyperlinkButton> + <HyperlinkButton Padding="0"> + <FontIcon Glyph="" FontSize="15" + Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" /> + </HyperlinkButton> + </StackPanel> + </Border> + <Border Grid.Column="1" Padding="10,0,0,0"> + <TextBlock + Text="{x:Bind LocalPath}" + TextTrimming="CharacterEllipsis" + IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> + </Border> + <Border Grid.Column="2"> + <TextBlock + Text="{x:Bind RemoteName}" + TextTrimming="CharacterEllipsis" + IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> + </Border> + <Border Grid.Column="3"> + <TextBlock + Text="{x:Bind RemotePath}" + TextTrimming="CharacterEllipsis" + IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> + </Border> + <Border Grid.Column="4"> + <Border.Resources> + <converters:StringToBrushSelector + x:Key="StatusColor" + SelectedKey="{x:Bind Path=StatusCategory}"> + + <converters:StringToBrushSelectorItem + Value="{ThemeResource SystemFillColorCriticalBrush}" /> + <converters:StringToBrushSelectorItem + Key="Paused" + Value="{ThemeResource SystemControlForegroundBaseMediumBrush}" /> + <converters:StringToBrushSelectorItem + Key="Error" + Value="{ThemeResource SystemFillColorCriticalBrush}" /> + <converters:StringToBrushSelectorItem + Key="Conflicts" + Value="{ThemeResource SystemFillColorCautionBrush}" /> + <converters:StringToBrushSelectorItem + Key="Working" + Value="{ThemeResource SystemFillColorAttentionBrush}" /> + <converters:StringToBrushSelectorItem + Key="Ok" + Value="{ThemeResource DefaultTextForegroundThemeBrush}" /> + </converters:StringToBrushSelector> + </Border.Resources> + <TextBlock + Text="{x:Bind StatusString}" + TextTrimming="CharacterEllipsis" + Foreground="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}" + ToolTipService.ToolTip="{x:Bind StatusDetails}" /> + </Border> + <Border Grid.Column="5"> + <TextBlock + Text="{x:Bind LocalSize.SizeBytes, Converter={StaticResource FriendlyByteConverter}}" + ToolTipService.ToolTip="{x:Bind SizeDetails}" /> + </Border> + </Grid> + </DataTemplate> + </ItemsRepeater.ItemTemplate> + </ItemsRepeater> + + <!-- "New Sync" button --> + <!-- + HACK: this has some random numbers for padding and margins. Since + we need to align the icon and the text to the two grid columns + above (but still have it be within the same button), this is the + best solution I could come up with. + --> + <HyperlinkButton + Margin="13,5,0,0" + Command="{x:Bind ViewModel.StartCreatingNewSessionCommand}" + Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource InverseBoolToVisibilityConverter}, Mode=OneWay}"> - <Border Grid.Column="0" Padding="0"> - <StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> - <!-- TODO: gray out the button if the form is not filled out correctly --> - <HyperlinkButton - Padding="0" - Margin="0,0,5,0" - Command="{x:Bind ViewModel.ConfirmNewSessionCommand}"> + <StackPanel Orientation="Horizontal"> + <FontIcon + FontSize="18" + Margin="0,0,10,0" + Glyph="" + Foreground="{ThemeResource SystemFillColorSuccessBrush}" /> + <TextBlock + Text="New Sync" + Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" /> + </StackPanel> + </HyperlinkButton> - <FontIcon Glyph="" FontSize="15" - Foreground="{ThemeResource SystemFillColorSuccessBrush}" /> - </HyperlinkButton> - <HyperlinkButton - Padding="0" - Command="{x:Bind ViewModel.CancelNewSessionCommand}"> + <!-- New item Grid --> + <Grid + Margin="0,10" + Visibility="{x:Bind ViewModel.CreatingNewSession, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> - <FontIcon Glyph="" FontSize="15" - Foreground="{ThemeResource SystemFillColorCriticalBrush}" /> - </HyperlinkButton> - </StackPanel> - </Border> - <Border Grid.Column="1" Padding="10,0,0,0"> - <Grid> + <!-- These are (mostly) from the header Grid and should be copied here --> + <Grid.Resources> + <Style TargetType="Border"> + <Setter Property="Padding" Value="40,0,0,0" /> + </Style> + </Grid.Resources> <Grid.ColumnDefinitions> - <ColumnDefinition Width="*" /> - <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="43" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="120" /> + <ColumnDefinition Width="2*" MinWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> + <ColumnDefinition Width="1*" MinWidth="100" MaxWidth="200" /> </Grid.ColumnDefinitions> - <TextBox - Grid.Column="0" - Margin="0,0,5,0" - VerticalAlignment="Stretch" - Text="{x:Bind ViewModel.NewSessionLocalPath, Mode=TwoWay}" /> + <Border Grid.Column="0" Padding="0"> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> + <!-- TODO: gray out the button if the form is not filled out correctly --> + <HyperlinkButton + Padding="0" + Margin="0,0,5,0" + Command="{x:Bind ViewModel.ConfirmNewSessionCommand}"> + + <FontIcon Glyph="" FontSize="15" + Foreground="{ThemeResource SystemFillColorSuccessBrush}" /> + </HyperlinkButton> + <HyperlinkButton + Padding="0" + Command="{x:Bind ViewModel.CancelNewSessionCommand}"> + + <FontIcon Glyph="" FontSize="15" + Foreground="{ThemeResource SystemFillColorCriticalBrush}" /> + </HyperlinkButton> + </StackPanel> + </Border> + <Border Grid.Column="1" Padding="10,0,0,0"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + + <TextBox + Grid.Column="0" + Margin="0,0,5,0" + VerticalAlignment="Stretch" + Text="{x:Bind ViewModel.NewSessionLocalPath, Mode=TwoWay}" /> - <Button - Grid.Column="1" - IsEnabled="{x:Bind ViewModel.NewSessionLocalPathDialogOpen, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}" - Command="{x:Bind OpenLocalPathSelectDialogCommand}" - VerticalAlignment="Stretch"> + <Button + Grid.Column="1" + IsEnabled="{x:Bind ViewModel.NewSessionLocalPathDialogOpen, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}" + Command="{x:Bind OpenLocalPathSelectDialogCommand}" + VerticalAlignment="Stretch"> - <FontIcon Glyph="" FontSize="13" /> - </Button> + <FontIcon Glyph="" FontSize="13" /> + </Button> + </Grid> + </Border> + <Border Grid.Column="2"> + <!-- TODO: use a combo box for workspace agents --> + <!-- + <ComboBox + ItemsSource="{x:Bind WorkspaceAgents}" + VerticalAlignment="Stretch" + HorizontalAlignment="Stretch" /> + --> + <TextBox + VerticalAlignment="Stretch" + HorizontalAlignment="Stretch" + Text="{x:Bind ViewModel.NewSessionRemoteName, Mode=TwoWay}" /> + </Border> + <Border Grid.Column="3"> + <TextBox + VerticalAlignment="Stretch" + HorizontalAlignment="Stretch" + Text="{x:Bind ViewModel.NewSessionRemotePath, Mode=TwoWay}" /> + </Border> </Grid> - </Border> - <Border Grid.Column="2"> - <!-- TODO: use a combo box for workspace agents --> - <!-- - <ComboBox - ItemsSource="{x:Bind WorkspaceAgents}" - VerticalAlignment="Stretch" - HorizontalAlignment="Stretch" /> - --> - <TextBox - VerticalAlignment="Stretch" - HorizontalAlignment="Stretch" - Text="{x:Bind ViewModel.NewSessionRemoteName, Mode=TwoWay}" /> - </Border> - <Border Grid.Column="3"> - <TextBox - VerticalAlignment="Stretch" - HorizontalAlignment="Stretch" - Text="{x:Bind ViewModel.NewSessionRemotePath, Mode=TwoWay}" /> - </Border> - </Grid> - </StackPanel> - </ScrollView> + </StackPanel> + </ScrollView> + </Grid> + </Grid> </Page> 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 @@ <controls:HorizontalRule /> + <HyperlinkButton + Command="{x:Bind ViewModel.ShowFileSyncListWindowCommand, Mode=OneWay}" + Margin="-12,0" + HorizontalAlignment="Stretch" + HorizontalContentAlignment="Left"> + + <!-- TODO: status icon if there is a problem --> + <TextBlock Text="File sync" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" /> + </HyperlinkButton> + + <controls:HorizontalRule /> + <HyperlinkButton Command="{x:Bind ViewModel.SignOutCommand, Mode=OneWay}" IsEnabled="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource StoppedBoolConverter}, Mode=OneWay}" diff --git a/Tests.App/Services/MutagenControllerTest.cs b/Tests.App/Services/MutagenControllerTest.cs index 40d6a48..be054a7 100644 --- a/Tests.App/Services/MutagenControllerTest.cs +++ b/Tests.App/Services/MutagenControllerTest.cs @@ -90,12 +90,12 @@ public async Task CreateRestartsDaemon(CancellationToken ct) await using (var controller = new MutagenController(_mutagenBinaryPath, dataDirectory)) { await controller.Initialize(ct); - await controller.CreateSyncSession(new SyncSession(), ct); + await controller.CreateSyncSession(new CreateSyncSessionRequest(), ct); } var logPath = Path.Combine(dataDirectory, "daemon.log"); Assert.That(File.Exists(logPath)); - var logLines = File.ReadAllLines(logPath); + var logLines = await File.ReadAllLinesAsync(logPath, ct); // Here we're going to use the log to verify the daemon was started 2 times. // slightly brittle, but unlikely this log line will change. @@ -114,7 +114,7 @@ public async Task Orphaned(CancellationToken ct) { controller1 = new MutagenController(_mutagenBinaryPath, dataDirectory); await controller1.Initialize(ct); - await controller1.CreateSyncSession(new SyncSession(), ct); + await controller1.CreateSyncSession(new CreateSyncSessionRequest(), ct); controller2 = new MutagenController(_mutagenBinaryPath, dataDirectory); await controller2.Initialize(ct); @@ -127,7 +127,7 @@ public async Task Orphaned(CancellationToken ct) var logPath = Path.Combine(dataDirectory, "daemon.log"); Assert.That(File.Exists(logPath)); - var logLines = File.ReadAllLines(logPath); + var logLines = await File.ReadAllLinesAsync(logPath, ct); // Here we're going to use the log to verify the daemon was started 3 times. // slightly brittle, but unlikely this log line will change. From d1990260a6d37cb696e2508ba3e58584f7242b89 Mon Sep 17 00:00:00 2001 From: Dean Sheather <dean@deansheather.com> 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 <dean@deansheather.com> 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}"> <Grid> + <Grid + Visibility="{x:Bind ViewModel.ShowUnavailable, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" + Padding="60,60" + HorizontalAlignment="Center" + VerticalAlignment="Center"> + + <TextBlock + HorizontalAlignment="Center" + Text="{x:Bind ViewModel.UnavailableMessage, Mode=OneWay}" /> + </Grid> + <Grid Visibility="{x:Bind ViewModel.ShowLoading, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" Padding="60,60" From 4c37cabf51b4de0050f4f715ff8796eec38d8b9d Mon Sep 17 00:00:00 2001 From: Dean Sheather <dean@deansheather.com> 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<SyncSessionModel> 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<IEnumerable<SyncSessionModel>> ListSyncSessions(CancellationToken ct) @@ -138,7 +140,8 @@ public async Task<IEnumerable<SyncSessionModel>> 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 @@ </Border> <Border Grid.Column="1" Padding="10,0,0,0"> <TextBlock - Text="{x:Bind LocalPath}" + Text="{x:Bind AlphaPath}" TextTrimming="CharacterEllipsis" IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> </Border> <Border Grid.Column="2"> <TextBlock - Text="{x:Bind RemoteName}" + Text="{x:Bind BetaName}" TextTrimming="CharacterEllipsis" IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> </Border> <Border Grid.Column="3"> <TextBlock - Text="{x:Bind RemotePath}" + Text="{x:Bind BetaPath}" TextTrimming="CharacterEllipsis" IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> </Border> @@ -184,6 +184,9 @@ <converters:StringToBrushSelectorItem Key="Paused" Value="{ThemeResource SystemControlForegroundBaseMediumBrush}" /> + <converters:StringToBrushSelectorItem + Key="Halted" + Value="{ThemeResource SystemFillColorCriticalBrush}" /> <converters:StringToBrushSelectorItem Key="Error" Value="{ThemeResource SystemFillColorCriticalBrush}" /> @@ -206,7 +209,7 @@ </Border> <Border Grid.Column="5"> <TextBlock - Text="{x:Bind LocalSize.SizeBytes, Converter={StaticResource FriendlyByteConverter}}" + Text="{x:Bind AlphaSize.SizeBytes, Converter={StaticResource FriendlyByteConverter}}" ToolTipService.ToolTip="{x:Bind SizeDetails}" /> </Border> </Grid>