Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit e0a11dd

Browse files
authoredMar 25, 2025··
feat: add mock UI for file syncing listing (#60)
1 parent 13ce6b9 commit e0a11dd

21 files changed

+1337
-74
lines changed
 

‎App/App.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.1" />
6666
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.1" />
6767
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.6.250108002" />
68+
<PackageReference Include="WinUIEx" Version="2.5.1" />
6869
</ItemGroup>
6970

7071
<ItemGroup>

‎App/App.xaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33
<Application
44
x:Class="Coder.Desktop.App.App"
55
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
6-
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
6+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
7+
xmlns:converters="using:Coder.Desktop.App.Converters">
78
<Application.Resources>
89
<ResourceDictionary>
910
<ResourceDictionary.MergedDictionaries>
1011
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
1112
</ResourceDictionary.MergedDictionaries>
13+
14+
<converters:InverseBoolConverter x:Key="InverseBoolConverter" />
15+
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
16+
<converters:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisibilityConverter" />
17+
<converters:FriendlyByteConverter x:Key="FriendlyByteConverter" />
1218
</ResourceDictionary>
1319
</Application.Resources>
1420
</Application>

‎App/App.xaml.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ public App()
4747
services.AddTransient<SignInViewModel>();
4848
services.AddTransient<SignInWindow>();
4949

50+
// FileSyncListWindow views and view models
51+
services.AddTransient<FileSyncListViewModel>();
52+
// FileSyncListMainPage is created by FileSyncListWindow.
53+
services.AddTransient<FileSyncListWindow>();
54+
5055
// TrayWindow views and view models
5156
services.AddTransient<TrayWindowLoadingPage>();
5257
services.AddTransient<TrayWindowDisconnectedViewModel>();

‎App/Controls/SizedFrame.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ public class SizedFrameEventArgs : EventArgs
1212

1313
/// <summary>
1414
/// SizedFrame extends Frame by adding a SizeChanged event, which will be triggered when:
15-
/// - The contained Page's content's size changes
16-
/// - We switch to a different page.
17-
///
15+
/// - The contained Page's content's size changes
16+
/// - We switch to a different page.
1817
/// Sadly this is necessary because Window.Content.SizeChanged doesn't trigger when the Page's content changes.
1918
/// </summary>
2019
public class SizedFrame : Frame

‎App/Converters/AgentStatusToColorConverter.cs

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
using System;
2+
using System.Linq;
3+
using Windows.Foundation.Collections;
4+
using Windows.UI.Xaml.Markup;
5+
using Microsoft.UI.Xaml;
6+
using Microsoft.UI.Xaml.Data;
7+
using Microsoft.UI.Xaml.Media;
8+
9+
namespace Coder.Desktop.App.Converters;
10+
11+
// This file uses manual DependencyProperty properties rather than
12+
// DependencyPropertyGenerator since it doesn't seem to work properly with
13+
// generics.
14+
15+
/// <summary>
16+
/// An item in a DependencyObjectSelector. Each item has a key and a value.
17+
/// The default item in a DependencyObjectSelector will be the only item
18+
/// with a null key.
19+
/// </summary>
20+
/// <typeparam name="TK">Key type</typeparam>
21+
/// <typeparam name="TV">Value type</typeparam>
22+
public class DependencyObjectSelectorItem<TK, TV> : DependencyObject
23+
where TK : IEquatable<TK>
24+
{
25+
public static readonly DependencyProperty KeyProperty =
26+
DependencyProperty.Register(nameof(Key),
27+
typeof(TK?),
28+
typeof(DependencyObjectSelectorItem<TK, TV>),
29+
new PropertyMetadata(null));
30+
31+
public static readonly DependencyProperty ValueProperty =
32+
DependencyProperty.Register(nameof(Value),
33+
typeof(TV?),
34+
typeof(DependencyObjectSelectorItem<TK, TV>),
35+
new PropertyMetadata(null));
36+
37+
public TK? Key
38+
{
39+
get => (TK?)GetValue(KeyProperty);
40+
set => SetValue(KeyProperty, value);
41+
}
42+
43+
public TV? Value
44+
{
45+
get => (TV?)GetValue(ValueProperty);
46+
set => SetValue(ValueProperty, value);
47+
}
48+
}
49+
50+
/// <summary>
51+
/// Allows selecting between multiple value references based on a selected
52+
/// key. This allows for dynamic mapping of model values to other objects.
53+
/// The main use case is for selecting between other bound values, which
54+
/// you cannot do with a simple ValueConverter.
55+
/// </summary>
56+
/// <typeparam name="TK">Key type</typeparam>
57+
/// <typeparam name="TV">Value type</typeparam>
58+
[ContentProperty(Name = nameof(References))]
59+
public class DependencyObjectSelector<TK, TV> : DependencyObject
60+
where TK : IEquatable<TK>
61+
{
62+
public static readonly DependencyProperty ReferencesProperty =
63+
DependencyProperty.Register(nameof(References),
64+
typeof(DependencyObjectCollection),
65+
typeof(DependencyObjectSelector<TK, TV>),
66+
new PropertyMetadata(null, ReferencesPropertyChanged));
67+
68+
public static readonly DependencyProperty SelectedKeyProperty =
69+
DependencyProperty.Register(nameof(SelectedKey),
70+
typeof(TK?),
71+
typeof(DependencyObjectSelector<TK, TV>),
72+
new PropertyMetadata(null, SelectedKeyPropertyChanged));
73+
74+
public static readonly DependencyProperty SelectedObjectProperty =
75+
DependencyProperty.Register(nameof(SelectedObject),
76+
typeof(TV?),
77+
typeof(DependencyObjectSelector<TK, TV>),
78+
new PropertyMetadata(null));
79+
80+
public DependencyObjectCollection? References
81+
{
82+
get => (DependencyObjectCollection?)GetValue(ReferencesProperty);
83+
set
84+
{
85+
// Ensure unique keys and that the values are DependencyObjectSelectorItem<K, V>.
86+
if (value != null)
87+
{
88+
var items = value.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray();
89+
var keys = items.Select(i => i.Key).Distinct().ToArray();
90+
if (keys.Length != value.Count)
91+
throw new ArgumentException("ObservableCollection Keys must be unique.");
92+
}
93+
94+
SetValue(ReferencesProperty, value);
95+
}
96+
}
97+
98+
/// <summary>
99+
/// The key of the selected item. This should be bound to a property on
100+
/// the model.
101+
/// </summary>
102+
public TK? SelectedKey
103+
{
104+
get => (TK?)GetValue(SelectedKeyProperty);
105+
set => SetValue(SelectedKeyProperty, value);
106+
}
107+
108+
/// <summary>
109+
/// The selected object. This can be read from to get the matching
110+
/// object for the selected key. If the selected key doesn't match any
111+
/// object, this will be the value of the null key. If there is no null
112+
/// key, this will be null.
113+
/// </summary>
114+
public TV? SelectedObject
115+
{
116+
get => (TV?)GetValue(SelectedObjectProperty);
117+
set => SetValue(SelectedObjectProperty, value);
118+
}
119+
120+
public DependencyObjectSelector()
121+
{
122+
References = [];
123+
}
124+
125+
private void UpdateSelectedObject()
126+
{
127+
if (References != null)
128+
{
129+
// Look for a matching item a matching key, or fallback to the null
130+
// key.
131+
var references = References.OfType<DependencyObjectSelectorItem<TK, TV>>().ToArray();
132+
var item = references
133+
.FirstOrDefault(i =>
134+
(i.Key == null && SelectedKey == null) ||
135+
(i.Key != null && SelectedKey != null && i.Key!.Equals(SelectedKey!)))
136+
?? references.FirstOrDefault(i => i.Key == null);
137+
if (item is not null)
138+
{
139+
// Bind the SelectedObject property to the reference's Value.
140+
// If the underlying Value changes, it will propagate to the
141+
// SelectedObject.
142+
BindingOperations.SetBinding
143+
(
144+
this,
145+
SelectedObjectProperty,
146+
new Binding
147+
{
148+
Source = item,
149+
Path = new PropertyPath(nameof(DependencyObjectSelectorItem<TK, TV>.Value)),
150+
}
151+
);
152+
return;
153+
}
154+
}
155+
156+
ClearValue(SelectedObjectProperty);
157+
}
158+
159+
// Called when the References property is replaced.
160+
private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
161+
{
162+
var self = obj as DependencyObjectSelector<TK, TV>;
163+
if (self == null) return;
164+
var oldValue = args.OldValue as DependencyObjectCollection;
165+
if (oldValue != null)
166+
oldValue.VectorChanged -= self.OnVectorChangedReferences;
167+
var newValue = args.NewValue as DependencyObjectCollection;
168+
if (newValue != null)
169+
newValue.VectorChanged += self.OnVectorChangedReferences;
170+
}
171+
172+
// Called when the References collection changes without being replaced.
173+
private void OnVectorChangedReferences(IObservableVector<DependencyObject> sender, IVectorChangedEventArgs args)
174+
{
175+
UpdateSelectedObject();
176+
}
177+
178+
// Called when SelectedKey changes.
179+
private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
180+
{
181+
var self = obj as DependencyObjectSelector<TK, TV>;
182+
self?.UpdateSelectedObject();
183+
}
184+
}
185+
186+
public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem<string, Brush>;
187+
188+
public sealed class StringToBrushSelector : DependencyObjectSelector<string, Brush>;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using System;
2+
using Microsoft.UI.Xaml.Data;
3+
4+
namespace Coder.Desktop.App.Converters;
5+
6+
public class FriendlyByteConverter : IValueConverter
7+
{
8+
private static readonly string[] Suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
9+
10+
public object Convert(object value, Type targetType, object parameter, string language)
11+
{
12+
switch (value)
13+
{
14+
case int i:
15+
if (i < 0) i = 0;
16+
return FriendlyBytes((ulong)i);
17+
case uint ui:
18+
return FriendlyBytes(ui);
19+
case long l:
20+
if (l < 0) l = 0;
21+
return FriendlyBytes((ulong)l);
22+
case ulong ul:
23+
return FriendlyBytes(ul);
24+
default:
25+
return FriendlyBytes(0);
26+
}
27+
}
28+
29+
public object ConvertBack(object value, Type targetType, object parameter, string language)
30+
{
31+
throw new NotImplementedException();
32+
}
33+
34+
public static string FriendlyBytes(ulong bytes)
35+
{
36+
if (bytes == 0)
37+
return $"0 {Suffixes[0]}";
38+
39+
var place = System.Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
40+
var num = Math.Round(bytes / Math.Pow(1024, place), 1);
41+
return $"{num} {Suffixes[place]}";
42+
}
43+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System;
2+
using Microsoft.UI.Xaml.Data;
3+
4+
namespace Coder.Desktop.App.Converters;
5+
6+
public class InverseBoolConverter : IValueConverter
7+
{
8+
public object Convert(object value, Type targetType, object parameter, string language)
9+
{
10+
return value is false;
11+
}
12+
13+
public object ConvertBack(object value, Type targetType, object parameter, string language)
14+
{
15+
throw new NotImplementedException();
16+
}
17+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using Microsoft.UI.Xaml;
2+
3+
namespace Coder.Desktop.App.Converters;
4+
5+
public partial class InverseBoolToVisibilityConverter : BoolToObjectConverter
6+
{
7+
public InverseBoolToVisibilityConverter()
8+
{
9+
TrueValue = Visibility.Collapsed;
10+
FalseValue = Visibility.Visible;
11+
}
12+
}

‎App/Models/SyncSessionModel.cs

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
using System;
2+
using Coder.Desktop.App.Converters;
3+
using Coder.Desktop.MutagenSdk.Proto.Synchronization;
4+
using Coder.Desktop.MutagenSdk.Proto.Url;
5+
6+
namespace Coder.Desktop.App.Models;
7+
8+
// This is a much slimmer enum than the original enum from Mutagen and only
9+
// contains the overarching states that we care about from a code perspective.
10+
// We still store the original state in the model for rendering purposes.
11+
public enum SyncSessionStatusCategory
12+
{
13+
Unknown,
14+
Paused,
15+
16+
// Halted is a combination of Error and Paused. If the session
17+
// automatically pauses due to a safety check, we want to show it as an
18+
// error, but also show that it can be resumed.
19+
Halted,
20+
Error,
21+
22+
// If there are any conflicts, the state will be set to Conflicts,
23+
// overriding Working and Ok.
24+
Conflicts,
25+
Working,
26+
Ok,
27+
}
28+
29+
public sealed class SyncSessionModelEndpointSize
30+
{
31+
public ulong SizeBytes { get; init; }
32+
public ulong FileCount { get; init; }
33+
public ulong DirCount { get; init; }
34+
public ulong SymlinkCount { get; init; }
35+
36+
public string Description(string linePrefix = "")
37+
{
38+
var str =
39+
$"{linePrefix}{FriendlyByteConverter.FriendlyBytes(SizeBytes)}\n" +
40+
$"{linePrefix}{FileCount:N0} files\n" +
41+
$"{linePrefix}{DirCount:N0} directories";
42+
if (SymlinkCount > 0) str += $"\n{linePrefix} {SymlinkCount:N0} symlinks";
43+
44+
return str;
45+
}
46+
}
47+
48+
public class SyncSessionModel
49+
{
50+
public readonly string Identifier;
51+
public readonly string Name;
52+
53+
public readonly string AlphaName;
54+
public readonly string AlphaPath;
55+
public readonly string BetaName;
56+
public readonly string BetaPath;
57+
58+
public readonly SyncSessionStatusCategory StatusCategory;
59+
public readonly string StatusString;
60+
public readonly string StatusDescription;
61+
62+
public readonly SyncSessionModelEndpointSize AlphaSize;
63+
public readonly SyncSessionModelEndpointSize BetaSize;
64+
65+
public readonly string[] Errors = [];
66+
67+
public string StatusDetails
68+
{
69+
get
70+
{
71+
var str = $"{StatusString} ({StatusCategory})\n\n{StatusDescription}";
72+
foreach (var err in Errors) str += $"\n\n{err}";
73+
return str;
74+
}
75+
}
76+
77+
public string SizeDetails
78+
{
79+
get
80+
{
81+
var str = "Alpha:\n" + AlphaSize.Description(" ") + "\n\n" +
82+
"Remote:\n" + BetaSize.Description(" ");
83+
return str;
84+
}
85+
}
86+
87+
// TODO: remove once we process sessions from the mutagen RPC
88+
public SyncSessionModel(string alphaPath, string betaName, string betaPath,
89+
SyncSessionStatusCategory statusCategory,
90+
string statusString, string statusDescription, string[] errors)
91+
{
92+
Identifier = "TODO";
93+
Name = "TODO";
94+
95+
AlphaName = "Local";
96+
AlphaPath = alphaPath;
97+
BetaName = betaName;
98+
BetaPath = betaPath;
99+
StatusCategory = statusCategory;
100+
StatusString = statusString;
101+
StatusDescription = statusDescription;
102+
AlphaSize = new SyncSessionModelEndpointSize
103+
{
104+
SizeBytes = (ulong)new Random().Next(0, 1000000000),
105+
FileCount = (ulong)new Random().Next(0, 10000),
106+
DirCount = (ulong)new Random().Next(0, 10000),
107+
};
108+
BetaSize = new SyncSessionModelEndpointSize
109+
{
110+
SizeBytes = (ulong)new Random().Next(0, 1000000000),
111+
FileCount = (ulong)new Random().Next(0, 10000),
112+
DirCount = (ulong)new Random().Next(0, 10000),
113+
};
114+
115+
Errors = errors;
116+
}
117+
118+
public SyncSessionModel(State state)
119+
{
120+
Identifier = state.Session.Identifier;
121+
Name = state.Session.Name;
122+
123+
(AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha);
124+
(BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta);
125+
126+
switch (state.Status)
127+
{
128+
case Status.Disconnected:
129+
StatusCategory = SyncSessionStatusCategory.Error;
130+
StatusString = "Disconnected";
131+
StatusDescription =
132+
"The session is unpaused but not currently connected or connecting to either endpoint.";
133+
break;
134+
case Status.HaltedOnRootEmptied:
135+
StatusCategory = SyncSessionStatusCategory.Halted;
136+
StatusString = "Halted on root emptied";
137+
StatusDescription = "The session is halted due to the root emptying safety check.";
138+
break;
139+
case Status.HaltedOnRootDeletion:
140+
StatusCategory = SyncSessionStatusCategory.Halted;
141+
StatusString = "Halted on root deletion";
142+
StatusDescription = "The session is halted due to the root deletion safety check.";
143+
break;
144+
case Status.HaltedOnRootTypeChange:
145+
StatusCategory = SyncSessionStatusCategory.Halted;
146+
StatusString = "Halted on root type change";
147+
StatusDescription = "The session is halted due to the root type change safety check.";
148+
break;
149+
case Status.ConnectingAlpha:
150+
StatusCategory = SyncSessionStatusCategory.Working;
151+
StatusString = "Connecting (alpha)";
152+
StatusDescription = "The session is attempting to connect to the alpha endpoint.";
153+
break;
154+
case Status.ConnectingBeta:
155+
StatusCategory = SyncSessionStatusCategory.Working;
156+
StatusString = "Connecting (beta)";
157+
StatusDescription = "The session is attempting to connect to the beta endpoint.";
158+
break;
159+
case Status.Watching:
160+
StatusCategory = SyncSessionStatusCategory.Ok;
161+
StatusString = "Watching";
162+
StatusDescription = "The session is watching for filesystem changes.";
163+
break;
164+
case Status.Scanning:
165+
StatusCategory = SyncSessionStatusCategory.Working;
166+
StatusString = "Scanning";
167+
StatusDescription = "The session is scanning the filesystem on each endpoint.";
168+
break;
169+
case Status.WaitingForRescan:
170+
StatusCategory = SyncSessionStatusCategory.Working;
171+
StatusString = "Waiting for rescan";
172+
StatusDescription =
173+
"The session is waiting to retry scanning after an error during the previous scanning operation.";
174+
break;
175+
case Status.Reconciling:
176+
StatusCategory = SyncSessionStatusCategory.Working;
177+
StatusString = "Reconciling";
178+
StatusDescription = "The session is performing reconciliation.";
179+
break;
180+
case Status.StagingAlpha:
181+
StatusCategory = SyncSessionStatusCategory.Working;
182+
StatusString = "Staging (alpha)";
183+
StatusDescription = "The session is staging files on alpha.";
184+
break;
185+
case Status.StagingBeta:
186+
StatusCategory = SyncSessionStatusCategory.Working;
187+
StatusString = "Staging (beta)";
188+
StatusDescription = "The session is staging files on beta.";
189+
break;
190+
case Status.Transitioning:
191+
StatusCategory = SyncSessionStatusCategory.Working;
192+
StatusString = "Transitioning";
193+
StatusDescription = "The session is performing transition operations on each endpoint.";
194+
break;
195+
case Status.Saving:
196+
StatusCategory = SyncSessionStatusCategory.Working;
197+
StatusString = "Saving";
198+
StatusDescription = "The session is recording synchronization history to disk.";
199+
break;
200+
default:
201+
StatusCategory = SyncSessionStatusCategory.Unknown;
202+
StatusString = state.Status.ToString();
203+
StatusDescription = "Unknown status message.";
204+
break;
205+
}
206+
207+
// If the session is paused, override all other statuses except Halted.
208+
if (state.Session.Paused && StatusCategory is not SyncSessionStatusCategory.Halted)
209+
{
210+
StatusCategory = SyncSessionStatusCategory.Paused;
211+
StatusString = "Paused";
212+
StatusDescription = "The session is paused.";
213+
}
214+
215+
// If there are any conflicts, override Working and Ok.
216+
if (state.Conflicts.Count > 0 && StatusCategory > SyncSessionStatusCategory.Conflicts)
217+
{
218+
StatusCategory = SyncSessionStatusCategory.Conflicts;
219+
StatusString = "Conflicts";
220+
StatusDescription = "The session has conflicts that need to be resolved.";
221+
}
222+
223+
AlphaSize = new SyncSessionModelEndpointSize
224+
{
225+
SizeBytes = state.AlphaState.TotalFileSize,
226+
FileCount = state.AlphaState.Files,
227+
DirCount = state.AlphaState.Directories,
228+
SymlinkCount = state.AlphaState.SymbolicLinks,
229+
};
230+
BetaSize = new SyncSessionModelEndpointSize
231+
{
232+
SizeBytes = state.BetaState.TotalFileSize,
233+
FileCount = state.BetaState.Files,
234+
DirCount = state.BetaState.Directories,
235+
SymlinkCount = state.BetaState.SymbolicLinks,
236+
};
237+
238+
// TODO: accumulate errors, there seems to be multiple fields they can
239+
// come from
240+
if (!string.IsNullOrWhiteSpace(state.LastError)) Errors = [state.LastError];
241+
}
242+
243+
private static (string, string) NameAndPathFromUrl(URL url)
244+
{
245+
var name = "Local";
246+
var path = !string.IsNullOrWhiteSpace(url.Path) ? url.Path : "Unknown";
247+
248+
if (url.Protocol is not Protocol.Local)
249+
name = !string.IsNullOrWhiteSpace(url.Host) ? url.Host : "Unknown";
250+
if (string.IsNullOrWhiteSpace(url.Host)) name = url.Host;
251+
252+
return (name, path);
253+
}
254+
}

‎App/Services/MutagenController.cs

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.IO;
66
using System.Threading;
77
using System.Threading.Tasks;
8+
using Coder.Desktop.App.Models;
89
using Coder.Desktop.MutagenSdk;
910
using Coder.Desktop.MutagenSdk.Proto.Selection;
1011
using Coder.Desktop.MutagenSdk.Proto.Service.Daemon;
@@ -15,28 +16,17 @@
1516

1617
namespace Coder.Desktop.App.Services;
1718

18-
// <summary>
19-
// A file synchronization session to a Coder workspace agent.
20-
// </summary>
21-
// <remarks>
22-
// This implementation is a placeholder while implementing the daemon lifecycle. It's implementation
23-
// will be backed by the MutagenSDK eventually.
24-
// </remarks>
25-
public class SyncSession
19+
public class CreateSyncSessionRequest
2620
{
27-
public string name { get; init; } = "";
28-
public string localPath { get; init; } = "";
29-
public string workspace { get; init; } = "";
30-
public string agent { get; init; } = "";
31-
public string remotePath { get; init; } = "";
21+
// TODO: this
3222
}
3323

3424
public interface ISyncSessionController
3525
{
36-
Task<List<SyncSession>> ListSyncSessions(CancellationToken ct);
37-
Task<SyncSession> CreateSyncSession(SyncSession session, CancellationToken ct);
26+
Task<IEnumerable<SyncSessionModel>> ListSyncSessions(CancellationToken ct);
27+
Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct);
3828

39-
Task TerminateSyncSession(SyncSession session, CancellationToken ct);
29+
Task TerminateSyncSession(string identifier, CancellationToken ct);
4030

4131
// <summary>
4232
// Initializes the controller; running the daemon if there are any saved sessions. Must be called and
@@ -121,7 +111,7 @@
121111
}
122112

123113

124-
public async Task<SyncSession> CreateSyncSession(SyncSession session, CancellationToken ct)
114+
public async Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct)
125115
{
126116
// reads of _sessionCount are atomic, so don't bother locking for this quick check.
127117
if (_sessionCount == -1) throw new InvalidOperationException("Controller must be Initialized first");
@@ -132,11 +122,12 @@
132122
_sessionCount += 1;
133123
}
134124

135-
return session;
125+
// TODO: implement this
126+
return new SyncSessionModel(@"C:\path", "remote", "~/path", SyncSessionStatusCategory.Ok, "Watching",
127+
"Description", []);
136128
}
137129

