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">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+