Skip to content

Commit 3d83551

Browse files
committed
ComboBox for agent selection, modal behavior for directory window
1 parent 8a7acc1 commit 3d83551

16 files changed

+173
-87
lines changed

App/App.csproj

-16
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,6 @@
2929
<SelfContained>false</SelfContained>
3030
</PropertyGroup>
3131

32-
<ItemGroup>
33-
<None Remove="Views\Pages\DirectoryPickerMainPage.xaml" />
34-
</ItemGroup>
35-
3632
<ItemGroup>
3733
<Content Include="coder.ico" />
3834
</ItemGroup>
@@ -79,16 +75,4 @@
7975
<ProjectReference Include="..\Vpn.Proto\Vpn.Proto.csproj" />
8076
<ProjectReference Include="..\Vpn\Vpn.csproj" />
8177
</ItemGroup>
82-
83-
<ItemGroup>
84-
<Page Update="Views\Pages\DirectoryPickerMainPage.xaml">
85-
<Generator>MSBuild:Compile</Generator>
86-
</Page>
87-
</ItemGroup>
88-
89-
<ItemGroup>
90-
<Page Update="Views\DirectoryPickerWindow.xaml">
91-
<Generator>MSBuild:Compile</Generator>
92-
</Page>
93-
</ItemGroup>
9478
</Project>

App/Services/MutagenController.cs

+1
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,7 @@ private void StartDaemonProcess()
569569
{
570570
// ignored
571571
}
572+
572573
_daemonProcess?.Dispose();
573574
_logWriter?.Dispose();
574575
_daemonProcess = null;

App/ViewModels/DirectoryPickerViewModel.cs

+13-18
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@ namespace Coder.Desktop.App.ViewModels;
1414

1515
public class DirectoryPickerBreadcrumb
1616
{
17+
// HACK: you cannot access the parent context when inside an ItemsRepeater.
18+
public required DirectoryPickerViewModel ViewModel;
19+
1720
public required string Name { get; init; }
21+
1822
public required IReadOnlyList<string> AbsolutePathSegments { get; init; }
23+
1924
// HACK: we need to know which one is first so we don't prepend an arrow
2025
// icon. You can't get the index of the current ItemsRepeater item in XAML.
2126
public required bool IsFirst { get; init; }
22-
23-
// HACK: you cannot access the parent context when inside an ItemsRepeater.
24-
public required DirectoryPickerViewModel ViewModel;
2527
}
2628

2729
public enum DirectoryPickerItemKind
@@ -33,13 +35,13 @@ public enum DirectoryPickerItemKind
3335

3436
public class DirectoryPickerItem
3537
{
38+
// HACK: you cannot access the parent context when inside an ItemsRepeater.
39+
public required DirectoryPickerViewModel ViewModel;
40+
3641
public required DirectoryPickerItemKind Kind { get; init; }
3742
public required string Name { get; init; }
3843
public required IReadOnlyList<string> AbsolutePathSegments { get; init; }
3944

40-
// HACK: you cannot access the parent context when inside an ItemsRepeater.
41-
public required DirectoryPickerViewModel ViewModel;
42-
4345
public bool Selectable => Kind is DirectoryPickerItemKind.ParentDirectory or DirectoryPickerItemKind.Directory;
4446
}
4547

@@ -74,18 +76,15 @@ public partial class DirectoryPickerViewModel : ObservableObject
7476
[NotifyPropertyChangedFor(nameof(ShowListScreen))]
7577
public partial string? InitialLoadError { get; set; } = null;
7678

77-
[ObservableProperty]
78-
public partial bool NavigatingLoading { get; set; } = false;
79+
[ObservableProperty] public partial bool NavigatingLoading { get; set; } = false;
7980

8081
[ObservableProperty]
8182
[NotifyPropertyChangedFor(nameof(IsSelectable))]
8283
public partial string CurrentDirectory { get; set; } = "";
8384

84-
[ObservableProperty]
85-
public partial IReadOnlyList<DirectoryPickerBreadcrumb> Breadcrumbs { get; set; } = [];
85+
[ObservableProperty] public partial IReadOnlyList<DirectoryPickerBreadcrumb> Breadcrumbs { get; set; } = [];
8686