138-
139-
public async Task<List<SyncSession>> ListSyncSessions(CancellationToken ct)
130+
public async Task<IEnumerable<SyncSessionModel>> ListSyncSessions(CancellationToken ct)
140131
{
141132
// reads of _sessionCount are atomic, so don't bother locking for this quick check.
142133
switch (_sessionCount)
@@ -146,12 +137,11 @@
146137
case 0:
147138
// If we already know there are no sessions, don't start up the daemon
148139
// again.
149-
return new List<SyncSession>();
140+
return [];
150141
}
151142

152-
var client = await EnsureDaemon(ct);
153-
// TODO: implement
154-
return new List<SyncSession>();
143+
// TODO: implement this
144+
return [];
155145
}
156146

157147
public async Task Initialize(CancellationToken ct)
@@ -190,7 +180,7 @@
190180
}
191181
}
192182

193-
public async Task TerminateSyncSession(SyncSession session, CancellationToken ct)
183+
public async Task TerminateSyncSession(string identifier, CancellationToken ct)
194184
{
195185
if (_sessionCount < 0) throw new InvalidOperationException("Controller must be Initialized first");
196186
var client = await EnsureDaemon(ct);
@@ -274,7 +264,7 @@
274264
// it up to 5 times x 100ms. Those issues should resolve themselves quickly if they are
275265
// going to at all.
276266
const int maxAttempts = 5;
277267
ListResponse? sessions = null;
278268
for (var attempts = 1; attempts <= maxAttempts; attempts++)
279269
{
280270
ct.ThrowIfCancellationRequested();
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Windows.Storage.Pickers;
7+
using Coder.Desktop.App.Models;
8+
using Coder.Desktop.App.Services;
9+
using CommunityToolkit.Mvvm.ComponentModel;
10+
using CommunityToolkit.Mvvm.Input;
11+
using Microsoft.UI.Dispatching;
12+
using Microsoft.UI.Xaml;
13+
using WinRT.Interop;
14+
15+
namespace Coder.Desktop.App.ViewModels;
16+
17+
public partial class FileSyncListViewModel : ObservableObject
18+
{
19+
private DispatcherQueue? _dispatcherQueue;
20+
21+
private readonly ISyncSessionController _syncSessionController;
22+
private readonly IRpcController _rpcController;
23+
private readonly ICredentialManager _credentialManager;
24+
25+
[ObservableProperty]
26+
[NotifyPropertyChangedFor(nameof(ShowUnavailable))]
27+
[NotifyPropertyChangedFor(nameof(ShowLoading))]
28+
[NotifyPropertyChangedFor(nameof(ShowError))]
29+
[NotifyPropertyChangedFor(nameof(ShowSessions))]
30+
public partial string? UnavailableMessage { get; set; } = null;
31+
32+
[ObservableProperty]
33+
[NotifyPropertyChangedFor(nameof(ShowLoading))]
34+
[NotifyPropertyChangedFor(nameof(ShowSessions))]
35+
public partial bool Loading { get; set; } = true;
36+
37+
[ObservableProperty]
38+
[NotifyPropertyChangedFor(nameof(ShowLoading))]
39+
[NotifyPropertyChangedFor(nameof(ShowError))]
40+
[NotifyPropertyChangedFor(nameof(ShowSessions))]
41+
public partial string? Error { get; set; } = null;
42+
43+
[ObservableProperty] public partial List<SyncSessionModel> Sessions { get; set; } = [];
44+
45+
[ObservableProperty] public partial bool CreatingNewSession { get; set; } = false;
46+
47+
[ObservableProperty]
48+
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
49+
public partial string NewSessionLocalPath { get; set; } = "";
50+
51+
[ObservableProperty]
52+
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
53+
public partial bool NewSessionLocalPathDialogOpen { get; set; } = false;
54+
55+
[ObservableProperty]
56+
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
57+
public partial string NewSessionRemoteName { get; set; } = "";
58+
59+
[ObservableProperty]
60+
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
61+
public partial string NewSessionRemotePath { get; set; } = "";
62+
// TODO: NewSessionRemotePathDialogOpen for remote path
63+
64+
public bool NewSessionCreateEnabled
65+
{
66+
get
67+
{
68+
if (string.IsNullOrWhiteSpace(NewSessionLocalPath)) return false;
69+
if (NewSessionLocalPathDialogOpen) return false;
70+
if (string.IsNullOrWhiteSpace(NewSessionRemoteName)) return false;
71+
if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false;
72+
return true;
73+
}
74+
}
75+
76+
// TODO: this could definitely be improved
77+
public bool ShowUnavailable => UnavailableMessage != null;
78+
public bool ShowLoading => Loading && UnavailableMessage == null && Error == null;
79+
public bool ShowError => UnavailableMessage == null && Error != null;
80+
public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null;
81+
82+
public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController,
83+
ICredentialManager credentialManager)
84+
{
85+
_syncSessionController = syncSessionController;
86+
_rpcController = rpcController;
87+
_credentialManager = credentialManager;
88+
89+
Sessions =
90+
[
91+
new SyncSessionModel(@"C:\Users\dean\git\coder-desktop-windows", "pog", "~/repos/coder-desktop-windows",
92+
SyncSessionStatusCategory.Ok, "Watching", "Some description", []),
93+
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Paused,
94+
"Paused",
95+
"Some description", []),
96+
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Conflicts,
97+
"Conflicts", "Some description", []),
98+
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Halted,
99+
"Halted on root emptied", "Some description", []),
100+
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Error,
101+
"Some error", "Some description", []),
102+
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Unknown,
103+
"Unknown", "Some description", []),
104+
new SyncSessionModel(@"C:\Users\dean\git\coder", "pog", "~/coder", SyncSessionStatusCategory.Working,
105+
"Reconciling", "Some description", []),
106+
];
107+
}
108+
109+
public void Initialize(DispatcherQueue dispatcherQueue)
110+
{
111+
_dispatcherQueue = dispatcherQueue;
112+
if (!_dispatcherQueue.HasThreadAccess)
113+
throw new InvalidOperationException("Initialize must be called from the UI thread");
114+
115+
_rpcController.StateChanged += RpcControllerStateChanged;
116+
_credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged;
117+
118+
var rpcModel = _rpcController.GetState();
119+
var credentialModel = _credentialManager.GetCachedCredentials();
120+
MaybeSetUnavailableMessage(rpcModel, credentialModel);
121+
122+
// TODO: Simulate loading until we have real data.
123+
Task.Delay(TimeSpan.FromSeconds(3)).ContinueWith(_ => _dispatcherQueue.TryEnqueue(() => Loading = false));
124+
}
125+
126+
private void RpcControllerStateChanged(object? sender, RpcModel rpcModel)
127+
{
128+
// Ensure we're on the UI thread.
129+
if (_dispatcherQueue == null) return;
130+
if (!_dispatcherQueue.HasThreadAccess)
131+
{
132+
_dispatcherQueue.TryEnqueue(() => RpcControllerStateChanged(sender, rpcModel));
133+
return;
134+
}
135+
136+
var credentialModel = _credentialManager.GetCachedCredentials();
137+
MaybeSetUnavailableMessage(rpcModel, credentialModel);
138+
}
139+
140+
private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel)
141+
{
142+
// Ensure we're on the UI thread.
143+
if (_dispatcherQueue == null) return;
144+
if (!_dispatcherQueue.HasThreadAccess)
145+
{
146+
_dispatcherQueue.TryEnqueue(() => CredentialManagerCredentialsChanged(sender, credentialModel));
147+
return;
148+
}
149+
150+
var rpcModel = _rpcController.GetState();
151+
MaybeSetUnavailableMessage(rpcModel, credentialModel);
152+
}
153+
154+
private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel)
155+
{
156+
if (rpcModel.RpcLifecycle != RpcLifecycle.Connected)
157+
UnavailableMessage =
158+
"Disconnected from the Windows service. Please see the tray window for more information.";
159+
else if (credentialModel.State != CredentialState.Valid)
160+
UnavailableMessage = "Please sign in to access file sync.";
161+
else if (rpcModel.VpnLifecycle != VpnLifecycle.Started)
162+
UnavailableMessage = "Please start Coder Connect from the tray window to access file sync.";
163+
else
164+
UnavailableMessage = null;
165+
}
166+
167+
private void ClearNewForm()
168+
{
169+
CreatingNewSession = false;
170+
NewSessionLocalPath = "";
171+
NewSessionRemoteName = "";
172+
NewSessionRemotePath = "";
173+
}
174+
175+
[RelayCommand]
176+
private void ReloadSessions()
177+
{
178+
Loading = true;
179+
Error = null;
180+
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
181+
_ = _syncSessionController.ListSyncSessions(cts.Token).ContinueWith(HandleList, cts.Token);
182+
}
183+
184+
private void HandleList(Task<IEnumerable<SyncSessionModel>> t)
185+
{
186+
// Ensure we're on the UI thread.
187+
if (_dispatcherQueue == null) return;
188+
if (!_dispatcherQueue.HasThreadAccess)
189+
{
190+
_dispatcherQueue.TryEnqueue(() => HandleList(t));
191+
return;
192+
}
193+
194+
if (t.IsCompletedSuccessfully)
195+
{
196+
Sessions = t.Result.ToList();
197+
Loading = false;
198+
return;
199+
}
200+
201+
Error = "Could not list sync sessions: ";
202+
if (t.IsCanceled) Error += new TaskCanceledException();
203+
else if (t.IsFaulted) Error += t.Exception;
204+
else Error += "no successful result or error";
205+
Loading = false;
206+
}
207+
208+
[RelayCommand]
209+
private void StartCreatingNewSession()
210+
{
211+
ClearNewForm();
212+
CreatingNewSession = true;
213+
}
214+
215+
public async Task OpenLocalPathSelectDialog(Window window)
216+
{
217+
var picker = new FolderPicker
218+
{
219+
SuggestedStartLocation = PickerLocationId.ComputerFolder,
220+
};
221+
222+
var hwnd = WindowNative.GetWindowHandle(window);
223+
InitializeWithWindow.Initialize(picker, hwnd);
224+
225+
NewSessionLocalPathDialogOpen = true;
226+
try
227+
{
228+
var path = await picker.PickSingleFolderAsync();
229+
if (path == null) return;
230+
NewSessionLocalPath = path.Path;
231+
}
232+
catch
233+
{
234+
// ignored
235+
}
236+
finally
237+
{
238+
NewSessionLocalPathDialogOpen = false;
239+
}
240+
}
241+
242+
[RelayCommand]
243+
private void CancelNewSession()
244+
{
245+
ClearNewForm();
246+
}
247+
248+
[RelayCommand]
249+
private void ConfirmNewSession()
250+
{
251+
// TODO: implement
252+
ClearNewForm();
253+
}
254+
}

