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/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/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..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. + +/// <summary> +/// An item in a DependencyObjectSelector. Each item has a key and a value. +/// The default item in a DependencyObjectSelector will be the only item +/// with a null key. +/// </summary> +/// <typeparam name="TK">Key type</typeparam> +/// <typeparam name="TV">Value type</typeparam> +public class DependencyObjectSelectorItem<TK, TV> : DependencyObject + where TK : IEquatable<TK> +{ + public static readonly DependencyProperty KeyProperty = + DependencyProperty.Register(nameof(Key), + typeof(TK?), + typeof(DependencyObjectSelectorItem<TK, TV>), + new PropertyMetadata(null)); + + public static readonly DependencyProperty ValueProperty = + DependencyProperty.Register(nameof(Value), + typeof(TV?), + typeof(DependencyObjectSelectorItem<TK, TV>), + new PropertyMetadata(null)); + + public TK? Key + { + get => (TK?)GetValue(KeyProperty); + set => SetValue(KeyProperty, value); + } + + public TV? Value + { + get => (TV?)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } +} + +/// <summary> +/// Allows selecting between multiple value references based on a selected +/// key. This allows for dynamic mapping of model values to other objects. +/// The main use case is for selecting between other bound values, which +/// you cannot do with a simple ValueConverter. +/// </summary> +/// <typeparam name="TK">Key type</typeparam> +/// <typeparam name="TV">Value type</typeparam> +[ContentProperty(Name = nameof(References))] +public class DependencyObjectSelector<TK, TV> : DependencyObject + where TK : IEquatable<TK> +{ + public static readonly DependencyProperty ReferencesProperty = + DependencyProperty.Register(nameof(References), + typeof(DependencyObjectCollection), + typeof(DependencyObjectSelector<TK, TV>), + new PropertyMetadata(null, ReferencesPropertyChanged)); + + public static readonly DependencyProperty SelectedKeyProperty = + DependencyProperty.Register(nameof(SelectedKey), + typeof(TK?), + typeof(DependencyObjectSelector<TK, TV>), + new PropertyMetadata(null, SelectedKeyPropertyChanged)); + + public static readonly DependencyProperty SelectedObjectProperty = + DependencyProperty.Register(nameof(SelectedObject), + typeof(TV?), + typeof(DependencyObjectSelector<TK, TV>), + new PropertyMetadata(null)); + + public DependencyObjectCollection? References + { + get => (DependencyObjectCollection?)GetValue(ReferencesProperty); + set + { + // Ensure unique keys and that the values are DependencyObjectSelectorItem<K, V>. + if (value != null) + { + var items = value.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray(); + var keys = items.Select(i => i.Key).Distinct().ToArray(); + if (keys.Length != value.Count) + throw new ArgumentException("ObservableCollection Keys must be unique."); + } + + SetValue(ReferencesProperty, value); + } + } + + /// <summary> + /// The key of the selected item. This should be bound to a property on + /// the model. + /// </summary> + public TK? SelectedKey + { + get => (TK?)GetValue(SelectedKeyProperty); + set => SetValue(SelectedKeyProperty, value); + } + + /// <summary> + /// The selected object. This can be read from to get the matching + /// object for the selected key. If the selected key doesn't match any + /// object, this will be the value of the null key. If there is no null + /// key, this will be null. + /// </summary> + public TV? SelectedObject + { + get => (TV?)GetValue(SelectedObjectProperty); + set => SetValue(SelectedObjectProperty, value); + } + + public DependencyObjectSelector() + { + References = []; + } + + private void UpdateSelectedObject() + { + if (References != null) + { + // Look for a matching item a matching key, or fallback to the null + // key. + var references = References.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray(); + var item = references + .FirstOrDefault(i => + (i.Key == null && SelectedKey == null) || + (i.Key != null && SelectedKey != null && i.Key!.Equals(SelectedKey!))) + ?? references.FirstOrDefault(i => i.Key == null); + if (item is not null) + { + // Bind the SelectedObject property to the reference's Value. + // If the underlying Value changes, it will propagate to the + // SelectedObject. + BindingOperations.SetBinding + ( + this, + SelectedObjectProperty, + new Binding + { + Source = item, + Path = new PropertyPath(nameof(DependencyObjectSelectorItem<TK, TV>.Value)), + } + ); + return; + } + } + + ClearValue(SelectedObjectProperty); + } + + // Called when the References property is replaced. + private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + var self = obj as DependencyObjectSelector<TK, TV>; + if (self == null) return; + var oldValue = args.OldValue as DependencyObjectCollection; + if (oldValue != null) + oldValue.VectorChanged -= self.OnVectorChangedReferences; + var newValue = args.NewValue as DependencyObjectCollection; + if (newValue != null) + newValue.VectorChanged += self.OnVectorChangedReferences; + } + + // Called when the References collection changes without being replaced. + private void OnVectorChangedReferences(IObservableVector<DependencyObject> sender, IVectorChangedEventArgs args) + { + UpdateSelectedObject(); + } + + // Called when SelectedKey changes. + private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + var self = obj as DependencyObjectSelector<TK, TV>; + self?.UpdateSelectedObject(); + } +} + +public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem<string, Brush>; + +public sealed class StringToBrushSelector : DependencyObjectSelector<string, Brush>; 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; -// <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,12 @@ public async Task<SyncSession> 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<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 +137,11 @@ 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>(); + // 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<SyncSessionModel> Sessions { get; set; } = []; + + [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial string NewSessionLocalPath { get; set; } = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial bool NewSessionLocalPathDialogOpen { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial string NewSessionRemoteName { get; set; } = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial string NewSessionRemotePath { get; set; } = ""; + // TODO: NewSessionRemotePathDialogOpen for remote path + + public bool NewSessionCreateEnabled + { + get + { + if (string.IsNullOrWhiteSpace(NewSessionLocalPath)) return false; + if (NewSessionLocalPathDialogOpen) return false; + if (string.IsNullOrWhiteSpace(NewSessionRemoteName)) return false; + if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false; + return true; + } + } + + // TODO: this could definitely be improved + public bool ShowUnavailable => UnavailableMessage != null; + public bool ShowLoading => Loading && UnavailableMessage == null && Error == null; + public bool ShowError => UnavailableMessage == null && Error != null; + public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null; + + public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController, + ICredentialManager credentialManager) + { + _syncSessionController = syncSessionController; + _rpcController = rpcController; + _credentialManager = credentialManager; + + Sessions = + [ + new SyncSessionModel(@"C:\Users\dean\git\coder-desktop-windows", "pog", "~/repos/coder-desktop-windows", + SyncSessionStatusCategory.Ok, "Watching", "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Paused, + "Paused", + "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Conflicts, + "Conflicts", "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Halted, + "Halted on root emptied", "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Error, + "Some error", "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Unknown, + "Unknown", "Some description", []), + new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Working, + "Reconciling", "Some description", []), + ]; + } + + public void Initialize(DispatcherQueue dispatcherQueue) + { + _dispatcherQueue = dispatcherQueue; + if (!_dispatcherQueue.HasThreadAccess) + throw new InvalidOperationException("Initialize must be called from the UI thread"); + + _rpcController.StateChanged += RpcControllerStateChanged; + _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged; + + var rpcModel = _rpcController.GetState(); + var credentialModel = _credentialManager.GetCachedCredentials(); + MaybeSetUnavailableMessage(rpcModel, credentialModel); + + // TODO: Simulate loading until we have real data. + Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ => _dispatcherQueue.TryEnqueue(() => Loading = false)); + } + + private void RpcControllerStateChanged(object? sender, RpcModel rpcModel) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => RpcControllerStateChanged(sender, rpcModel)); + return; + } + + var credentialModel = _credentialManager.GetCachedCredentials(); + MaybeSetUnavailableMessage(rpcModel, credentialModel); + } + + private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => CredentialManagerCredentialsChanged(sender, credentialModel)); + return; + } + + var rpcModel = _rpcController.GetState(); + MaybeSetUnavailableMessage(rpcModel, credentialModel); + } + + private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel) + { + if (rpcModel.RpcLifecycle != RpcLifecycle.Connected) + UnavailableMessage = + "Disconnected from the Windows service. Please see the tray window for more information."; + else if (credentialModel.State != CredentialState.Valid) + UnavailableMessage = "Please sign in to access file sync."; + else if (rpcModel.VpnLifecycle != VpnLifecycle.Started) + UnavailableMessage = "Please start Coder Connect from the tray window to access file sync."; + else + UnavailableMessage = null; + } + + private void ClearNewForm() + { + CreatingNewSession = false; + NewSessionLocalPath = ""; + NewSessionRemoteName = ""; + NewSessionRemotePath = ""; + } + + [RelayCommand] + private void ReloadSessions() + { + Loading = true; + Error = null; + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + _ = _syncSessionController.ListSyncSessions(cts.Token).ContinueWith(HandleList, cts.Token); + } + + private void HandleList(Task<IEnumerable<SyncSessionModel>> t) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => HandleList(t)); + return; + } + + if (t.IsCompletedSuccessfully) + { + Sessions = t.Result.ToList(); + Loading = false; + return; + } + + Error = "Could not list sync sessions: "; + if (t.IsCanceled) Error += new TaskCanceledException(); + else if (t.IsFaulted) Error += t.Exception; + else Error += "no successful result or error"; + Loading = false; + } + + [RelayCommand] + private void StartCreatingNewSession() + { + ClearNewForm(); + CreatingNewSession = true; + } + + public async Task OpenLocalPathSelectDialog(Window window) + { + var picker = new FolderPicker + { + SuggestedStartLocation = PickerLocationId.ComputerFolder, + }; + + var hwnd = WindowNative.GetWindowHandle(window); + InitializeWithWindow.Initialize(picker, hwnd); + + NewSessionLocalPathDialogOpen = true; + try + { + var path = await picker.PickSingleFolderAsync(); + if (path == null) return; + NewSessionLocalPath = path.Path; + } + catch + { + // ignored + } + finally + { + NewSessionLocalPathDialogOpen = false; + } + } + + [RelayCommand] + private void CancelNewSession() + { + ClearNewForm(); + } + + [RelayCommand] + private void ConfirmNewSession() + { + // TODO: implement + ClearNewForm(); + } +} 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>(); + _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 @@ +<?xml version="1.0" encoding="utf-8"?> + +<winuiex:WindowEx + x:Class="Coder.Desktop.App.Views.FileSyncListWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:winuiex="using:WinUIEx" + mc:Ignorable="d" + Title="Coder File Sync" + Width="1000" Height="300" + MinWidth="1000" MinHeight="300"> + + <Window.SystemBackdrop> + <DesktopAcrylicBackdrop /> + </Window.SystemBackdrop> + + <Frame x:Name="RootFrame" /> +</winuiex:WindowEx> 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 @@ +<?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}"> + + <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" + HorizontalAlignment="Center" + VerticalAlignment="Center"> + + <ProgressRing + Width="32" + Height="32" + Margin="0,30" + HorizontalAlignment="Center" /> + + <TextBlock HorizontalAlignment="Center" Text="Loading sync sessions..." /> + </Grid> + + <StackPanel + Visibility="{x:Bind ViewModel.ShowError, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" + Orientation="Vertical" + Padding="20"> + + <TextBlock + Margin="0,0,0,20" + Foreground="Red" + TextWrapping="Wrap" + Text="{x:Bind ViewModel.Error, Mode=OneWay}" /> + + <Button Command="{x:Bind ViewModel.ReloadSessionsCommand, Mode=OneWay}"> + <TextBlock Text="Reload" /> + </Button> + </StackPanel> + + <!-- 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> + + <StackPanel + Grid.Row="0" + Orientation="Vertical" + Padding="30,15,30,0"> + + <!-- + 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}" /> + </StackPanel> + + <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> + + <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 AlphaPath}" + TextTrimming="CharacterEllipsis" + IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> + </Border> + <Border Grid.Column="2"> + <TextBlock + Text="{x:Bind BetaName}" + TextTrimming="CharacterEllipsis" + IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> + </Border> + <Border Grid.Column="3"> + <TextBlock + Text="{x:Bind BetaPath}" + 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="Halted" + Value="{ThemeResource SystemFillColorCriticalBrush}" /> + <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 AlphaSize.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> + </Grid> + </Grid> +</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..b208020 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" @@ -203,6 +228,18 @@ <controls:HorizontalRule /> + <HyperlinkButton + Command="{x:Bind ViewModel.ShowFileSyncListWindowCommand, Mode=OneWay}" + Margin="-12,0" + HorizontalAlignment="Stretch" + HorizontalContentAlignment="Left"> + + <!-- TODO: status icon if there is a problem --> + <TextBlock Text="File sync" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" /> + </HyperlinkButton> + + <controls:HorizontalRule /> + <HyperlinkButton Command="{x:Bind ViewModel.SignOutCommand, Mode=OneWay}" IsEnabled="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource StoppedBoolConverter}, Mode=OneWay}" 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", 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}"); + } + } +} 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.