87-
[ObservableProperty]
88-
public partial IReadOnlyList<DirectoryPickerItem> Items { get; set; } = [];
87+
[ObservableProperty] public partial IReadOnlyList<DirectoryPickerItem> Items { get; set; } = [];
8988

9089
public bool ShowLoadingScreen => InitialLoadError == null && InitialLoading;
9190
public bool ShowErrorScreen => InitialLoadError != null;
@@ -100,7 +99,7 @@ public partial class DirectoryPickerViewModel : ObservableObject
10099

101100
public DirectoryPickerViewModel(IAgentApiClientFactory clientFactory, string agentFqdn)
102101
{
103-
_client = clientFactory.Create(hostname: agentFqdn);
102+
_client = clientFactory.Create(agentFqdn);
104103
AgentFqdn = agentFqdn;
105104
}
106105

@@ -218,7 +217,7 @@ private void ProcessResponse(ListDirectoryResponse res)
218217

219218
var breadcrumbs = new List<DirectoryPickerBreadcrumb>(res.AbsolutePath.Count + 1)
220219
{
221-
new DirectoryPickerBreadcrumb
220+
new()
222221
{
223222
Name = "(root)",
224223
AbsolutePathSegments = [],
@@ -227,27 +226,23 @@ private void ProcessResponse(ListDirectoryResponse res)
227226
},
228227
};
229228
for (var i = 0; i < res.AbsolutePath.Count; i++)
230-
{
231229
breadcrumbs.Add(new DirectoryPickerBreadcrumb
232230
{
233231
Name = res.AbsolutePath[i],
234232
AbsolutePathSegments = res.AbsolutePath[..(i + 1)],
235233
IsFirst = false,
236234
ViewModel = this,
237235
});
238-
}
239236

240237
var items = new List<DirectoryPickerItem>(res.Contents.Count + 1);
241238
if (res.AbsolutePath.Count != 0)
242-
{
243239
items.Add(new DirectoryPickerItem
244240
{
245241
Kind = DirectoryPickerItemKind.ParentDirectory,
246242
Name = "..",
247243
AbsolutePathSegments = res.AbsolutePath[..^1],
248244
ViewModel = this,
249245
});
250-
}
251246

252247
foreach (var item in res.Contents)
253248
{

App/ViewModels/FileSyncListViewModel.cs

+47-15
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public partial class FileSyncListViewModel : ObservableObject
2121
{
2222
private Window? _window;
2323
private DispatcherQueue? _dispatcherQueue;
24-
private Window? _remotePickerWindow;
24+
private DirectoryPickerWindow? _remotePickerWindow;
2525

2626
private readonly ISyncSessionController _syncSessionController;
2727
private readonly IRpcController _rpcController;
@@ -50,7 +50,7 @@ public partial class FileSyncListViewModel : ObservableObject
5050

5151
[ObservableProperty] public partial bool OperationInProgress { get; set; } = false;
5252

53-
[ObservableProperty] public partial List<SyncSessionViewModel> Sessions { get; set; } = [];
53+
[ObservableProperty] public partial IReadOnlyList<SyncSessionViewModel> Sessions { get; set; } = [];
5454

5555
[ObservableProperty] public partial bool CreatingNewSession { get; set; } = false;
5656

@@ -62,18 +62,29 @@ public partial class FileSyncListViewModel : ObservableObject
6262
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
6363
public partial bool NewSessionLocalPathDialogOpen { get; set; } = false;
6464

65+
[ObservableProperty]
66+
[NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))]
67+
public partial IReadOnlyList<string> AvailableHosts { get; set; } = [];
68+
6569
[ObservableProperty]
6670
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
67-
public partial string NewSessionRemoteHost { get; set; } = "";
71+
[NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))]
72+
public partial string? NewSessionRemoteHost { get; set; } = null;
6873

6974
[ObservableProperty]
7075
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
7176
public partial string NewSessionRemotePath { get; set; } = "";
7277

7378
[ObservableProperty]
7479
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
80+
[NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))]
7581
public partial bool NewSessionRemotePathDialogOpen { get; set; } = false;
7682