‎App/ViewModels/TrayWindowViewModel.cs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
using System.Threading.Tasks;
55
using Coder.Desktop.App.Models;
66
using Coder.Desktop.App.Services;
7+
using Coder.Desktop.App.Views;
78
using Coder.Desktop.Vpn.Proto;
89
using CommunityToolkit.Mvvm.ComponentModel;
910
using CommunityToolkit.Mvvm.Input;
1011
using Google.Protobuf;
12+
using Microsoft.Extensions.DependencyInjection;
1113
using Microsoft.UI.Dispatching;
1214
using Microsoft.UI.Xaml;
1315
using Microsoft.UI.Xaml.Controls;
@@ -20,9 +22,12 @@ public partial class TrayWindowViewModel : ObservableObject
2022
private const int MaxAgents = 5;
2123
private const string DefaultDashboardUrl = "https://coder.com";
2224

25+
private readonly IServiceProvider _services;
2326
private readonly IRpcController _rpcController;
2427
private readonly ICredentialManager _credentialManager;
2528

29+
private FileSyncListWindow? _fileSyncListWindow;
30+
2631
private DispatcherQueue? _dispatcherQueue;
2732

2833
[ObservableProperty]
@@ -73,8 +78,10 @@ public partial class TrayWindowViewModel : ObservableObject
7378

