From 8a7acc1c84bc688c02291917b08ef4fc4ee1d0aa Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 23 Apr 2025 00:37:54 +1000 Subject: [PATCH 1/4] feat: add remote directory picker to file sync Adds a new remote directory picker window used when creating a file sync to select the remote directory. --- App/App.csproj | 17 ++ App/App.xaml.cs | 5 + App/Converters/DependencyObjectSelector.cs | 4 + App/Services/CredentialManager.cs | 1 + App/Services/MutagenController.cs | 64 +++-- App/ViewModels/DirectoryPickerViewModel.cs | 268 ++++++++++++++++++ App/ViewModels/FileSyncListViewModel.cs | 72 ++++- App/Views/DirectoryPickerWindow.xaml | 21 ++ App/Views/DirectoryPickerWindow.xaml.cs | 21 ++ App/Views/Pages/DirectoryPickerMainPage.xaml | 173 +++++++++++ .../Pages/DirectoryPickerMainPage.xaml.cs | 27 ++ App/Views/Pages/FileSyncListMainPage.xaml | 51 +++- App/Views/Pages/FileSyncListMainPage.xaml.cs | 8 - App/packages.lock.json | 24 ++ CoderSdk/Agent/AgentApiClient.cs | 61 ++++ CoderSdk/Agent/ListDirectory.cs | 50 ++++ CoderSdk/Coder/CoderApiClient.cs | 71 +++++ CoderSdk/{ => Coder}/Deployment.cs | 2 +- CoderSdk/{ => Coder}/Users.cs | 2 +- CoderSdk/CoderApiClient.cs | 119 -------- CoderSdk/Errors.cs | 15 +- CoderSdk/JsonHttpClient.cs | 82 ++++++ Vpn.Service/Manager.cs | 2 +- 23 files changed, 990 insertions(+), 170 deletions(-) create mode 100644 App/ViewModels/DirectoryPickerViewModel.cs create mode 100644 App/Views/DirectoryPickerWindow.xaml create mode 100644 App/Views/DirectoryPickerWindow.xaml.cs create mode 100644 App/Views/Pages/DirectoryPickerMainPage.xaml create mode 100644 App/Views/Pages/DirectoryPickerMainPage.xaml.cs create mode 100644 CoderSdk/Agent/AgentApiClient.cs create mode 100644 CoderSdk/Agent/ListDirectory.cs create mode 100644 CoderSdk/Coder/CoderApiClient.cs rename CoderSdk/{ => Coder}/Deployment.cs (91%) rename CoderSdk/{ => Coder}/Users.cs (91%) delete mode 100644 CoderSdk/CoderApiClient.cs create mode 100644 CoderSdk/JsonHttpClient.cs diff --git a/App/App.csproj b/App/App.csproj index 2a15166..1e0bbc3 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -29,6 +29,10 @@ false + + + + @@ -56,6 +60,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -74,4 +79,16 @@ + + + + MSBuild:Compile + + + + + + MSBuild:Compile + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 4a35a0f..216b9fa 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -7,6 +7,7 @@ using Coder.Desktop.App.ViewModels; using Coder.Desktop.App.Views; using Coder.Desktop.App.Views.Pages; +using Coder.Desktop.CoderSdk.Agent; using Coder.Desktop.Vpn; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -37,6 +38,8 @@ public App() var services = builder.Services; + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); @@ -53,6 +56,8 @@ public App() // FileSyncListMainPage is created by FileSyncListWindow. services.AddTransient(); + // DirectoryPickerWindow views and view models are created by FileSyncListViewModel. + // TrayWindow views and view models services.AddTransient(); services.AddTransient(); diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs index 8c1570f..a31c33b 100644 --- a/App/Converters/DependencyObjectSelector.cs +++ b/App/Converters/DependencyObjectSelector.cs @@ -186,3 +186,7 @@ private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyP public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem; public sealed class StringToBrushSelector : DependencyObjectSelector; + +public sealed class StringToStringSelectorItem : DependencyObjectSelectorItem; + +public sealed class StringToStringSelector : DependencyObjectSelector; diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index 41a8dc7..a2f6567 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn.Utilities; namespace Coder.Desktop.App.Services; diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index dd489df..212101f 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -12,6 +12,7 @@ using Coder.Desktop.MutagenSdk.Proto.Service.Prompting; using Coder.Desktop.MutagenSdk.Proto.Service.Synchronization; using Coder.Desktop.MutagenSdk.Proto.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Synchronization.Core.Ignore; using Coder.Desktop.MutagenSdk.Proto.Url; using Coder.Desktop.Vpn.Utilities; using Grpc.Core; @@ -213,8 +214,11 @@ public async Task CreateSyncSession(CreateSyncSessionRequest r { Alpha = req.Alpha.MutagenUrl, Beta = req.Beta.MutagenUrl, - // TODO: probably should set these at some point - Configuration = new Configuration(), + // TODO: probably should add a configuration page for these at some point + Configuration = new Configuration + { + IgnoreVCSMode = IgnoreVCSMode.Ignore, + }, ConfigurationAlpha = new Configuration(), ConfigurationBeta = new Configuration(), }, @@ -534,25 +538,43 @@ private void StartDaemonProcess() Directory.CreateDirectory(_mutagenDataDirectory); var logPath = Path.Combine(_mutagenDataDirectory, "daemon.log"); var logStream = new StreamWriter(logPath, true); - - _daemonProcess = new Process(); - _daemonProcess.StartInfo.FileName = _mutagenExecutablePath; - _daemonProcess.StartInfo.Arguments = "daemon run"; - _daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory); - // hide the console window - _daemonProcess.StartInfo.CreateNoWindow = true; - // shell needs to be disabled since we set the environment - // https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.environment?view=net-8.0 - _daemonProcess.StartInfo.UseShellExecute = false; - _daemonProcess.StartInfo.RedirectStandardError = true; - // TODO: log exited process - // _daemonProcess.Exited += ... - if (!_daemonProcess.Start()) - throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false"); - - var writer = new LogWriter(_daemonProcess.StandardError, logStream); - Task.Run(() => { _ = writer.Run(); }); - _logWriter = writer; + try + { + _daemonProcess = new Process(); + _daemonProcess.StartInfo.FileName = _mutagenExecutablePath; + _daemonProcess.StartInfo.Arguments = "daemon run"; + _daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory); + // hide the console window + _daemonProcess.StartInfo.CreateNoWindow = true; + // shell needs to be disabled since we set the environment + // https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.environment?view=net-8.0 + _daemonProcess.StartInfo.UseShellExecute = false; + _daemonProcess.StartInfo.RedirectStandardError = true; + // TODO: log exited process + // _daemonProcess.Exited += ... + if (!_daemonProcess.Start()) + throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false"); + + var writer = new LogWriter(_daemonProcess.StandardError, logStream); + Task.Run(() => { _ = writer.Run(); }); + _logWriter = writer; + } + catch + { + try + { + _daemonProcess?.Kill(); + } + catch + { + // ignored + } + _daemonProcess?.Dispose(); + _logWriter?.Dispose(); + _daemonProcess = null; + _logWriter = null; + throw; + } } /// diff --git a/App/ViewModels/DirectoryPickerViewModel.cs b/App/ViewModels/DirectoryPickerViewModel.cs new file mode 100644 index 0000000..f96bf7b --- /dev/null +++ b/App/ViewModels/DirectoryPickerViewModel.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.CoderSdk.Agent; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.ViewModels; + +public class DirectoryPickerBreadcrumb +{ + public required string Name { get; init; } + public required IReadOnlyList AbsolutePathSegments { get; init; } + // HACK: we need to know which one is first so we don't prepend an arrow + // icon. You can't get the index of the current ItemsRepeater item in XAML. + public required bool IsFirst { get; init; } + + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; +} + +public enum DirectoryPickerItemKind +{ + ParentDirectory, // aka. ".." + Directory, + File, // includes everything else +} + +public class DirectoryPickerItem +{ + public required DirectoryPickerItemKind Kind { get; init; } + public required string Name { get; init; } + public required IReadOnlyList AbsolutePathSegments { get; init; } + + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; + + public bool Selectable => Kind is DirectoryPickerItemKind.ParentDirectory or DirectoryPickerItemKind.Directory; +} + +public partial class DirectoryPickerViewModel : ObservableObject +{ + // PathSelected will be called ONCE when the user either cancels or selects + // a directory. If the user cancelled, the path will be null. + public event EventHandler? PathSelected; + + private const int RequestTimeoutMilliseconds = 15_000; + + private readonly IAgentApiClient _client; + + private Window? _window; + private DispatcherQueue? _dispatcherQueue; + + public readonly string AgentFqdn; + + // The initial loading screen is differentiated from subsequent loading + // screens because: + // 1. We don't want to show a broken state while the page is loading. + // 2. An error dialog allows the user to get to a broken state with no + // breadcrumbs, no items, etc. with no chance to reload. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))] + [NotifyPropertyChangedFor(nameof(ShowListScreen))] + public partial bool InitialLoading { get; set; } = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))] + [NotifyPropertyChangedFor(nameof(ShowErrorScreen))] + [NotifyPropertyChangedFor(nameof(ShowListScreen))] + public partial string? InitialLoadError { get; set; } = null; + + [ObservableProperty] + public partial bool NavigatingLoading { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSelectable))] + public partial string CurrentDirectory { get; set; } = ""; + + [ObservableProperty] + public partial IReadOnlyList Breadcrumbs { get; set; } = []; + + [ObservableProperty] + public partial IReadOnlyList Items { get; set; } = []; + + public bool ShowLoadingScreen => InitialLoadError == null && InitialLoading; + public bool ShowErrorScreen => InitialLoadError != null; + public bool ShowListScreen => InitialLoadError == null && !InitialLoading; + + // The "root" directory on Windows isn't a real thing, but in our model + // it's a drive listing. We don't allow users to select the fake drive + // listing directory. + // + // On Linux, this will never be empty since the highest you can go is "/". + public bool IsSelectable => CurrentDirectory != ""; + + public DirectoryPickerViewModel(IAgentApiClientFactory clientFactory, string agentFqdn) + { + _client = clientFactory.Create(hostname: agentFqdn); + AgentFqdn = agentFqdn; + } + + public void Initialize(Window window, DispatcherQueue dispatcherQueue) + { + _window = window; + _dispatcherQueue = dispatcherQueue; + if (!_dispatcherQueue.HasThreadAccess) + throw new InvalidOperationException("Initialize must be called from the UI thread"); + + InitialLoading = true; + InitialLoadError = null; + // Initial load is in the home directory. + _ = BackgroundLoad(ListDirectoryRelativity.Home, []).ContinueWith(ContinueInitialLoad); + } + + [RelayCommand] + private void RetryLoad() + { + InitialLoading = true; + InitialLoadError = null; + // Subsequent loads after the initial failure are always in the root + // directory in case there's a permanent issue preventing listing the + // home directory. + _ = BackgroundLoad(ListDirectoryRelativity.Root, []).ContinueWith(ContinueInitialLoad); + } + + private async Task BackgroundLoad(ListDirectoryRelativity relativity, List path) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + return await _client.ListDirectory(new ListDirectoryRequest + { + Path = path, + Relativity = relativity, + }, cts.Token); + } + + private void ContinueInitialLoad(Task task) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => ContinueInitialLoad(task)); + return; + } + + if (task.IsCompletedSuccessfully) + { + ProcessResponse(task.Result); + return; + } + + InitialLoadError = "Could not list home directory in workspace: "; + if (task.IsCanceled) InitialLoadError += new TaskCanceledException(); + else if (task.IsFaulted) InitialLoadError += task.Exception; + else InitialLoadError += "no successful result or error"; + InitialLoading = false; + } + + [RelayCommand] + public async Task ListPath(IReadOnlyList path) + { + if (_window is null || NavigatingLoading) return; + NavigatingLoading = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(RequestTimeoutMilliseconds)); + try + { + var res = await _client.ListDirectory(new ListDirectoryRequest + { + Path = path.ToList(), + Relativity = ListDirectoryRelativity.Root, + }, cts.Token); + ProcessResponse(res); + } + catch (Exception e) + { + // Subsequent listing errors are just shown as dialog boxes. + var dialog = new ContentDialog + { + Title = "Failed to list remote directory", + Content = $"{e}", + CloseButtonText = "Ok", + XamlRoot = _window.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + NavigatingLoading = false; + } + } + + [RelayCommand] + public void Cancel() + { + PathSelected?.Invoke(this, null); + _window?.Close(); + } + + [RelayCommand] + public void Select() + { + if (CurrentDirectory == "") return; + PathSelected?.Invoke(this, CurrentDirectory); + _window?.Close(); + } + + private void ProcessResponse(ListDirectoryResponse res) + { + InitialLoading = false; + InitialLoadError = null; + NavigatingLoading = false; + + var breadcrumbs = new List(res.AbsolutePath.Count + 1) + { + new DirectoryPickerBreadcrumb + { + Name = "(root)", + AbsolutePathSegments = [], + IsFirst = true, + ViewModel = this, + }, + }; + for (var i = 0; i < res.AbsolutePath.Count; i++) + { + breadcrumbs.Add(new DirectoryPickerBreadcrumb + { + Name = res.AbsolutePath[i], + AbsolutePathSegments = res.AbsolutePath[..(i + 1)], + IsFirst = false, + ViewModel = this, + }); + } + + var items = new List(res.Contents.Count + 1); + if (res.AbsolutePath.Count != 0) + { + items.Add(new DirectoryPickerItem + { + Kind = DirectoryPickerItemKind.ParentDirectory, + Name = "..", + AbsolutePathSegments = res.AbsolutePath[..^1], + ViewModel = this, + }); + } + + foreach (var item in res.Contents) + { + if (item.Name.StartsWith(".")) continue; + items.Add(new DirectoryPickerItem + { + Kind = item.IsDir ? DirectoryPickerItemKind.Directory : DirectoryPickerItemKind.File, + Name = item.Name, + AbsolutePathSegments = res.AbsolutePath.Append(item.Name).ToList(), + ViewModel = this, + }); + } + + CurrentDirectory = res.AbsolutePathString; + Breadcrumbs = breadcrumbs; + Items = items; + } +} diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 7fdd881..73e4b89 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -6,6 +6,8 @@ using Windows.Storage.Pickers; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; +using Coder.Desktop.App.Views; +using Coder.Desktop.CoderSdk.Agent; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.UI.Dispatching; @@ -19,10 +21,12 @@ public partial class FileSyncListViewModel : ObservableObject { private Window? _window; private DispatcherQueue? _dispatcherQueue; + private Window? _remotePickerWindow; private readonly ISyncSessionController _syncSessionController; private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; + private readonly IAgentApiClientFactory _agentApiClientFactory; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowUnavailable))] @@ -65,7 +69,10 @@ public partial class FileSyncListViewModel : ObservableObject [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] public partial string NewSessionRemotePath { get; set; } = ""; - // TODO: NewSessionRemotePathDialogOpen for remote path + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial bool NewSessionRemotePathDialogOpen { get; set; } = false; public bool NewSessionCreateEnabled { @@ -75,6 +82,7 @@ public bool NewSessionCreateEnabled if (NewSessionLocalPathDialogOpen) return false; if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) return false; if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false; + if (NewSessionRemotePathDialogOpen) return false; return true; } } @@ -86,11 +94,12 @@ public bool NewSessionCreateEnabled public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null; public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController, - ICredentialManager credentialManager) + ICredentialManager credentialManager, IAgentApiClientFactory agentApiClientFactory) { _syncSessionController = syncSessionController; _rpcController = rpcController; _credentialManager = credentialManager; + _agentApiClientFactory = agentApiClientFactory; } public void Initialize(Window window, DispatcherQueue dispatcherQueue) @@ -100,9 +109,28 @@ public void Initialize(Window window, DispatcherQueue dispatcherQueue) if (!_dispatcherQueue.HasThreadAccess) throw new InvalidOperationException("Initialize must be called from the UI thread"); + // Force the remote picker to activate when activating the file sync + // list window if open. + _window.Activated += (_, e) => + { + if (_remotePickerWindow is not null && e.WindowActivationState is WindowActivationState.PointerActivated) + { + e.Handled = true; + _remotePickerWindow.Activate(); + } + }; + _rpcController.StateChanged += RpcControllerStateChanged; _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged; _syncSessionController.StateChanged += SyncSessionStateChanged; + _window.Closed += (_, _) => + { + _remotePickerWindow?.Close(); + + _rpcController.StateChanged -= RpcControllerStateChanged; + _credentialManager.CredentialsChanged -= CredentialManagerCredentialsChanged; + _syncSessionController.StateChanged -= SyncSessionStateChanged; + }; var rpcModel = _rpcController.GetState(); var credentialModel = _credentialManager.GetCachedCredentials(); @@ -187,6 +215,8 @@ private void ClearNewForm() NewSessionLocalPath = ""; NewSessionRemoteHost = ""; NewSessionRemotePath = ""; + + _remotePickerWindow?.Close(); } [RelayCommand] @@ -230,14 +260,17 @@ private void StartCreatingNewSession() CreatingNewSession = true; } - public async Task OpenLocalPathSelectDialog(Window window) + [RelayCommand] + public async Task OpenLocalPathSelectDialog() { + if (_window is null) return; + var picker = new FolderPicker { SuggestedStartLocation = PickerLocationId.ComputerFolder, }; - var hwnd = WindowNative.GetWindowHandle(window); + var hwnd = WindowNative.GetWindowHandle(_window); InitializeWithWindow.Initialize(picker, hwnd); NewSessionLocalPathDialogOpen = true; @@ -257,6 +290,37 @@ public async Task OpenLocalPathSelectDialog(Window window) } } + [RelayCommand] + public void OpenRemotePathSelectDialog() + { + if (_remotePickerWindow is not null) + { + _remotePickerWindow.Activate(); + return; + } + + NewSessionRemotePathDialogOpen = true; + var pickerViewModel = new DirectoryPickerViewModel(_agentApiClientFactory, NewSessionRemoteHost); + pickerViewModel.PathSelected += OnRemotePathSelected; + + _remotePickerWindow = new DirectoryPickerWindow(pickerViewModel); + _remotePickerWindow.Closed += (_, _) => + { + _remotePickerWindow = null; + NewSessionRemotePathDialogOpen = false; + }; + _remotePickerWindow.Activate(); + } + + private void OnRemotePathSelected(object? sender, string? path) + { + if (sender is not DirectoryPickerViewModel pickerViewModel) return; + pickerViewModel.PathSelected -= OnRemotePathSelected; + + if (path == null) return; + NewSessionRemotePath = path; + } + [RelayCommand] private void CancelNewSession() { diff --git a/App/Views/DirectoryPickerWindow.xaml b/App/Views/DirectoryPickerWindow.xaml new file mode 100644 index 0000000..a34293a --- /dev/null +++ b/App/Views/DirectoryPickerWindow.xaml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs new file mode 100644 index 0000000..7ec0c86 --- /dev/null +++ b/App/Views/DirectoryPickerWindow.xaml.cs @@ -0,0 +1,21 @@ +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 DirectoryPickerWindow : WindowEx +{ + public DirectoryPickerWindow(DirectoryPickerViewModel viewModel) + { + InitializeComponent(); + SystemBackdrop = new DesktopAcrylicBackdrop(); + + viewModel.Initialize(this, DispatcherQueue); + RootFrame.Content = new DirectoryPickerMainPage(viewModel); + + // TODO: this should appear near the mouse instead, similar to the tray window + this.CenterOnScreen(); + } +} diff --git a/App/Views/Pages/DirectoryPickerMainPage.xaml b/App/Views/Pages/DirectoryPickerMainPage.xaml new file mode 100644 index 0000000..921a403 --- /dev/null +++ b/App/Views/Pages/DirectoryPickerMainPage.xaml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -335,10 +341,27 @@ Text="{x:Bind ViewModel.NewSessionRemoteHost, Mode=TwoWay}" /> - + + + + + + + + + + diff --git a/App/Views/Pages/FileSyncListMainPage.xaml.cs b/App/Views/Pages/FileSyncListMainPage.xaml.cs index c54c29e..4373efc 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml.cs +++ b/App/Views/Pages/FileSyncListMainPage.xaml.cs @@ -1,6 +1,4 @@ -using System.Threading.Tasks; using Coder.Desktop.App.ViewModels; -using CommunityToolkit.Mvvm.Input; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -31,10 +29,4 @@ private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedCha }; ToolTipService.SetToolTip(sender, toolTip); } - - [RelayCommand] - public async Task OpenLocalPathSelectDialog() - { - await ViewModel.OpenLocalPathSelectDialog(_window); - } } diff --git a/App/packages.lock.json b/App/packages.lock.json index 405ea61..c1181aa 100644 --- a/App/packages.lock.json +++ b/App/packages.lock.json @@ -8,6 +8,16 @@ "resolved": "8.4.0", "contentHash": "tqVU8yc/ADO9oiTRyTnwhFN68hCwvkliMierptWOudIAvWY1mWCh5VFh+guwHJmpMwfg0J0rY+yyd5Oy7ty9Uw==" }, + "CommunityToolkit.WinUI.Controls.Primitives": { + "type": "Direct", + "requested": "[8.2.250402, )", + "resolved": "8.2.250402", + "contentHash": "Wx3t1zADrzBWDar45uRl+lmSxDO5Vx7tTMFm/mNgl3fs5xSQ1ySPdGqD10EFov3rkKc5fbpHGW5xj8t62Yisvg==", + "dependencies": { + "CommunityToolkit.WinUI.Extensions": "8.2.250402", + "Microsoft.WindowsAppSDK": "1.6.250108002" + } + }, "DependencyPropertyGenerator": { "type": "Direct", "requested": "[1.5.0, )", @@ -94,6 +104,20 @@ "Microsoft.WindowsAppSDK": "1.6.240829007" } }, + "CommunityToolkit.Common": { + "type": "Transitive", + "resolved": "8.2.1", + "contentHash": "LWuhy8cQKJ/MYcy3XafJ916U3gPH/YDvYoNGWyQWN11aiEKCZszzPOTJAOvBjP9yG8vHmIcCyPUt4L82OK47Iw==" + }, + "CommunityToolkit.WinUI.Extensions": { + "type": "Transitive", + "resolved": "8.2.250402", + "contentHash": "rAOYzNX6kdUeeE1ejGd6Q8B+xmyZvOrWFUbqCgOtP8OQsOL66en9ZQTtzxAlaaFC4qleLvnKcn8FJFBezujOlw==", + "dependencies": { + "CommunityToolkit.Common": "8.2.1", + "Microsoft.WindowsAppSDK": "1.6.250108002" + } + }, "Google.Protobuf": { "type": "Transitive", "resolved": "3.29.3", diff --git a/CoderSdk/Agent/AgentApiClient.cs b/CoderSdk/Agent/AgentApiClient.cs new file mode 100644 index 0000000..27eaea3 --- /dev/null +++ b/CoderSdk/Agent/AgentApiClient.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Serialization; + +namespace Coder.Desktop.CoderSdk.Agent; + +public interface IAgentApiClientFactory +{ + public IAgentApiClient Create(string hostname); +} + +public class AgentApiClientFactory : IAgentApiClientFactory +{ + public IAgentApiClient Create(string hostname) + { + return new AgentApiClient(hostname); + } +} + +public partial interface IAgentApiClient +{ +} + +[JsonSerializable(typeof(ListDirectoryRequest))] +[JsonSerializable(typeof(ListDirectoryResponse))] +[JsonSerializable(typeof(Response))] +public partial class AgentApiJsonContext : JsonSerializerContext; + +public partial class AgentApiClient : IAgentApiClient +{ + private const int AgentApiPort = 4; + + private readonly JsonHttpClient _httpClient; + + public AgentApiClient(string hostname) : this(new UriBuilder + { + Scheme = "http", + Host = hostname, + Port = AgentApiPort, + Path = "/", + }.Uri) + { + } + + public AgentApiClient(Uri baseUrl) + { + if (baseUrl.PathAndQuery != "/") + throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); + _httpClient = new JsonHttpClient(baseUrl, AgentApiJsonContext.Default); + } + + private async Task SendRequestNoBodyAsync(HttpMethod method, string path, + CancellationToken ct = default) + { + return await SendRequestAsync(method, path, null, ct); + } + + private Task SendRequestAsync(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + return _httpClient.SendRequestAsync(method, path, payload, ct); + } +} diff --git a/CoderSdk/Agent/ListDirectory.cs b/CoderSdk/Agent/ListDirectory.cs new file mode 100644 index 0000000..9fd8753 --- /dev/null +++ b/CoderSdk/Agent/ListDirectory.cs @@ -0,0 +1,50 @@ +namespace Coder.Desktop.CoderSdk.Agent; + +public partial interface IAgentApiClient +{ + public Task ListDirectory(ListDirectoryRequest req, CancellationToken ct = default); +} + +public enum ListDirectoryRelativity +{ + // Root means `/` on Linux, and lists drive letters on Windows. + Root, + // Home means the user's home directory, usually `/home/xyz` or + // `C:\Users\xyz`. + Home, +} + +public class ListDirectoryRequest +{ + // Path segments like ["home", "coder", "repo"] or even just [] + public List Path { get; set; } = []; + // Where the path originates, either in the home directory or on the root + // of the system + public ListDirectoryRelativity Relativity { get; set; } = ListDirectoryRelativity.Root; +} + +public class ListDirectoryItem +{ + public required string Name { get; init; } + public required string AbsolutePathString { get; init; } + public required bool IsDir { get; init; } +} + +public class ListDirectoryResponse +{ + // The resolved absolute path (always from root) for future requests. + // E.g. if you did a request like `home: ["repo"]`, + // this would return ["home", "coder", "repo"] and "/home/coder/repo" + public required List AbsolutePath { get; init; } + // e.g. "C:\\Users\\coder\\repo" or "/home/coder/repo" + public required string AbsolutePathString { get; init; } + public required List Contents { get; init; } +} + +public partial class AgentApiClient +{ + public Task ListDirectory(ListDirectoryRequest req, CancellationToken ct = default) + { + return SendRequestAsync(HttpMethod.Post, "/api/v0/list-directory", req, ct); + } +} diff --git a/CoderSdk/Coder/CoderApiClient.cs b/CoderSdk/Coder/CoderApiClient.cs new file mode 100644 index 0000000..79c5c2f --- /dev/null +++ b/CoderSdk/Coder/CoderApiClient.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace Coder.Desktop.CoderSdk.Coder; + +public interface ICoderApiClientFactory +{ + public ICoderApiClient Create(string baseUrl); +} + +public class CoderApiClientFactory : ICoderApiClientFactory +{ + public ICoderApiClient Create(string baseUrl) + { + return new CoderApiClient(baseUrl); + } +} + +public partial interface ICoderApiClient +{ + public void SetSessionToken(string token); +} + +[JsonSerializable(typeof(BuildInfo))] +[JsonSerializable(typeof(Response))] +[JsonSerializable(typeof(User))] +[JsonSerializable(typeof(ValidationError))] +public partial class CoderApiJsonContext : JsonSerializerContext; + +/// +/// Provides a limited selection of API methods for a Coder instance. +/// +public partial class CoderApiClient : ICoderApiClient +{ + private const string SessionTokenHeader = "Coder-Session-Token"; + + private readonly JsonHttpClient _httpClient; + + public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute)) + { + } + + public CoderApiClient(Uri baseUrl) + { + if (baseUrl.PathAndQuery != "/") + throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); + _httpClient = new JsonHttpClient(baseUrl, CoderApiJsonContext.Default); + } + + public CoderApiClient(string baseUrl, string token) : this(baseUrl) + { + SetSessionToken(token); + } + + public void SetSessionToken(string token) + { + _httpClient.RemoveHeader(SessionTokenHeader); + _httpClient.SetHeader(SessionTokenHeader, token); + } + + private async Task SendRequestNoBodyAsync(HttpMethod method, string path, + CancellationToken ct = default) + { + return await SendRequestAsync(method, path, null, ct); + } + + private Task SendRequestAsync(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + return _httpClient.SendRequestAsync(method, path, payload, ct); + } +} diff --git a/CoderSdk/Deployment.cs b/CoderSdk/Coder/Deployment.cs similarity index 91% rename from CoderSdk/Deployment.cs rename to CoderSdk/Coder/Deployment.cs index e95e039..978d79d 100644 --- a/CoderSdk/Deployment.cs +++ b/CoderSdk/Coder/Deployment.cs @@ -1,4 +1,4 @@ -namespace Coder.Desktop.CoderSdk; +namespace Coder.Desktop.CoderSdk.Coder; public partial interface ICoderApiClient { diff --git a/CoderSdk/Users.cs b/CoderSdk/Coder/Users.cs similarity index 91% rename from CoderSdk/Users.cs rename to CoderSdk/Coder/Users.cs index fd81b32..6d1914b 100644 --- a/CoderSdk/Users.cs +++ b/CoderSdk/Coder/Users.cs @@ -1,4 +1,4 @@ -namespace Coder.Desktop.CoderSdk; +namespace Coder.Desktop.CoderSdk.Coder; public partial interface ICoderApiClient { diff --git a/CoderSdk/CoderApiClient.cs b/CoderSdk/CoderApiClient.cs deleted file mode 100644 index df2d923..0000000 --- a/CoderSdk/CoderApiClient.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Coder.Desktop.CoderSdk; - -public interface ICoderApiClientFactory -{ - public ICoderApiClient Create(string baseUrl); -} - -public class CoderApiClientFactory : ICoderApiClientFactory -{ - public ICoderApiClient Create(string baseUrl) - { - return new CoderApiClient(baseUrl); - } -} - -public partial interface ICoderApiClient -{ - public void SetSessionToken(string token); -} - -/// -/// Changes names from PascalCase to snake_case. -/// -internal class SnakeCaseNamingPolicy : JsonNamingPolicy -{ - public override string ConvertName(string name) - { - return string.Concat( - name.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + char.ToLower(x) : char.ToLower(x).ToString()) - ); - } -} - -[JsonSerializable(typeof(BuildInfo))] -[JsonSerializable(typeof(Response))] -[JsonSerializable(typeof(User))] -[JsonSerializable(typeof(ValidationError))] -public partial class CoderSdkJsonContext : JsonSerializerContext; - -/// -/// Provides a limited selection of API methods for a Coder instance. -/// -public partial class CoderApiClient : ICoderApiClient -{ - public static readonly JsonSerializerOptions JsonOptions = new() - { - TypeInfoResolver = CoderSdkJsonContext.Default, - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = new SnakeCaseNamingPolicy(), - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - // TODO: allow adding headers - private readonly HttpClient _httpClient = new(); - - public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute)) - { - } - - public CoderApiClient(Uri baseUrl) - { - if (baseUrl.PathAndQuery != "/") - throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); - _httpClient.BaseAddress = baseUrl; - } - - public CoderApiClient(string baseUrl, string token) : this(baseUrl) - { - SetSessionToken(token); - } - - public void SetSessionToken(string token) - { - _httpClient.DefaultRequestHeaders.Remove("Coder-Session-Token"); - _httpClient.DefaultRequestHeaders.Add("Coder-Session-Token", token); - } - - private async Task SendRequestNoBodyAsync(HttpMethod method, string path, - CancellationToken ct = default) - { - return await SendRequestAsync(method, path, null, ct); - } - - private async Task SendRequestAsync(HttpMethod method, string path, - TRequest? payload, CancellationToken ct = default) - { - try - { - var request = new HttpRequestMessage(method, path); - - if (payload is not null) - { - var json = JsonSerializer.Serialize(payload, typeof(TRequest), JsonOptions); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - } - - var res = await _httpClient.SendAsync(request, ct); - if (!res.IsSuccessStatusCode) - throw await CoderApiHttpException.FromResponse(res, ct); - - var content = await res.Content.ReadAsStringAsync(ct); - var data = JsonSerializer.Deserialize(content, JsonOptions); - if (data is null) throw new JsonException("Deserialized response is null"); - return data; - } - catch (CoderApiHttpException) - { - throw; - } - catch (Exception e) - { - throw new Exception($"Coder API Request failed: {method} {path}", e); - } - } -} diff --git a/CoderSdk/Errors.cs b/CoderSdk/Errors.cs index 4d79a59..2cab82d 100644 --- a/CoderSdk/Errors.cs +++ b/CoderSdk/Errors.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text.Json; +using System.Text.Json.Serialization; namespace Coder.Desktop.CoderSdk; @@ -16,8 +17,20 @@ public class Response public List Validations { get; set; } = []; } +[JsonSerializable(typeof(Response))] +[JsonSerializable(typeof(ValidationError))] +public partial class ErrorJsonContext : JsonSerializerContext; + public class CoderApiHttpException : Exception { + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + TypeInfoResolver = ErrorJsonContext.Default, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = new SnakeCaseNamingPolicy(), + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + private static readonly Dictionary Helpers = new() { { HttpStatusCode.Unauthorized, "Try signing in again" }, @@ -45,7 +58,7 @@ public static async Task FromResponse(HttpResponseMessage Response? responseObject; try { - responseObject = JsonSerializer.Deserialize(content, CoderApiClient.JsonOptions); + responseObject = JsonSerializer.Deserialize(content, JsonOptions); } catch (JsonException) { diff --git a/CoderSdk/JsonHttpClient.cs b/CoderSdk/JsonHttpClient.cs new file mode 100644 index 0000000..024e389 --- /dev/null +++ b/CoderSdk/JsonHttpClient.cs @@ -0,0 +1,82 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Coder.Desktop.CoderSdk; + +/// +/// Changes names from PascalCase to snake_case. +/// +internal class SnakeCaseNamingPolicy : JsonNamingPolicy +{ + public override string ConvertName(string name) + { + return string.Concat( + name.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + char.ToLower(x) : char.ToLower(x).ToString()) + ); + } +} + +internal class JsonHttpClient +{ + private readonly JsonSerializerOptions _jsonOptions; + + // TODO: allow users to add headers + private readonly HttpClient _httpClient = new(); + + public JsonHttpClient(Uri baseUri, IJsonTypeInfoResolver typeResolver) + { + _jsonOptions = new JsonSerializerOptions + { + TypeInfoResolver = typeResolver, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = new SnakeCaseNamingPolicy(), + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + _jsonOptions.Converters.Add(new JsonStringEnumConverter(new SnakeCaseNamingPolicy(), allowIntegerValues: false)); + _httpClient.BaseAddress = baseUri; + } + + public void RemoveHeader(string key) + { + _httpClient.DefaultRequestHeaders.Remove(key); + } + + public void SetHeader(string key, string value) + { + _httpClient.DefaultRequestHeaders.Add(key, value); + } + + public async Task SendRequestAsync(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + try + { + var request = new HttpRequestMessage(method, path); + + if (payload is not null) + { + var json = JsonSerializer.Serialize(payload, typeof(TRequest), _jsonOptions); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + + var res = await _httpClient.SendAsync(request, ct); + if (!res.IsSuccessStatusCode) + throw await CoderApiHttpException.FromResponse(res, ct); + + var content = await res.Content.ReadAsStringAsync(ct); + var data = JsonSerializer.Deserialize(content, _jsonOptions); + if (data is null) throw new JsonException("Deserialized response is null"); + return data; + } + catch (CoderApiHttpException) + { + throw; + } + catch (Exception e) + { + throw new Exception($"API Request failed: {method} {path}", e); + } + } +} diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 1eca8bf..fc014c0 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -1,5 +1,5 @@ using System.Runtime.InteropServices; -using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn.Proto; using Coder.Desktop.Vpn.Utilities; using Microsoft.Extensions.Logging; From 3d83551de4b383d50621b677a522f057e75dc06c Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 23 Apr 2025 13:45:07 +1000 Subject: [PATCH 2/4] ComboBox for agent selection, modal behavior for directory window --- App/App.csproj | 16 ----- App/Services/MutagenController.cs | 1 + App/ViewModels/DirectoryPickerViewModel.cs | 31 ++++---- App/ViewModels/FileSyncListViewModel.cs | 62 ++++++++++++---- App/ViewModels/TrayWindowViewModel.cs | 1 + App/Views/DirectoryPickerWindow.xaml | 3 +- App/Views/DirectoryPickerWindow.xaml.cs | 74 +++++++++++++++++++- App/Views/FileSyncListWindow.xaml.cs | 2 +- App/Views/Pages/DirectoryPickerMainPage.xaml | 22 +++--- App/Views/Pages/FileSyncListMainPage.xaml | 24 +++---- App/Views/Pages/FileSyncListMainPage.xaml.cs | 6 +- CoderSdk/Agent/ListDirectory.cs | 6 +- CoderSdk/Errors.cs | 2 +- CoderSdk/JsonHttpClient.cs | 2 +- Installer/Program.cs | 7 +- Tests.App/Services/CredentialManagerTest.cs | 1 - 16 files changed, 173 insertions(+), 87 deletions(-) diff --git a/App/App.csproj b/App/App.csproj index 1e0bbc3..1ed95c9 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -29,10 +29,6 @@ false - - - - @@ -79,16 +75,4 @@ - - - - MSBuild:Compile - - - - - - MSBuild:Compile - - diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index 212101f..a87934c 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -569,6 +569,7 @@ private void StartDaemonProcess() { // ignored } + _daemonProcess?.Dispose(); _logWriter?.Dispose(); _daemonProcess = null; diff --git a/App/ViewModels/DirectoryPickerViewModel.cs b/App/ViewModels/DirectoryPickerViewModel.cs index f96bf7b..dc37d9a 100644 --- a/App/ViewModels/DirectoryPickerViewModel.cs +++ b/App/ViewModels/DirectoryPickerViewModel.cs @@ -14,14 +14,16 @@ namespace Coder.Desktop.App.ViewModels; public class DirectoryPickerBreadcrumb { + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; + public required string Name { get; init; } + public required IReadOnlyList AbsolutePathSegments { get; init; } + // HACK: we need to know which one is first so we don't prepend an arrow // icon. You can't get the index of the current ItemsRepeater item in XAML. public required bool IsFirst { get; init; } - - // HACK: you cannot access the parent context when inside an ItemsRepeater. - public required DirectoryPickerViewModel ViewModel; } public enum DirectoryPickerItemKind @@ -33,13 +35,13 @@ public enum DirectoryPickerItemKind public class DirectoryPickerItem { + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; + public required DirectoryPickerItemKind Kind { get; init; } public required string Name { get; init; } public required IReadOnlyList AbsolutePathSegments { get; init; } - // HACK: you cannot access the parent context when inside an ItemsRepeater. - public required DirectoryPickerViewModel ViewModel; - public bool Selectable => Kind is DirectoryPickerItemKind.ParentDirectory or DirectoryPickerItemKind.Directory; } @@ -74,18 +76,15 @@ public partial class DirectoryPickerViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ShowListScreen))] public partial string? InitialLoadError { get; set; } = null; - [ObservableProperty] - public partial bool NavigatingLoading { get; set; } = false; + [ObservableProperty] public partial bool NavigatingLoading { get; set; } = false; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsSelectable))] public partial string CurrentDirectory { get; set; } = ""; - [ObservableProperty] - public partial IReadOnlyList Breadcrumbs { get; set; } = []; + [ObservableProperty] public partial IReadOnlyList Breadcrumbs { get; set; } = []; - [ObservableProperty] - public partial IReadOnlyList Items { get; set; } = []; + [ObservableProperty] public partial IReadOnlyList Items { get; set; } = []; public bool ShowLoadingScreen => InitialLoadError == null && InitialLoading; public bool ShowErrorScreen => InitialLoadError != null; @@ -100,7 +99,7 @@ public partial class DirectoryPickerViewModel : ObservableObject public DirectoryPickerViewModel(IAgentApiClientFactory clientFactory, string agentFqdn) { - _client = clientFactory.Create(hostname: agentFqdn); + _client = clientFactory.Create(agentFqdn); AgentFqdn = agentFqdn; } @@ -218,7 +217,7 @@ private void ProcessResponse(ListDirectoryResponse res) var breadcrumbs = new List(res.AbsolutePath.Count + 1) { - new DirectoryPickerBreadcrumb + new() { Name = "(root)", AbsolutePathSegments = [], @@ -227,7 +226,6 @@ private void ProcessResponse(ListDirectoryResponse res) }, }; for (var i = 0; i < res.AbsolutePath.Count; i++) - { breadcrumbs.Add(new DirectoryPickerBreadcrumb { Name = res.AbsolutePath[i], @@ -235,11 +233,9 @@ private void ProcessResponse(ListDirectoryResponse res) IsFirst = false, ViewModel = this, }); - } var items = new List(res.Contents.Count + 1); if (res.AbsolutePath.Count != 0) - { items.Add(new DirectoryPickerItem { Kind = DirectoryPickerItemKind.ParentDirectory, @@ -247,7 +243,6 @@ private void ProcessResponse(ListDirectoryResponse res) AbsolutePathSegments = res.AbsolutePath[..^1], ViewModel = this, }); - } foreach (var item in res.Contents) { diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 73e4b89..76b4028 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -21,7 +21,7 @@ public partial class FileSyncListViewModel : ObservableObject { private Window? _window; private DispatcherQueue? _dispatcherQueue; - private Window? _remotePickerWindow; + private DirectoryPickerWindow? _remotePickerWindow; private readonly ISyncSessionController _syncSessionController; private readonly IRpcController _rpcController; @@ -50,7 +50,7 @@ public partial class FileSyncListViewModel : ObservableObject [ObservableProperty] public partial bool OperationInProgress { get; set; } = false; - [ObservableProperty] public partial List Sessions { get; set; } = []; + [ObservableProperty] public partial IReadOnlyList Sessions { get; set; } = []; [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false; @@ -62,9 +62,14 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] public partial bool NewSessionLocalPathDialogOpen { get; set; } = false; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))] + public partial IReadOnlyList AvailableHosts { get; set; } = []; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] - public partial string NewSessionRemoteHost { get; set; } = ""; + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial string? NewSessionRemoteHost { get; set; } = null; [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] @@ -72,8 +77,14 @@ public partial class FileSyncListViewModel : ObservableObject [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] public partial bool NewSessionRemotePathDialogOpen { get; set; } = false; + public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0; + + public bool NewSessionRemotePathDialogEnabled => + !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen; + public bool NewSessionCreateEnabled { get @@ -109,17 +120,6 @@ public void Initialize(Window window, DispatcherQueue dispatcherQueue) if (!_dispatcherQueue.HasThreadAccess) throw new InvalidOperationException("Initialize must be called from the UI thread"); - // Force the remote picker to activate when activating the file sync - // list window if open. - _window.Activated += (_, e) => - { - if (_remotePickerWindow is not null && e.WindowActivationState is WindowActivationState.PointerActivated) - { - e.Handled = true; - _remotePickerWindow.Activate(); - } - }; - _rpcController.StateChanged += RpcControllerStateChanged; _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged; _syncSessionController.StateChanged += SyncSessionStateChanged; @@ -199,8 +199,13 @@ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel crede else { UnavailableMessage = null; + // Reload if we transitioned from unavailable to available. if (oldMessage != null) ReloadSessions(); } + + // When transitioning from available to unavailable: + if (oldMessage == null && UnavailableMessage != null) + ClearNewForm(); } private void UpdateSyncSessionState(SyncSessionControllerStateModel syncSessionState) @@ -253,10 +258,34 @@ private void HandleRefresh(Task t) Loading = false; } + // Overriding AvailableHosts seems to make the ComboBox clear its value, so + // we only do this while the create form is not open. + // Must be called in UI thread. + private void SetAvailableHostsFromRpcModel(RpcModel rpcModel) + { + var hosts = new List(rpcModel.Agents.Count); + // Agents will only contain started agents. + foreach (var agent in rpcModel.Agents) + { + var fqdn = agent.Fqdn + .Select(a => a.Trim('.')) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b); + if (string.IsNullOrWhiteSpace(fqdn)) + continue; + hosts.Add(fqdn); + } + + NewSessionRemoteHost = null; + AvailableHosts = hosts; + } + [RelayCommand] private void StartCreatingNewSession() { ClearNewForm(); + // Ensure we have a fresh hosts list before we open the form. + SetAvailableHostsFromRpcModel(_rpcController.GetState()); CreatingNewSession = true; } @@ -293,6 +322,8 @@ public async Task OpenLocalPathSelectDialog() [RelayCommand] public void OpenRemotePathSelectDialog() { + if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) + return; if (_remotePickerWindow is not null) { _remotePickerWindow.Activate(); @@ -304,6 +335,7 @@ public void OpenRemotePathSelectDialog() pickerViewModel.PathSelected += OnRemotePathSelected; _remotePickerWindow = new DirectoryPickerWindow(pickerViewModel); + _remotePickerWindow.SetParent(_window); _remotePickerWindow.Closed += (_, _) => { _remotePickerWindow = null; @@ -347,7 +379,7 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest Beta = new CreateSyncSessionRequest.Endpoint { Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh, - Host = NewSessionRemoteHost, + Host = NewSessionRemoteHost!, Path = NewSessionRemotePath, }, }, cts.Token); diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 532bfe4..f845521 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -178,6 +178,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) { // We just assume that it's a single-agent workspace. Hostname = workspace.Name, + // TODO: this needs to get the suffix from the server HostnameSuffix = ".coder", ConnectionStatus = AgentConnectionStatus.Gray, DashboardUrl = WorkspaceUri(coderUri, workspace.Name), diff --git a/App/Views/DirectoryPickerWindow.xaml b/App/Views/DirectoryPickerWindow.xaml index a34293a..8a107cb 100644 --- a/App/Views/DirectoryPickerWindow.xaml +++ b/App/Views/DirectoryPickerWindow.xaml @@ -1,4 +1,5 @@ + workArea.X + workArea.Width) // right edge + windowPos.X = workArea.X + workArea.Width - AppWindow.Size.Width; + if (windowPos.Y + AppWindow.Size.Height > workArea.Y + workArea.Height) // bottom edge + windowPos.Y = workArea.Y + workArea.Height - AppWindow.Size.Height; + if (windowPos.X < workArea.X) // left edge + windowPos.X = workArea.X; + if (windowPos.Y < workArea.Y) // top edge + windowPos.Y = workArea.Y; + + AppWindow.Move(windowPos); + + var parentHandle = WindowNative.GetWindowHandle(parentWindow); + var thisHandle = WindowNative.GetWindowHandle(this); + + // Set the parent window in win API. + NativeApi.SetWindowParent(thisHandle, parentHandle); + + // Override the presenter, which allows us to enable modal-like + // behavior for this window: + // - Disables the parent window + // - Any activations of the parent window will play a bell sound and + // focus the modal window + // + // This behavior is very similar to the native file/directory picker on + // Windows. + var presenter = OverlappedPresenter.CreateForDialog(); + presenter.IsModal = true; + AppWindow.SetPresenter(presenter); + AppWindow.Show(); + + // Cascade close events. + parentWindow.Closed += OnParentWindowClosed; + Closed += (_, _) => + { + parentWindow.Closed -= OnParentWindowClosed; + parentWindow.Activate(); + }; + } + + private void OnParentWindowClosed(object? sender, WindowEventArgs e) + { + Close(); + } + + private static class NativeApi + { + [DllImport("user32.dll")] + private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + + public static void SetWindowParent(IntPtr window, IntPtr parent) + { + SetWindowLongPtr(window, -8, parent); + } + } } diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index 8a409d7..428363b 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -16,7 +16,7 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) SystemBackdrop = new DesktopAcrylicBackdrop(); ViewModel.Initialize(this, DispatcherQueue); - RootFrame.Content = new FileSyncListMainPage(ViewModel, this); + RootFrame.Content = new FileSyncListMainPage(ViewModel); this.CenterOnScreen(); } diff --git a/App/Views/Pages/DirectoryPickerMainPage.xaml b/App/Views/Pages/DirectoryPickerMainPage.xaml index 921a403..dd08c46 100644 --- a/App/Views/Pages/DirectoryPickerMainPage.xaml +++ b/App/Views/Pages/DirectoryPickerMainPage.xaml @@ -1,4 +1,5 @@ + - - + + @@ -126,14 +127,19 @@ - - - - - + + + + + + + + - + diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml index 31fc188..a477d3d 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -43,8 +43,8 @@ Padding="20"> - - + + @@ -86,7 +86,7 @@ @@ -138,7 +138,7 @@ @@ -272,7 +272,7 @@ @@ -328,17 +328,13 @@ - - - @@ -355,7 +351,7 @@