83+
public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0;
84+
85+
public bool NewSessionRemotePathDialogEnabled =>
86+
!string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen;
87+
7788
public bool NewSessionCreateEnabled
7889
{
7990
get
@@ -109,17 +120,6 @@ public void Initialize(Window window, DispatcherQueue dispatcherQueue)
109120
if (!_dispatcherQueue.HasThreadAccess)
110121
throw new InvalidOperationException("Initialize must be called from the UI thread");
111122

112-
// Force the remote picker to activate when activating the file sync
113-
// list window if open.
114-
_window.Activated += (_, e) =>
115-
{
116-
if (_remotePickerWindow is not null && e.WindowActivationState is WindowActivationState.PointerActivated)
117-
{
118-
e.Handled = true;
119-
_remotePickerWindow.Activate();
120-
}
121-
};
122-
123123
_rpcController.StateChanged += RpcControllerStateChanged;
124124
_credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged;
125125
_syncSessionController.StateChanged += SyncSessionStateChanged;
@@ -199,8 +199,13 @@ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel crede
199199
else
200200
{
201201
UnavailableMessage = null;
202+
// Reload if we transitioned from unavailable to available.
202203
if (oldMessage != null) ReloadSessions();
203204
}
205+
206+
// When transitioning from available to unavailable:
207+
if (oldMessage == null && UnavailableMessage != null)
208+
ClearNewForm();
204209
}
205210

206211
private void UpdateSyncSessionState(SyncSessionControllerStateModel syncSessionState)
@@ -253,10 +258,34 @@ private void HandleRefresh(Task<SyncSessionControllerStateModel> t)
253258
Loading = false;
254259
}
255260

261+
// Overriding AvailableHosts seems to make the ComboBox clear its value, so
262+
// we only do this while the create form is not open.
263+
// Must be called in UI thread.
264+
private void SetAvailableHostsFromRpcModel(RpcModel rpcModel)
265+
{
266+
var hosts = new List<string>(rpcModel.Agents.Count);
267+
// Agents will only contain started agents.
268+
foreach (var agent in rpcModel.Agents)
269+
{
270+
var fqdn = agent.Fqdn
271+
.Select(a => a.Trim('.'))
272+
.Where(a => !string.IsNullOrWhiteSpace(a))
273+
.Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b);
274+
if (string.IsNullOrWhiteSpace(fqdn))
275+
continue;
276+
hosts.Add(fqdn);
277+
}
278+
279+
NewSessionRemoteHost = null;
280+
AvailableHosts = hosts;
281+
}
282+
256283
[RelayCommand]
257284
private void StartCreatingNewSession()
258285
{
259286
ClearNewForm();
287+
// Ensure we have a fresh hosts list before we open the form.
288+
SetAvailableHostsFromRpcModel(_rpcController.GetState());
260289
CreatingNewSession = true;
261290
}
262291

@@ -293,6 +322,8 @@ public async Task OpenLocalPathSelectDialog()
293322
[RelayCommand]
294323
public void OpenRemotePathSelectDialog()
295324
{
325+
if (string.IsNullOrWhiteSpace(NewSessionRemoteHost))
326+
return;
296327
if (_remotePickerWindow is not null)
297328
{
298329
_remotePickerWindow.Activate();
@@ -304,6 +335,7 @@ public void OpenRemotePathSelectDialog()
304335
pickerViewModel.PathSelected += OnRemotePathSelected;
305336

306337
_remotePickerWindow = new DirectoryPickerWindow(pickerViewModel);
338+
_remotePickerWindow.SetParent(_window);
307339
_remotePickerWindow.Closed += (_, _) =>
308340
{
309341
_remotePickerWindow = null;
@@ -347,7 +379,7 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest
347379
Beta = new CreateSyncSessionRequest.Endpoint
348380
{
349381
Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh,
350-
Host = NewSessionRemoteHost,
382+
Host = NewSessionRemoteHost!,
351383
Path = NewSessionRemotePath,
352384
},
353385
}, cts.Token);

App/ViewModels/TrayWindowViewModel.cs

+1
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
178178
{
179179
// We just assume that it's a single-agent workspace.
180180
Hostname = workspace.Name,
181+
// TODO: this needs to get the suffix from the server
181182
HostnameSuffix = ".coder",
182183
ConnectionStatus = AgentConnectionStatus.Gray,
183184
DashboardUrl = WorkspaceUri(coderUri, workspace.Name),

App/Views/DirectoryPickerWindow.xaml

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
<?xml version="1.0" encoding="utf-8"?>
2+
23
<winuiex:WindowEx
34
x:Class="Coder.Desktop.App.Views.DirectoryPickerWindow"
45
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
56
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
67
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
78
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
89
xmlns:winuiex="using:WinUIEx"
9-
xmlns:locals="using:Coder.Desktop.App.Views"
10-
xmlns:converters="using:Coder.Desktop.App.Converters"
1110
mc:Ignorable="d"
1211
Title="Directory Picker"
1312
Width="400" Height="600"

App/Views/DirectoryPickerWindow.xaml.cs

+73-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using Windows.Graphics;
14
using Coder.Desktop.App.ViewModels;
25
using Coder.Desktop.App.Views.Pages;
6+
using Microsoft.UI.Windowing;
7+
using Microsoft.UI.Xaml;
38
using Microsoft.UI.Xaml.Media;
9+
using WinRT.Interop;
410
using WinUIEx;
511

612
namespace Coder.Desktop.App.Views;
@@ -15,7 +21,73 @@ public DirectoryPickerWindow(DirectoryPickerViewModel viewModel)
1521
viewModel.Initialize(this, DispatcherQueue);
1622
RootFrame.Content = new DirectoryPickerMainPage(viewModel);
1723

18-
// TODO: this should appear near the mouse instead, similar to the tray window
24+
// This will be moved to the center of the parent window in SetParent.
1925
this.CenterOnScreen();
2026
}
27+
28+
public void SetParent(Window parentWindow)
29+
{
30+
// Move the window to the center of the parent window.
31+
var scale = DisplayScale.WindowScale(parentWindow);
32+
var windowPos = new PointInt32(
33+
parentWindow.AppWindow.Position.X + parentWindow.AppWindow.Size.Width / 2 - AppWindow.Size.Width / 2,
34+
parentWindow.AppWindow.Position.Y + parentWindow.AppWindow.Size.Height / 2 - AppWindow.Size.Height / 2
35+
);
36+
37+
// Ensure we stay within the display.
38+
var workArea = DisplayArea.GetFromPoint(parentWindow.AppWindow.Position, DisplayAreaFallback.Primary).WorkArea;
39+
if (windowPos.X + AppWindow.Size.Width > workArea.X + workArea.Width) // right edge
40+
windowPos.X = workArea.X + workArea.Width - AppWindow.Size.Width;
41+
if (windowPos.Y + AppWindow.Size.Height > workArea.Y + workArea.Height) // bottom edge
42+
windowPos.Y = workArea.Y + workArea.Height - AppWindow.Size.Height;
43+
if (windowPos.X < workArea.X) // left edge
44+
windowPos.X = workArea.X;
45+
if (windowPos.Y < workArea.Y) // top edge
46+
windowPos.Y = workArea.Y;
47+
48+
AppWindow.Move(windowPos);
49+
50+
var parentHandle = WindowNative.GetWindowHandle(parentWindow);
51+
var thisHandle = WindowNative.GetWindowHandle(this);
52+
53+
// Set the parent window in win API.
54+
NativeApi.SetWindowParent(thisHandle, parentHandle);
55+
56+
// Override the presenter, which allows us to enable modal-like
57+
// behavior for this window:
58+
// - Disables the parent window
59+
// - Any activations of the parent window will play a bell sound and
60+
// focus the modal window
61+
//
62+
// This behavior is very similar to the native file/directory picker on
63+
// Windows.
64+
var presenter = OverlappedPresenter.CreateForDialog();
65+
presenter.IsModal = true;
66+
AppWindow.SetPresenter(presenter);
67+
AppWindow.Show();
68+
69+
// Cascade close events.
70+
parentWindow.Closed += OnParentWindowClosed;
71+
Closed += (_, _) =>
72+
{
73+
parentWindow.Closed -= OnParentWindowClosed;
74+
parentWindow.Activate();
75+
};
76+
}
77+
78+
private void OnParentWindowClosed(object? sender, WindowEventArgs e)
79+
{
80+
Close();
81+
}
82+
83+
private static class NativeApi
84+
{
85+
[DllImport("user32.dll")]
86+
private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
87+
88+
public static void SetWindowParent(IntPtr window, IntPtr parent)
89+
{
90+
SetWindowLongPtr(window, -8, parent);
91+
}
92+
}
2193
}

0 commit comments

Comments
 (0)