7479
[ObservableProperty] public partial string DashboardUrl { get; set; } = "https://coder.com";
7580

76-
public TrayWindowViewModel(IRpcController rpcController, ICredentialManager credentialManager)
81+
public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController,
82+
ICredentialManager credentialManager)
7783
{
84+
_services = services;
7885
_rpcController = rpcController;
7986
_credentialManager = credentialManager;
8087
}
@@ -204,6 +211,14 @@ private string WorkspaceUri(Uri? baseUri, string? workspaceName)
204211

205212
private void UpdateFromCredentialsModel(CredentialModel credentialModel)
206213
{
214+
// Ensure we're on the UI thread.
215+
if (_dispatcherQueue == null) return;
216+
if (!_dispatcherQueue.HasThreadAccess)
217+
{
218+
_dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel));
219+
return;
220+
}
221+
207222
// HACK: the HyperlinkButton crashes the whole app if the initial URI
208223
// or this URI is invalid. CredentialModel.CoderUrl should never be
209224
// null while the Page is active as the Page is only displayed when
@@ -234,7 +249,7 @@ private async Task StartVpn()
234249
}
235250
catch (Exception e)
236251
{
237-
VpnFailedMessage = "Failed to start Coder Connect: " + MaybeUnwrapTunnelError(e);
252+
VpnFailedMessage = "Failed to start CoderVPN: " + MaybeUnwrapTunnelError(e);
238253
}
239254
}
240255

@@ -246,7 +261,7 @@ private async Task StopVpn()
246261
}
247262
catch (Exception e)
248263
{
249-
VpnFailedMessage = "Failed to stop Coder Connect: " + MaybeUnwrapTunnelError(e);
264+
VpnFailedMessage = "Failed to stop CoderVPN: " + MaybeUnwrapTunnelError(e);
250265
}
251266
}
252267

@@ -262,6 +277,22 @@ public void ToggleShowAllAgents()
262277
ShowAllAgents = !ShowAllAgents;
263278
}
264279

280+
[RelayCommand]
281+
public void ShowFileSyncListWindow()
282+
{
283+
// This is safe against concurrent access since it all happens in the
284+
// UI thread.
285+
if (_fileSyncListWindow != null)
286+
{
287+
_fileSyncListWindow.Activate();
288+
return;
289+
}
290+
291+
_fileSyncListWindow = _services.GetRequiredService<FileSyncListWindow>();
292+
_fileSyncListWindow.Closed += (_, _) => _fileSyncListWindow = null;
293+
_fileSyncListWindow.Activate();
294+
}
295+
265296
[RelayCommand]
266297
public void SignOut()
267298
{

‎App/Views/FileSyncListWindow.xaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
3+
<winuiex:WindowEx
4+
x:Class="Coder.Desktop.App.Views.FileSyncListWindow"
5+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
6+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
7+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
8+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
9+
xmlns:winuiex="using:WinUIEx"
10+
mc:Ignorable="d"
11+
Title="Coder File Sync"
12+
Width="1000" Height="300"
13+
MinWidth="1000" MinHeight="300">
14+
15+
<Window.SystemBackdrop>
16+
<DesktopAcrylicBackdrop />
17+
</Window.SystemBackdrop>
18+
19+
<Frame x:Name="RootFrame" />
20+
</winuiex:WindowEx>

‎App/Views/FileSyncListWindow.xaml.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using Coder.Desktop.App.ViewModels;
2+
using Coder.Desktop.App.Views.Pages;
3+
using Microsoft.UI.Xaml.Media;
4+
using WinUIEx;
5+
6+
namespace Coder.Desktop.App.Views;
7+
8+
public sealed partial class FileSyncListWindow : WindowEx
9+
{
10+
public readonly FileSyncListViewModel ViewModel;
11+
12+
public FileSyncListWindow(FileSyncListViewModel viewModel)
13+
{
14+
ViewModel = viewModel;
15+
InitializeComponent();
16+
SystemBackdrop = new DesktopAcrylicBackdrop();
17+
18+
ViewModel.Initialize(DispatcherQueue);
19+
RootFrame.Content = new FileSyncListMainPage(ViewModel, this);
20+
21+
this.CenterOnScreen();
22+
}
23+
}

‎App/Views/Pages/FileSyncListMainPage.xaml

Lines changed: 331 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using System.Threading.Tasks;
2+
using Coder.Desktop.App.ViewModels;
3+
using CommunityToolkit.Mvvm.Input;
4+
using Microsoft.UI.Xaml;
5+
using Microsoft.UI.Xaml.Controls;
6+
7+
namespace Coder.Desktop.App.Views.Pages;
8+
9+
public sealed partial class FileSyncListMainPage : Page
10+
{
11+
public FileSyncListViewModel ViewModel;
12+
13+
private readonly Window _window;
14+
15+
public FileSyncListMainPage(FileSyncListViewModel viewModel, Window window)
16+
{
17+
ViewModel = viewModel; // already initialized
18+
_window = window;
19+
InitializeComponent();
20+
}
21+
22+
// Adds a tooltip with the full text when it's ellipsized.
23+
private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedChangedEventArgs e)
24+
{
25+
ToolTipService.SetToolTip(sender, null);
26+
if (!sender.IsTextTrimmed) return;
27+
28+
var toolTip = new ToolTip
29+
{
30+
Content = sender.Text,
31+
};
32+
ToolTipService.SetToolTip(sender, toolTip);
33+
}
34+
35+
[RelayCommand]
36+
public async Task OpenLocalPathSelectDialog()
37+
{
38+
await ViewModel.OpenLocalPathSelectDialog(_window);
39+
}
40+
}

