diff --git a/App/App.csproj b/App/App.csproj index 8b7e810..2a15166 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -65,6 +65,7 @@ + diff --git a/App/App.xaml b/App/App.xaml index a5b6d8b..c614e0e 100644 --- a/App/App.xaml +++ b/App/App.xaml @@ -3,12 +3,18 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:converters="using:Coder.Desktop.App.Converters"> + + + + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs index e1c5cb4..0b159a9 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -47,6 +47,11 @@ public App() services.AddTransient(); services.AddTransient(); + // FileSyncListWindow views and view models + services.AddTransient(); + // FileSyncListMainPage is created by FileSyncListWindow. + services.AddTransient(); + // TrayWindow views and view models services.AddTransient(); services.AddTransient(); diff --git a/App/Controls/SizedFrame.cs b/App/Controls/SizedFrame.cs index a666c55..bd2462b 100644 --- a/App/Controls/SizedFrame.cs +++ b/App/Controls/SizedFrame.cs @@ -12,9 +12,8 @@ public class SizedFrameEventArgs : EventArgs /// /// SizedFrame extends Frame by adding a SizeChanged event, which will be triggered when: -/// - The contained Page's content's size changes -/// - We switch to a different page. -/// +/// - The contained Page's content's size changes +/// - We switch to a different page. /// Sadly this is necessary because Window.Content.SizeChanged doesn't trigger when the Page's content changes. /// public class SizedFrame : Frame diff --git a/App/Converters/AgentStatusToColorConverter.cs b/App/Converters/AgentStatusToColorConverter.cs deleted file mode 100644 index ebcabdd..0000000 --- a/App/Converters/AgentStatusToColorConverter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using Windows.UI; -using Coder.Desktop.App.ViewModels; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Media; - -namespace Coder.Desktop.App.Converters; - -public class AgentStatusToColorConverter : IValueConverter -{ - private static readonly SolidColorBrush Green = new(Color.FromArgb(255, 52, 199, 89)); - private static readonly SolidColorBrush Yellow = new(Color.FromArgb(255, 255, 204, 1)); - private static readonly SolidColorBrush Red = new(Color.FromArgb(255, 255, 59, 48)); - private static readonly SolidColorBrush Gray = new(Color.FromArgb(255, 142, 142, 147)); - - public object Convert(object value, Type targetType, object parameter, string language) - { - if (value is not AgentConnectionStatus status) return Gray; - - return status switch - { - AgentConnectionStatus.Green => Green, - AgentConnectionStatus.Yellow => Yellow, - AgentConnectionStatus.Red => Red, - _ => Gray, - }; - } - - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - throw new NotImplementedException(); - } -} diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs new file mode 100644 index 0000000..8c1570f --- /dev/null +++ b/App/Converters/DependencyObjectSelector.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using Windows.Foundation.Collections; +using Windows.UI.Xaml.Markup; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; + +namespace Coder.Desktop.App.Converters; + +// This file uses manual DependencyProperty properties rather than +// DependencyPropertyGenerator since it doesn't seem to work properly with +// generics. + +/// +/// An item in a DependencyObjectSelector. Each item has a key and a value. +/// The default item in a DependencyObjectSelector will be the only item +/// with a null key. +/// +/// Key type +/// Value type +public class DependencyObjectSelectorItem : DependencyObject + where TK : IEquatable +{ + public static readonly DependencyProperty KeyProperty = + DependencyProperty.Register(nameof(Key), + typeof(TK?), + typeof(DependencyObjectSelectorItem), + new PropertyMetadata(null)); + + public static readonly DependencyProperty ValueProperty = + DependencyProperty.Register(nameof(Value), + typeof(TV?), + typeof(DependencyObjectSelectorItem), + new PropertyMetadata(null)); + + public TK? Key + { + get => (TK?)GetValue(KeyProperty); + set => SetValue(KeyProperty, value); + } + + public TV? Value + { + get => (TV?)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } +} + +/// +/// Allows selecting between multiple value references based on a selected +/// key. This allows for dynamic mapping of model values to other objects. +/// The main use case is for selecting between other bound values, which +/// you cannot do with a simple ValueConverter. +/// +/// Key type +/// Value type +[ContentProperty(Name = nameof(References))] +public class DependencyObjectSelector : DependencyObject + where TK : IEquatable +{ + public static readonly DependencyProperty ReferencesProperty = + DependencyProperty.Register(nameof(References), + typeof(DependencyObjectCollection), + typeof(DependencyObjectSelector), + new PropertyMetadata(null, ReferencesPropertyChanged)); + + public static readonly DependencyProperty SelectedKeyProperty = + DependencyProperty.Register(nameof(SelectedKey), + typeof(TK?), + typeof(DependencyObjectSelector), + new PropertyMetadata(null, SelectedKeyPropertyChanged)); + + public static readonly DependencyProperty SelectedObjectProperty = + DependencyProperty.Register(nameof(SelectedObject), + typeof(TV?), + typeof(DependencyObjectSelector), + new PropertyMetadata(null)); + + public DependencyObjectCollection? References + { + get => (DependencyObjectCollection?)GetValue(ReferencesProperty); + set + { + // Ensure unique keys and that the values are DependencyObjectSelectorItem. + if (value != null) + { + var items = value.OfType>().ToArray(); + var keys = items.Select(i => i.Key).Distinct().ToArray(); + if (keys.Length != value.Count) + throw new ArgumentException("ObservableCollection Keys must be unique."); + } + + SetValue(ReferencesProperty, value); + } + } + + /// + /// The key of the selected item. This should be bound to a property on + /// the model. + /// + public TK? SelectedKey + { + get => (TK?)GetValue(SelectedKeyProperty); + set => SetValue(SelectedKeyProperty, value); + } + + /// + /// The selected object. This can be read from to get the matching + /// object for the selected key. If the selected key doesn't match any + /// object, this will be the value of the null key. If there is no null + /// key, this will be null. + /// + public TV? SelectedObject + { + get => (TV?)GetValue(SelectedObjectProperty); + set => SetValue(SelectedObjectProperty, value); + } + + public DependencyObjectSelector() + { + References = []; + } + + private void UpdateSelectedObject() + { + if (References != null) + { + // Look for a matching item a matching key, or fallback to the null + // key. + var references = References.OfType>().ToArray(); + var item = references + .FirstOrDefault(i => + (i.Key == null && SelectedKey == null) || + (i.Key != null && SelectedKey != null && i.Key!.Equals(SelectedKey!))) + ?? references.FirstOrDefault(i => i.Key == null); + if (item is not null) + { + // Bind the SelectedObject property to the reference's Value. + // If the underlying Value changes, it will propagate to the + // SelectedObject. + BindingOperations.SetBinding + ( + this, + SelectedObjectProperty, + new Binding + { + Source = item, + Path = new PropertyPath(nameof(DependencyObjectSelectorItem.Value)), + } + ); + return; + } + } + + ClearValue(SelectedObjectProperty); + } + + // Called when the References property is replaced. + private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + var self = obj as DependencyObjectSelector; + if (self == null) return; + var oldValue = args.OldValue as DependencyObjectCollection; + if (oldValue != null) + oldValue.VectorChanged -= self.OnVectorChangedReferences; + var newValue = args.NewValue as DependencyObjectCollection; + if (newValue != null) + newValue.VectorChanged += self.OnVectorChangedReferences; + } + + // Called when the References collection changes without being replaced. + private void OnVectorChangedReferences(IObservableVector sender, IVectorChangedEventArgs args) + { + UpdateSelectedObject(); + } + + // Called when SelectedKey changes. + private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + var self = obj as DependencyObjectSelector; + self?.UpdateSelectedObject(); + } +} + +public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem; + +public sealed class StringToBrushSelector : DependencyObjectSelector; diff --git a/App/Converters/FriendlyByteConverter.cs b/App/Converters/FriendlyByteConverter.cs new file mode 100644 index 0000000..c2bce4e --- /dev/null +++ b/App/Converters/FriendlyByteConverter.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.UI.Xaml.Data; + +namespace Coder.Desktop.App.Converters; + +public class FriendlyByteConverter : IValueConverter +{ + private static readonly string[] Suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; + + public object Convert(object value, Type targetType, object parameter, string language) + { + switch (value) + { + case int i: + if (i < 0) i = 0; + return FriendlyBytes((ulong)i); + case uint ui: + return FriendlyBytes(ui); + case long l: + if (l < 0) l = 0; + return FriendlyBytes((ulong)l); + case ulong ul: + return FriendlyBytes(ul); + default: + return FriendlyBytes(0); + } + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + + public static string FriendlyBytes(ulong bytes) + { + if (bytes == 0) + return $"0 {Suffixes[0]}"; + + var place = System.Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024))); + var num = Math.Round(bytes / Math.Pow(1024, place), 1); + return $"{num} {Suffixes[place]}"; + } +} diff --git a/App/Converters/InverseBoolConverter.cs b/App/Converters/InverseBoolConverter.cs new file mode 100644 index 0000000..927b420 --- /dev/null +++ b/App/Converters/InverseBoolConverter.cs @@ -0,0 +1,17 @@ +using System; +using Microsoft.UI.Xaml.Data; + +namespace Coder.Desktop.App.Converters; + +public class InverseBoolConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + return value is false; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} diff --git a/App/Converters/InverseBoolToVisibilityConverter.cs b/App/Converters/InverseBoolToVisibilityConverter.cs new file mode 100644 index 0000000..dd9c864 --- /dev/null +++ b/App/Converters/InverseBoolToVisibilityConverter.cs @@ -0,0 +1,12 @@ +using Microsoft.UI.Xaml; + +namespace Coder.Desktop.App.Converters; + +public partial class InverseBoolToVisibilityConverter : BoolToObjectConverter +{ + public InverseBoolToVisibilityConverter() + { + TrueValue = Visibility.Collapsed; + FalseValue = Visibility.Visible; + } +} diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs new file mode 100644 index 0000000..d8d261d --- /dev/null +++ b/App/Models/SyncSessionModel.cs @@ -0,0 +1,254 @@ +using System; +using Coder.Desktop.App.Converters; +using Coder.Desktop.MutagenSdk.Proto.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Url; + +namespace Coder.Desktop.App.Models; + +// This is a much slimmer enum than the original enum from Mutagen and only +// contains the overarching states that we care about from a code perspective. +// We still store the original state in the model for rendering purposes. +public enum SyncSessionStatusCategory +{ + Unknown, + Paused, + + // Halted is a combination of Error and Paused. If the session + // automatically pauses due to a safety check, we want to show it as an + // error, but also show that it can be resumed. + Halted, + Error, + + // If there are any conflicts, the state will be set to Conflicts, + // overriding Working and Ok. + Conflicts, + Working, + Ok, +} + +public sealed class SyncSessionModelEndpointSize +{ + public ulong SizeBytes { get; init; } + public ulong FileCount { get; init; } + public ulong DirCount { get; init; } + public ulong SymlinkCount { get; init; } + + public string Description(string linePrefix = "") + { + var str = + $"{linePrefix}{FriendlyByteConverter.FriendlyBytes(SizeBytes)}\n" + + $"{linePrefix}{FileCount:N0} files\n" + + $"{linePrefix}{DirCount:N0} directories"; + if (SymlinkCount > 0) str += $"\n{linePrefix} {SymlinkCount:N0} symlinks"; + + return str; + } +} + +public class SyncSessionModel +{ + public readonly string Identifier; + public readonly string Name; + + public readonly string AlphaName; + public readonly string AlphaPath; + public readonly string BetaName; + public readonly string BetaPath; + + public readonly SyncSessionStatusCategory StatusCategory; + public readonly string StatusString; + public readonly string StatusDescription; + + public readonly SyncSessionModelEndpointSize AlphaSize; + public readonly SyncSessionModelEndpointSize BetaSize; + + public readonly string[] Errors = []; + + public string StatusDetails + { + get + { + var str = $"{StatusString} ({StatusCategory})\n\n{StatusDescription}"; + foreach (var err in Errors) str += $"\n\n{err}"; + return str; + } + } + + public string SizeDetails + { + get + { + var str = "Alpha:\n" + AlphaSize.Description(" ") + "\n\n" + + "Remote:\n" + BetaSize.Description(" "); + return str; + } + } + + // TODO: remove once we process sessions from the mutagen RPC + public SyncSessionModel(string alphaPath, string betaName, string betaPath, + SyncSessionStatusCategory statusCategory, + string statusString, string statusDescription, string[] errors) + { + Identifier = "TODO"; + Name = "TODO"; + + AlphaName = "Local"; + AlphaPath = alphaPath; + BetaName = betaName; + BetaPath = betaPath; + StatusCategory = statusCategory; + StatusString = statusString; + StatusDescription = statusDescription; + AlphaSize = new SyncSessionModelEndpointSize + { + SizeBytes = (ulong)new Random().Next(0, 1000000000), + FileCount = (ulong)new Random().Next(0, 10000), + DirCount = (ulong)new Random().Next(0, 10000), + }; + BetaSize = new SyncSessionModelEndpointSize + { + SizeBytes = (ulong)new Random().Next(0, 1000000000), + FileCount = (ulong)new Random().Next(0, 10000), + DirCount = (ulong)new Random().Next(0, 10000), + }; + + Errors = errors; + } + + public SyncSessionModel(State state) + { + Identifier = state.Session.Identifier; + Name = state.Session.Name; + + (AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha); + (BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta); + + switch (state.Status) + { + case Status.Disconnected: + StatusCategory = SyncSessionStatusCategory.Error; + StatusString = "Disconnected"; + StatusDescription = + "The session is unpaused but not currently connected or connecting to either endpoint."; + break; + case Status.HaltedOnRootEmptied: + StatusCategory = SyncSessionStatusCategory.Halted; + StatusString = "Halted on root emptied"; + StatusDescription = "The session is halted due to the root emptying safety check."; + break; + case Status.HaltedOnRootDeletion: + StatusCategory = SyncSessionStatusCategory.Halted; + StatusString = "Halted on root deletion"; + StatusDescription = "The session is halted due to the root deletion safety check."; + break; + case Status.HaltedOnRootTypeChange: + StatusCategory = SyncSessionStatusCategory.Halted; + StatusString = "Halted on root type change"; + StatusDescription = "The session is halted due to the root type change safety check."; + break; + case Status.ConnectingAlpha: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Connecting (alpha)"; + StatusDescription = "The session is attempting to connect to the alpha endpoint."; + break; + case Status.ConnectingBeta: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Connecting (beta)"; + StatusDescription = "The session is attempting to connect to the beta endpoint."; + break; + case Status.Watching: + StatusCategory = SyncSessionStatusCategory.Ok; + StatusString = "Watching"; + StatusDescription = "The session is watching for filesystem changes."; + break; + case Status.Scanning: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Scanning"; + StatusDescription = "The session is scanning the filesystem on each endpoint."; + break; + case Status.WaitingForRescan: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Waiting for rescan"; + StatusDescription = + "The session is waiting to retry scanning after an error during the previous scanning operation."; + break; + case Status.Reconciling: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Reconciling"; + StatusDescription = "The session is performing reconciliation."; + break; + case Status.StagingAlpha: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Staging (alpha)"; + StatusDescription = "The session is staging files on alpha."; + break; + case Status.StagingBeta: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Staging (beta)"; + StatusDescription = "The session is staging files on beta."; + break; + case Status.Transitioning: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Transitioning"; + StatusDescription = "The session is performing transition operations on each endpoint."; + break; + case Status.Saving: + StatusCategory = SyncSessionStatusCategory.Working; + StatusString = "Saving"; + StatusDescription = "The session is recording synchronization history to disk."; + break; + default: + StatusCategory = SyncSessionStatusCategory.Unknown; + StatusString = state.Status.ToString(); + StatusDescription = "Unknown status message."; + break; + } + + // If the session is paused, override all other statuses except Halted. + if (state.Session.Paused && StatusCategory is not SyncSessionStatusCategory.Halted) + { + StatusCategory = SyncSessionStatusCategory.Paused; + StatusString = "Paused"; + StatusDescription = "The session is paused."; + } + + // If there are any conflicts, override Working and Ok. + if (state.Conflicts.Count > 0 && StatusCategory > SyncSessionStatusCategory.Conflicts) + { + StatusCategory = SyncSessionStatusCategory.Conflicts; + StatusString = "Conflicts"; + StatusDescription = "The session has conflicts that need to be resolved."; + } + + AlphaSize = new SyncSessionModelEndpointSize + { + SizeBytes = state.AlphaState.TotalFileSize, + FileCount = state.AlphaState.Files, + DirCount = state.AlphaState.Directories, + SymlinkCount = state.AlphaState.SymbolicLinks, + }; + BetaSize = new SyncSessionModelEndpointSize + { + SizeBytes = state.BetaState.TotalFileSize, + FileCount = state.BetaState.Files, + DirCount = state.BetaState.Directories, + SymlinkCount = state.BetaState.SymbolicLinks, + }; + + // TODO: accumulate errors, there seems to be multiple fields they can + // come from + if (!string.IsNullOrWhiteSpace(state.LastError)) Errors = [state.LastError]; + } + + private static (string, string) NameAndPathFromUrl(URL url) + { + var name = "Local"; + var path = !string.IsNullOrWhiteSpace(url.Path) ? url.Path : "Unknown"; + + if (url.Protocol is not Protocol.Local) + name = !string.IsNullOrWhiteSpace(url.Host) ? url.Host : "Unknown"; + if (string.IsNullOrWhiteSpace(url.Host)) name = url.Host; + + return (name, path); + } +} diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index 7f48426..4bd5688 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -5,6 +5,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using Coder.Desktop.App.Models; using Coder.Desktop.MutagenSdk; using Coder.Desktop.MutagenSdk.Proto.Selection; using Coder.Desktop.MutagenSdk.Proto.Service.Daemon; @@ -15,28 +16,17 @@ namespace Coder.Desktop.App.Services; -// -// A file synchronization session to a Coder workspace agent. -// -// -// This implementation is a placeholder while implementing the daemon lifecycle. It's implementation -// will be backed by the MutagenSDK eventually. -// -public class SyncSession +public class CreateSyncSessionRequest { - public string name { get; init; } = ""; - public string localPath { get; init; } = ""; - public string workspace { get; init; } = ""; - public string agent { get; init; } = ""; - public string remotePath { get; init; } = ""; + // TODO: this } public interface ISyncSessionController { - Task> ListSyncSessions(CancellationToken ct); - Task CreateSyncSession(SyncSession session, CancellationToken ct); + Task> ListSyncSessions(CancellationToken ct); + Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct); - Task TerminateSyncSession(SyncSession session, CancellationToken ct); + Task TerminateSyncSession(string identifier, CancellationToken ct); // // Initializes the controller; running the daemon if there are any saved sessions. Must be called and @@ -121,7 +111,7 @@ public async ValueTask DisposeAsync() } - public async Task CreateSyncSession(SyncSession session, CancellationToken ct) + public async Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct) { // reads of _sessionCount are atomic, so don't bother locking for this quick check. if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first"); @@ -132,11 +122,12 @@ public async Task CreateSyncSession(SyncSession session, Cancellati _sessionCount += 1; } - return session; + // TODO: implement this + return new SyncSessionModel(@"C:\path", "remote", "~/path", SyncSessionStatusCategory.Ok, "Watching", + "Description", []); } - - public async Task> ListSyncSessions(CancellationToken ct) + public async Task> ListSyncSessions(CancellationToken ct) { // reads of _sessionCount are atomic, so don't bother locking for this quick check. switch (_sessionCount) @@ -146,12 +137,11 @@ public async Task> ListSyncSessions(CancellationToken ct) case 0: // If we already know there are no sessions, don't start up the daemon // again. - return new List(); + return []; } - var client = await EnsureDaemon(ct); - // TODO: implement - return new List(); + // TODO: implement this + return []; } public async Task Initialize(CancellationToken ct) @@ -190,7 +180,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 new file mode 100644 index 0000000..45ca318 --- /dev/null +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Windows.Storage.Pickers; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using WinRT.Interop; + +namespace Coder.Desktop.App.ViewModels; + +public partial class FileSyncListViewModel : ObservableObject +{ + private DispatcherQueue? _dispatcherQueue; + + private readonly ISyncSessionController _syncSessionController; + private readonly IRpcController _rpcController; + private readonly ICredentialManager _credentialManager; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowUnavailable))] + [NotifyPropertyChangedFor(nameof(ShowLoading))] + [NotifyPropertyChangedFor(nameof(ShowError))] + [NotifyPropertyChangedFor(nameof(ShowSessions))] + public partial string? UnavailableMessage { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoading))] + [NotifyPropertyChangedFor(nameof(ShowSessions))] + public partial bool Loading { get; set; } = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoading))] + [NotifyPropertyChangedFor(nameof(ShowError))] + [NotifyPropertyChangedFor(nameof(ShowSessions))] + public partial string? Error { get; set; } = null; + + [ObservableProperty] public partial List Sessions { get; set; } = []; + + [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial string NewSessionLocalPath { get; set; } = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial bool NewSessionLocalPathDialogOpen { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial string NewSessionRemoteName { get; set; } = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial string NewSessionRemotePath { get; set; } = ""; + // TODO: NewSessionRemotePathDialogOpen for remote path + + public bool NewSessionCreateEnabled + { + get + { + if (string.IsNullOrWhiteSpace(NewSessionLocalPath)) return false; + if (NewSessionLocalPathDialogOpen) return false; + if (string.IsNullOrWhiteSpace(NewSessionRemoteName)) return false; + if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false; + return true; + } + } + + // TODO: this could definitely be improved + public bool ShowUnavailable => UnavailableMessage != null; + public bool ShowLoading => Loading && UnavailableMessage == null && Error == null; + public bool ShowError => UnavailableMessage == null && Error != null; + public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null; + + public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController, + ICredentialManager credentialManager) + { + _syncSessionController = syncSessionController; + _rpcController = rpcController; + _credentialManager = credentialManager; + + Sessions = + [ + new SyncSessionModel(@"C:\Users\dean\git\coder-desktop-windows", "pog", "~/repos/coder-desktop-windows", + SyncSessionStatusCategory.Ok, "Watching", "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Paused, + "Paused", + "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Conflicts, + "Conflicts", "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Halted, + "Halted on root emptied", "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Error, + "Some error", "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Unknown, + "Unknown", "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Working, + "Reconciling", "Some description", []), + ]; + } + + public void Initialize(DispatcherQueue dispatcherQueue) + { + _dispatcherQueue = dispatcherQueue; + if (!_dispatcherQueue.HasThreadAccess) + throw new InvalidOperationException("Initialize must be called from the UI thread"); + + _rpcController.StateChanged += RpcControllerStateChanged; + _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged; + + var rpcModel = _rpcController.GetState(); + var credentialModel = _credentialManager.GetCachedCredentials(); + MaybeSetUnavailableMessage(rpcModel, credentialModel); + + // TODO: Simulate loading until we have real data. + Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ => _dispatcherQueue.TryEnqueue(() => Loading = false)); + } + + private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => RpcControllerStateChanged(sender, rpcModel)); + return; + } + + var credentialModel = _credentialManager.GetCachedCredentials(); + MaybeSetUnavailableMessage(rpcModel, credentialModel); + } + + private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => CredentialManagerCredentialsChanged(sender, credentialModel)); + return; + } + + var rpcModel = _rpcController.GetState(); + MaybeSetUnavailableMessage(rpcModel, credentialModel); + } + + private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel) + { + if (rpcModel.RpcLifecycle != RpcLifecycle.Connected) + UnavailableMessage = + "Disconnected from the Windows service. Please see the tray window for more information."; + else if (credentialModel.State != CredentialState.Valid) + UnavailableMessage = "Please sign in to access file sync."; + else if (rpcModel.VpnLifecycle != VpnLifecycle.Started) + UnavailableMessage = "Please start Coder Connect from the tray window to access file sync."; + else + UnavailableMessage = null; + } + + private void ClearNewForm() + { + CreatingNewSession = false; + NewSessionLocalPath = ""; + NewSessionRemoteName = ""; + NewSessionRemotePath = ""; + } + + [RelayCommand] + private void ReloadSessions() + { + Loading = true; + Error = null; + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + _ = _syncSessionController.ListSyncSessions(cts.Token).ContinueWith(HandleList, cts.Token); + } + + private void HandleList(Task> t) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => HandleList(t)); + return; + } + + if (t.IsCompletedSuccessfully) + { + Sessions = t.Result.ToList(); + Loading = false; + return; + } + + Error = "Could not list sync sessions: "; + if (t.IsCanceled) Error += new TaskCanceledException(); + else if (t.IsFaulted) Error += t.Exception; + else Error += "no successful result or error"; + Loading = false; + } + + [RelayCommand] + private void StartCreatingNewSession() + { + ClearNewForm(); + CreatingNewSession = true; + } + + public async Task OpenLocalPathSelectDialog(Window window) + { + var picker = new FolderPicker + { + SuggestedStartLocation = PickerLocationId.ComputerFolder, + }; + + var hwnd = WindowNative.GetWindowHandle(window); + InitializeWithWindow.Initialize(picker, hwnd); + + NewSessionLocalPathDialogOpen = true; + try + { + var path = await picker.PickSingleFolderAsync(); + if (path == null) return; + NewSessionLocalPath = path.Path; + } + catch + { + // ignored + } + finally + { + NewSessionLocalPathDialogOpen = false; + } + } + + [RelayCommand] + private void CancelNewSession() + { + ClearNewForm(); + } + + [RelayCommand] + private void ConfirmNewSession() + { + // TODO: implement + ClearNewForm(); + } +} diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 62cf692..532bfe4 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -4,10 +4,12 @@ using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; +using Coder.Desktop.App.Views; using Coder.Desktop.Vpn.Proto; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -20,9 +22,12 @@ public partial class TrayWindowViewModel : ObservableObject private const int MaxAgents = 5; private const string DefaultDashboardUrl = "https://coder.com"; + private readonly IServiceProvider _services; private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; + private FileSyncListWindow? _fileSyncListWindow; + private DispatcherQueue? _dispatcherQueue; [ObservableProperty] @@ -73,8 +78,10 @@ public partial class TrayWindowViewModel : ObservableObject [ObservableProperty] public partial string DashboardUrl { get; set; } = "https://coder.com"; - public TrayWindowViewModel(IRpcController rpcController, ICredentialManager credentialManager) + public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController, + ICredentialManager credentialManager) { + _services = services; _rpcController = rpcController; _credentialManager = credentialManager; } @@ -204,6 +211,14 @@ private string WorkspaceUri(Uri? baseUri, string? workspaceName) private void UpdateFromCredentialsModel(CredentialModel credentialModel) { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel)); + return; + } + // HACK: the HyperlinkButton crashes the whole app if the initial URI // or this URI is invalid. CredentialModel.CoderUrl should never be // null while the Page is active as the Page is only displayed when @@ -234,7 +249,7 @@ private async Task StartVpn() } catch (Exception e) { - VpnFailedMessage = "Failed to start Coder Connect: " + MaybeUnwrapTunnelError(e); + VpnFailedMessage = "Failed to start CoderVPN: " + MaybeUnwrapTunnelError(e); } } @@ -246,7 +261,7 @@ private async Task StopVpn() } catch (Exception e) { - VpnFailedMessage = "Failed to stop Coder Connect: " + MaybeUnwrapTunnelError(e); + VpnFailedMessage = "Failed to stop CoderVPN: " + MaybeUnwrapTunnelError(e); } } @@ -262,6 +277,22 @@ public void ToggleShowAllAgents() ShowAllAgents = !ShowAllAgents; } + [RelayCommand] + public void ShowFileSyncListWindow() + { + // This is safe against concurrent access since it all happens in the + // UI thread. + if (_fileSyncListWindow != null) + { + _fileSyncListWindow.Activate(); + return; + } + + _fileSyncListWindow = _services.GetRequiredService(); + _fileSyncListWindow.Closed += (_, _) => _fileSyncListWindow = null; + _fileSyncListWindow.Activate(); + } + [RelayCommand] public void SignOut() { diff --git a/App/Views/FileSyncListWindow.xaml b/App/Views/FileSyncListWindow.xaml new file mode 100644 index 0000000..070efd2 --- /dev/null +++ b/App/Views/FileSyncListWindow.xaml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs new file mode 100644 index 0000000..27d386d --- /dev/null +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -0,0 +1,23 @@ +using Coder.Desktop.App.ViewModels; +using Coder.Desktop.App.Views.Pages; +using Microsoft.UI.Xaml.Media; +using WinUIEx; + +namespace Coder.Desktop.App.Views; + +public sealed partial class FileSyncListWindow : WindowEx +{ + public readonly FileSyncListViewModel ViewModel; + + public FileSyncListWindow(FileSyncListViewModel viewModel) + { + ViewModel = viewModel; + InitializeComponent(); + SystemBackdrop = new DesktopAcrylicBackdrop(); + + ViewModel.Initialize(DispatcherQueue); + RootFrame.Content = new FileSyncListMainPage(ViewModel, this); + + this.CenterOnScreen(); + } +} diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml new file mode 100644 index 0000000..768e396 --- /dev/null +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -0,0 +1,331 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..b208020 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -12,14 +12,11 @@ mc:Ignorable="d"> - - - @@ -118,6 +115,34 @@ HorizontalAlignment="Stretch" Spacing="10"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + (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}"); + } + } +} 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. pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy