-
Notifications
You must be signed in to change notification settings - Fork 5
feat: add remote directory picker to file sync #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8a7acc1
3d83551
9257b92
9ae1dad
11c1f28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,263 @@ | ||
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 | ||
{ | ||
// HACK: you cannot access the parent context when inside an ItemsRepeater. | ||
public required DirectoryPickerViewModel ViewModel; | ||
|
||
public required string Name { get; init; } | ||
|
||
public required IReadOnlyList<string> 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; } | ||
} | ||
|
||
public enum DirectoryPickerItemKind | ||
{ | ||
ParentDirectory, // aka. ".." | ||
Directory, | ||
File, // includes everything else | ||
} | ||
|
||
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<string> AbsolutePathSegments { get; init; } | ||
|
||
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<string?>? 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<DirectoryPickerBreadcrumb> Breadcrumbs { get; set; } = []; | ||
|
||
[ObservableProperty] public partial IReadOnlyList<DirectoryPickerItem> 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(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<ListDirectoryResponse> BackgroundLoad(ListDirectoryRelativity relativity, List<string> 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<ListDirectoryResponse> 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<string> 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<DirectoryPickerBreadcrumb>(res.AbsolutePath.Count + 1) | ||
{ | ||
new() | ||
{ | ||
Name = "🖥️", | ||
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<DirectoryPickerItem>(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; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 @@ | |
{ | ||
private Window? _window; | ||
private DispatcherQueue? _dispatcherQueue; | ||
private DirectoryPickerWindow? _remotePickerWindow; | ||
|
||
private readonly ISyncSessionController _syncSessionController; | ||
private readonly IRpcController _rpcController; | ||
private readonly ICredentialManager _credentialManager; | ||
private readonly IAgentApiClientFactory _agentApiClientFactory; | ||
|
||
[ObservableProperty] | ||
[NotifyPropertyChangedFor(nameof(ShowUnavailable))] | ||
|
@@ -46,7 +50,7 @@ | |
|
||
[ObservableProperty] public partial bool OperationInProgress { get; set; } = false; | ||
|
||
[ObservableProperty] public partial List<SyncSessionViewModel> Sessions { get; set; } = []; | ||
[ObservableProperty] public partial IReadOnlyList<SyncSessionViewModel> Sessions { get; set; } = []; | ||
|
||
[ObservableProperty] public partial bool CreatingNewSession { get; set; } = false; | ||
|
||
|
@@ -58,17 +62,30 @@ | |
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] | ||
public partial bool NewSessionLocalPathDialogOpen { get; set; } = false; | ||
|
||
[ObservableProperty] | ||
[NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))] | ||
public partial IReadOnlyList<string> 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))] | ||
public partial string NewSessionRemotePath { get; set; } = ""; | ||
// TODO: NewSessionRemotePathDialogOpen for remote path | ||
|
||
[ObservableProperty] | ||
public partial string NewSessionStatus { get; set; } = ""; | ||
[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; | ||
|
||
[ObservableProperty] public partial string NewSessionStatus { get; set; } = ""; | ||
|
||
public bool NewSessionCreateEnabled | ||
{ | ||
|
@@ -78,6 +95,7 @@ | |
if (NewSessionLocalPathDialogOpen) return false; | ||
if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) return false; | ||
if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false; | ||
if (NewSessionRemotePathDialogOpen) return false; | ||
return true; | ||
} | ||
} | ||
|
@@ -89,11 +107,12 @@ | |
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) | ||
|
@@ -106,6 +125,14 @@ | |
_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(); | ||
|
@@ -174,8 +201,13 @@ | |
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) | ||
|
@@ -191,6 +223,7 @@ | |
NewSessionRemoteHost = ""; | ||
NewSessionRemotePath = ""; | ||
NewSessionStatus = ""; | ||
_remotePickerWindow?.Close(); | ||
} | ||
|
||
[RelayCommand] | ||
|
@@ -227,21 +260,50 @@ | |
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<string>(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. We don't | ||
// bind directly to the list on RPC state updates as updating the list | ||
// while in use seems to break it. | ||
SetAvailableHostsFromRpcModel(_rpcController.GetState()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It might be a little annoying to find that the available hosts only updates when you click the button to create a new session. I could imagine starting up a workspace, then creating a new session to find your workspace isn't yet listed. To get it to populate you need to cancel out, then wait for it to start, maybe by watching the tray menu, then create a new sync session. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If any entries get added or removed while the selector is broken it messes up the selector There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. lame. I guess we can see if it's annoying in practice. |
||
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; | ||
|
@@ -261,6 +323,40 @@ | |
} | ||
} | ||
|
||
[RelayCommand] | ||
public void OpenRemotePathSelectDialog() | ||
{ | ||
if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) | ||
return; | ||
if (_remotePickerWindow is not null) | ||
{ | ||
_remotePickerWindow.Activate(); | ||
return; | ||
} | ||
|
||
NewSessionRemotePathDialogOpen = true; | ||
var pickerViewModel = new DirectoryPickerViewModel(_agentApiClientFactory, NewSessionRemoteHost); | ||
pickerViewModel.PathSelected += OnRemotePathSelected; | ||
|
||
_remotePickerWindow = new DirectoryPickerWindow(pickerViewModel); | ||
_remotePickerWindow.SetParent(_window); | ||
Check warning on line 342 in App/ViewModels/FileSyncListViewModel.cs
|
||
_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() | ||
{ | ||
|
@@ -300,7 +396,7 @@ | |
Beta = new CreateSyncSessionRequest.Endpoint | ||
{ | ||
Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh, | ||
Host = NewSessionRemoteHost, | ||
Host = NewSessionRemoteHost!, | ||
Path = NewSessionRemotePath, | ||
}, | ||
}, OnCreateSessionProgress, cts.Token); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
<?xml version="1.0" encoding="utf-8"?> | ||
|
||
<winuiex:WindowEx | ||
x:Class="Coder.Desktop.App.Views.DirectoryPickerWindow" | ||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" | ||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" | ||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" | ||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" | ||
xmlns:winuiex="using:WinUIEx" | ||
mc:Ignorable="d" | ||
Title="Directory Picker" | ||
Width="400" Height="600" | ||
MinWidth="400" MinHeight="600"> | ||
|
||
<Window.SystemBackdrop> | ||
<DesktopAcrylicBackdrop /> | ||
</Window.SystemBackdrop> | ||
|
||
<Frame x:Name="RootFrame" /> | ||
</winuiex:WindowEx> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
using System; | ||
using System.Runtime.InteropServices; | ||
using Windows.Graphics; | ||
using Coder.Desktop.App.ViewModels; | ||
using Coder.Desktop.App.Views.Pages; | ||
using Microsoft.UI.Windowing; | ||
using Microsoft.UI.Xaml; | ||
using Microsoft.UI.Xaml.Media; | ||
using WinRT.Interop; | ||
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); | ||
|
||
// This will be moved to the center of the parent window in SetParent. | ||
this.CenterOnScreen(); | ||
} | ||
|
||
public void SetParent(Window parentWindow) | ||
{ | ||
// Move the window to the center of the parent window. | ||
var scale = DisplayScale.WindowScale(parentWindow); | ||
var windowPos = new PointInt32( | ||
parentWindow.AppWindow.Position.X + parentWindow.AppWindow.Size.Width / 2 - AppWindow.Size.Width / 2, | ||
parentWindow.AppWindow.Position.Y + parentWindow.AppWindow.Size.Height / 2 - AppWindow.Size.Height / 2 | ||
); | ||
|
||
// Ensure we stay within the display. | ||
var workArea = DisplayArea.GetFromPoint(parentWindow.AppWindow.Position, DisplayAreaFallback.Primary).WorkArea; | ||
if (windowPos.X + AppWindow.Size.Width > 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); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,179 @@ | ||
<?xml version="1.0" encoding="utf-8"?> | ||
|
||
<Page | ||
x:Class="Coder.Desktop.App.Views.Pages.DirectoryPickerMainPage" | ||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" | ||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" | ||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" | ||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" | ||
xmlns:converters="using:Coder.Desktop.App.Converters" | ||
xmlns:toolkit="using:CommunityToolkit.WinUI.Controls" | ||
xmlns:viewmodels="using:Coder.Desktop.App.ViewModels" | ||
mc:Ignorable="d" | ||
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> | ||
|
||
<Grid> | ||
<Grid | ||
Visibility="{x:Bind ViewModel.ShowLoadingScreen, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" | ||
Padding="60,60" | ||
HorizontalAlignment="Center" | ||
VerticalAlignment="Center"> | ||
|
||
<ProgressRing | ||
Width="32" | ||
Height="32" | ||
Margin="0,30" | ||
HorizontalAlignment="Center" /> | ||
|
||
<TextBlock HorizontalAlignment="Center" Text="Loading home directory..." /> | ||
</Grid> | ||
|
||
<Grid | ||
Visibility="{x:Bind ViewModel.ShowErrorScreen, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" | ||
Padding="20"> | ||
|
||
<Grid.RowDefinitions> | ||
<RowDefinition Height="*" /> | ||
<RowDefinition Height="Auto" /> | ||
</Grid.RowDefinitions> | ||
|
||
<ScrollView Grid.Row="0"> | ||
<TextBlock | ||
Margin="0,0,0,20" | ||
Foreground="Red" | ||
TextWrapping="Wrap" | ||
Text="{x:Bind ViewModel.InitialLoadError, Mode=OneWay}" /> | ||
</ScrollView> | ||
|
||
<Button Grid.Row="1" Command="{x:Bind ViewModel.RetryLoadCommand, Mode=OneWay}"> | ||
<TextBlock Text="Reload" /> | ||
</Button> | ||
</Grid> | ||
|
||
<Grid | ||
Visibility="{x:Bind ViewModel.ShowListScreen, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" | ||
Padding="20"> | ||
|
||
<Grid.RowDefinitions> | ||
<RowDefinition Height="Auto" /> | ||
<RowDefinition Height="Auto" /> | ||
<RowDefinition Height="*" /> | ||
<RowDefinition Height="Auto" /> | ||
</Grid.RowDefinitions> | ||
|
||
<Grid Grid.Row="0"> | ||
<Grid.ColumnDefinitions> | ||
<ColumnDefinition Width="*" /> | ||
<ColumnDefinition Width="Auto" /> | ||
</Grid.ColumnDefinitions> | ||
|
||
<TextBlock | ||
Grid.Column="0" | ||
Text="{x:Bind ViewModel.AgentFqdn}" | ||
Style="{StaticResource SubtitleTextBlockStyle}" | ||
TextTrimming="CharacterEllipsis" | ||
IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" | ||
Margin="0,0,0,10" /> | ||
<ProgressRing | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a nice touch |
||
Grid.Column="1" | ||
IsActive="{x:Bind ViewModel.NavigatingLoading, Mode=OneWay}" | ||
Width="24" | ||
Height="24" | ||
Margin="10,0" | ||
HorizontalAlignment="Right" /> | ||
</Grid> | ||
|
||
<ItemsRepeater | ||
Grid.Row="1" | ||
Margin="-4,0,0,15" | ||
ItemsSource="{x:Bind ViewModel.Breadcrumbs, Mode=OneWay}"> | ||
|
||
<ItemsRepeater.Layout> | ||
<toolkit:WrapLayout Orientation="Horizontal" /> | ||
</ItemsRepeater.Layout> | ||
|
||
<ItemsRepeater.ItemTemplate> | ||
<DataTemplate x:DataType="viewmodels:DirectoryPickerBreadcrumb"> | ||
<StackPanel Orientation="Horizontal"> | ||
<!-- Add a chevron before each item except the "root" item --> | ||
<FontIcon | ||
Glyph="" | ||
FontSize="14" | ||
Visibility="{x:Bind IsFirst, Converter={StaticResource InverseBoolToVisibilityConverter}}" /> | ||
<HyperlinkButton | ||
Content="{x:Bind Name}" | ||
Command="{x:Bind ViewModel.ListPathCommand}" | ||
CommandParameter="{x:Bind AbsolutePathSegments}" | ||
Padding="2,-1,2,0" /> | ||
</StackPanel> | ||
</DataTemplate> | ||
</ItemsRepeater.ItemTemplate> | ||
</ItemsRepeater> | ||
|
||
<ScrollView Grid.Row="2" Margin="-12,0,-12,15"> | ||
<ItemsRepeater ItemsSource="{x:Bind ViewModel.Items, Mode=OneWay}"> | ||
<ItemsRepeater.Layout> | ||
<StackLayout Orientation="Vertical" /> | ||
</ItemsRepeater.Layout> | ||
|
||
<ItemsRepeater.ItemTemplate> | ||
<DataTemplate x:DataType="viewmodels:DirectoryPickerItem"> | ||
<HyperlinkButton | ||
IsEnabled="{x:Bind Selectable}" | ||
Command="{x:Bind ViewModel.ListPathCommand}" | ||
CommandParameter="{x:Bind AbsolutePathSegments}" | ||
HorizontalAlignment="Stretch" | ||
HorizontalContentAlignment="Left"> | ||
|
||
<Grid> | ||
<Grid.Resources> | ||
<converters:StringToStringSelector x:Key="Icon" | ||
SelectedKey="{x:Bind Path=Kind}"> | ||
<converters:StringToStringSelectorItem Value="" /> | ||
<!-- Document --> | ||
<converters:StringToStringSelectorItem Key="ParentDirectory" | ||
Value="" /> <!-- Back --> | ||
<converters:StringToStringSelectorItem Key="Directory" Value="" /> | ||
<!-- Folder --> | ||
<converters:StringToStringSelectorItem Key="File" Value="" /> | ||
<!-- Document --> | ||
</converters:StringToStringSelector> | ||
</Grid.Resources> | ||
|
||
<Grid.ColumnDefinitions> | ||
<ColumnDefinition Width="Auto" /> | ||
<ColumnDefinition Width="*" /> | ||
</Grid.ColumnDefinitions> | ||
|
||
<!-- The accent-colored icon actually looks nice here, so we don't override it --> | ||
<FontIcon | ||
Grid.Column="0" | ||
Glyph="{Binding Source={StaticResource Icon}, Path=SelectedObject}" | ||
Margin="0,0,10,0" FontSize="16" /> | ||
<TextBlock | ||
Grid.Column="1" | ||
Text="{x:Bind Name}" | ||
Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" | ||
TextTrimming="CharacterEllipsis" | ||
IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> | ||
</Grid> | ||
</HyperlinkButton> | ||
</DataTemplate> | ||
</ItemsRepeater.ItemTemplate> | ||
</ItemsRepeater> | ||
</ScrollView> | ||
|
||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right"> | ||
<Button | ||
Content="Cancel" | ||
Command="{x:Bind ViewModel.CancelCommand}" | ||
Margin="0,0,10,0" /> | ||
<Button | ||
IsEnabled="{x:Bind ViewModel.IsSelectable, Mode=OneWay}" | ||
Content="Use This Directory" | ||
Command="{x:Bind ViewModel.SelectCommand}" | ||
Style="{StaticResource AccentButtonStyle}" /> | ||
</StackPanel> | ||
</Grid> | ||
</Grid> | ||
</Page> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
using Coder.Desktop.App.ViewModels; | ||
using Microsoft.UI.Xaml.Controls; | ||
|
||
namespace Coder.Desktop.App.Views.Pages; | ||
|
||
public sealed partial class DirectoryPickerMainPage : Page | ||
{ | ||
public readonly DirectoryPickerViewModel ViewModel; | ||
|
||
public DirectoryPickerMainPage(DirectoryPickerViewModel viewModel) | ||
{ | ||
ViewModel = viewModel; | ||
InitializeComponent(); | ||
} | ||
|
||
private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedChangedEventArgs e) | ||
{ | ||
ToolTipService.SetToolTip(sender, null); | ||
if (!sender.IsTextTrimmed) return; | ||
|
||
var toolTip = new ToolTip | ||
{ | ||
Content = sender.Text, | ||
}; | ||
ToolTipService.SetToolTip(sender, toolTip); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<TResponse> SendRequestNoBodyAsync<TResponse>(HttpMethod method, string path, | ||
CancellationToken ct = default) | ||
{ | ||
return await SendRequestAsync<object, TResponse>(method, path, null, ct); | ||
} | ||
|
||
private Task<TResponse> SendRequestAsync<TRequest, TResponse>(HttpMethod method, string path, | ||
TRequest? payload, CancellationToken ct = default) | ||
{ | ||
return _httpClient.SendRequestAsync<TRequest, TResponse>(method, path, payload, ct); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
namespace Coder.Desktop.CoderSdk.Agent; | ||
|
||
public partial interface IAgentApiClient | ||
{ | ||
public Task<ListDirectoryResponse> 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<string> 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<string> AbsolutePath { get; init; } | ||
|
||
// e.g. "C:\\Users\\coder\\repo" or "/home/coder/repo" | ||
public required string AbsolutePathString { get; init; } | ||
public required List<ListDirectoryItem> Contents { get; init; } | ||
} | ||
|
||
public partial class AgentApiClient | ||
{ | ||
public Task<ListDirectoryResponse> ListDirectory(ListDirectoryRequest req, CancellationToken ct = default) | ||
{ | ||
return SendRequestAsync<ListDirectoryRequest, ListDirectoryResponse>(HttpMethod.Post, "/api/v0/list-directory", | ||
req, ct); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
|
||
/// <summary> | ||
/// Provides a limited selection of API methods for a Coder instance. | ||
/// </summary> | ||
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<TResponse> SendRequestNoBodyAsync<TResponse>(HttpMethod method, string path, | ||
CancellationToken ct = default) | ||
{ | ||
return await SendRequestAsync<object, TResponse>(method, path, null, ct); | ||
} | ||
|
||
private Task<TResponse> SendRequestAsync<TRequest, TResponse>(HttpMethod method, string path, | ||
TRequest? payload, CancellationToken ct = default) | ||
{ | ||
return _httpClient.SendRequestAsync<TRequest, TResponse>(method, path, payload, ct); | ||
} | ||
} |
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
|
||
/// <summary> | ||
/// Changes names from PascalCase to snake_case. | ||
/// </summary> | ||
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(), 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<TResponse> SendRequestAsync<TRequest, TResponse>(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<TResponse>(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); | ||
} | ||
} | ||
} |
Unchanged files with check annotations Beta
var converter = new FriendlyByteConverter(); | ||
foreach (var (input, expected) in cases) | ||
{ | ||
var actual = converter.Convert(input, typeof(string), null, null); | ||
Check warning on line 32 in Tests.App/Converters/FriendlyByteConverterTest.cs
|
||
Assert.That(actual, Is.EqualTo(expected), $"case ({input.GetType()}){input}"); | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.