‎App/Views/Pages/TrayWindowMainPage.xaml

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,11 @@
1212
mc:Ignorable="d">
1313

1414
<Page.Resources>
15-
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
16-
1715
<converters:VpnLifecycleToBoolConverter x:Key="ConnectingBoolConverter" Unknown="true" Starting="true"
1816
Stopping="true" />
1917
<converters:VpnLifecycleToBoolConverter x:Key="NotConnectingBoolConverter" Started="true" Stopped="true" />
2018
<converters:VpnLifecycleToBoolConverter x:Key="StoppedBoolConverter" Stopped="true" />
2119

22-
<converters:AgentStatusToColorConverter x:Key="AgentStatusToColorConverter" />
2320
<converters:BoolToObjectConverter x:Key="ShowMoreLessTextConverter" TrueValue="Show less"
2421
FalseValue="Show more" />
2522
</Page.Resources>
@@ -118,22 +115,50 @@
118115
HorizontalAlignment="Stretch"
119116
Spacing="10">
120117

118+
<StackPanel.Resources>
119+
<converters:StringToBrushSelector
120+
x:Key="StatusColor"
121+
SelectedKey="{x:Bind Path=ConnectionStatus, Mode=OneWay}">
122+
123+
<converters:StringToBrushSelectorItem>
124+
<converters:StringToBrushSelectorItem.Value>
125+
<SolidColorBrush Color="#8e8e93" />
126+
</converters:StringToBrushSelectorItem.Value>
127+
</converters:StringToBrushSelectorItem>
128+
<converters:StringToBrushSelectorItem Key="Red">
129+
<converters:StringToBrushSelectorItem.Value>
130+
<SolidColorBrush Color="#ff3b30" />
131+
</converters:StringToBrushSelectorItem.Value>
132+
</converters:StringToBrushSelectorItem>
133+
<converters:StringToBrushSelectorItem Key="Yellow">
134+
<converters:StringToBrushSelectorItem.Value>
135+
<SolidColorBrush Color="#ffcc01" />
136+
</converters:StringToBrushSelectorItem.Value>
137+
</converters:StringToBrushSelectorItem>
138+
<converters:StringToBrushSelectorItem Key="Green">
139+
<converters:StringToBrushSelectorItem.Value>
140+
<SolidColorBrush Color="#34c759" />
141+
</converters:StringToBrushSelectorItem.Value>
142+
</converters:StringToBrushSelectorItem>
143+
</converters:StringToBrushSelector>
144+
</StackPanel.Resources>
145+
121146
<Canvas
122147
HorizontalAlignment="Center"
123148
VerticalAlignment="Center"
124149
Height="14" Width="14"
125150
Margin="0,1,0,0">
126151

127152
<Ellipse
128-
Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}"
153+
Fill="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}"
129154
Opacity="0.2"
130155
Width="14"
131156
Height="14"
132157
Canvas.Left="0"
133158
Canvas.Top="0" />
134159

135160
<Ellipse
136-
Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}"
161+
Fill="{Binding Source={StaticResource StatusColor}, Path=SelectedObject}"
137162
Width="8"
138163
Height="8"
139164
VerticalAlignment="Center"
@@ -203,6 +228,18 @@
203228

204229
<controls:HorizontalRule />
205230

231+
<HyperlinkButton
232+
Command="{x:Bind ViewModel.ShowFileSyncListWindowCommand, Mode=OneWay}"
233+
Margin="-12,0"
234+
HorizontalAlignment="Stretch"
235+
HorizontalContentAlignment="Left">
236+
237+
<!-- TODO: status icon if there is a problem -->
238+
<TextBlock Text="File sync" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
239+
</HyperlinkButton>
240+
241+
<controls:HorizontalRule />
242+
206243
<HyperlinkButton
207244
Command="{x:Bind ViewModel.SignOutCommand, Mode=OneWay}"
208245
IsEnabled="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource StoppedBoolConverter}, Mode=OneWay}"

‎App/packages.lock.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@
8585
"Microsoft.Windows.SDK.BuildTools": "10.0.22621.756"
8686
}
8787
},
88+
"WinUIEx": {
89+
"type": "Direct",
90+
"requested": "[2.5.1, )",
91+
"resolved": "2.5.1",
92+
"contentHash": "ihW4bA2quKbwWBOl5Uu80jBZagc4cT4E6CdExmvSZ05Qwz0jgoGyZuSTKcU9Uz7lZlQij3KxNor0dGXNUyIV9Q==",
93+
"dependencies": {
94+
"Microsoft.WindowsAppSDK": "1.6.240829007"
95+
}
96+
},
8897
"Google.Protobuf": {
8998
"type": "Transitive",
9099
"resolved": "3.29.3",
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using Coder.Desktop.App.Converters;
2+
3+
namespace Coder.Desktop.Tests.App.Converters;
4+
5+
[TestFixture]
6+
public class FriendlyByteConverterTest
7+
{
8+
[Test]
9+
public void EndToEnd()
10+
{
11+
var cases = new List<(object, string)>
12+
{
13+
(0, "0 B"),
14+
((uint)0, "0 B"),
15+
((long)0, "0 B"),
16+
((ulong)0, "0 B"),
17+
18+
(1, "1 B"),
19+
(1024, "1 KB"),
20+
((ulong)(1.1 * 1024), "1.1 KB"),
21+
(1024 * 1024, "1 MB"),
22+
(1024 * 1024 * 1024, "1 GB"),
23+
((ulong)1024 * 1024 * 1024 * 1024, "1 TB"),
24+
((ulong)1024 * 1024 * 1024 * 1024 * 1024, "1 PB"),
25+
((ulong)1024 * 1024 * 1024 * 1024 * 1024 * 1024, "1 EB"),
26+
(ulong.MaxValue, "16 EB"),
27+
};
28+
29+
var converter = new FriendlyByteConverter();
30+
foreach (var (input, expected) in cases)
31+
{
32+
var actual = converter.Convert(input, typeof(string), null, null);
33+
Assert.That(actual, Is.EqualTo(expected), $"case ({input.GetType()}){input}");
34+
}
35+
}
36+
}

‎Tests.App/Services/MutagenControllerTest.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,12 @@ public async Task CreateRestartsDaemon(CancellationToken ct)
9090
await using (var controller = new MutagenController(_mutagenBinaryPath, dataDirectory))
9191
{
9292
await controller.Initialize(ct);
93-
await controller.CreateSyncSession(new SyncSession(), ct);
93+
await controller.CreateSyncSession(new CreateSyncSessionRequest(), ct);
9494
}
9595

9696
var logPath = Path.Combine(dataDirectory, "daemon.log");
9797
Assert.That(File.Exists(logPath));
98-
var logLines = File.ReadAllLines(logPath);
98+
var logLines = await File.ReadAllLinesAsync(logPath, ct);
9999

100100
// Here we're going to use the log to verify the daemon was started 2 times.
101101
// slightly brittle, but unlikely this log line will change.
@@ -114,7 +114,7 @@ public async Task Orphaned(CancellationToken ct)
114114
{
115115
controller1 = new MutagenController(_mutagenBinaryPath, dataDirectory);
116116
await controller1.Initialize(ct);
117-
await controller1.CreateSyncSession(new SyncSession(), ct);
117+
await controller1.CreateSyncSession(new CreateSyncSessionRequest(), ct);
118118

119119
controller2 = new MutagenController(_mutagenBinaryPath, dataDirectory);
120120
await controller2.Initialize(ct);
@@ -127,7 +127,7 @@ public async Task Orphaned(CancellationToken ct)
127127

128128
var logPath = Path.Combine(dataDirectory, "daemon.log");
129129
Assert.That(File.Exists(logPath));
130-
var logLines = File.ReadAllLines(logPath);
130+
var logLines = await File.ReadAllLinesAsync(logPath, ct);
131131

132132
// Here we're going to use the log to verify the daemon was started 3 times.
133133
// slightly brittle, but unlikely this log line will change.

0 commit comments

Comments
 (0)
Please sign in to comment.