Skip to content

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

Merged
merged 5 commits into from
May 1, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions App/App.csproj
Original file line number Diff line number Diff line change
@@ -56,6 +56,7 @@

<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.250402" />
<PackageReference Include="DependencyPropertyGenerator" Version="1.5.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
16 changes: 11 additions & 5 deletions App/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Windows.ApplicationModel.Activation;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
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;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.UI.Xaml;
using Microsoft.Win32;
using Microsoft.Windows.AppLifecycle;
using Windows.ApplicationModel.Activation;
using Microsoft.Extensions.Logging;
using Serilog;
using System.Collections.Generic;
using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs;

namespace Coder.Desktop.App;

@@ -60,6 +62,8 @@ public App()
loggerConfig.ReadFrom.Configuration(builder.Configuration);
});

services.AddSingleton<IAgentApiClientFactory, AgentApiClientFactory>();

services.AddSingleton<ICredentialManager, CredentialManager>();
services.AddSingleton<IRpcController, RpcController>();

@@ -76,6 +80,8 @@ public App()
// FileSyncListMainPage is created by FileSyncListWindow.
services.AddTransient<FileSyncListWindow>();

// DirectoryPickerWindow views and view models are created by FileSyncListViewModel.

// TrayWindow views and view models
services.AddTransient<TrayWindowLoadingPage>();
services.AddTransient<TrayWindowDisconnectedViewModel>();
@@ -89,7 +95,7 @@ public App()
services.AddTransient<TrayWindow>();

_services = services.BuildServiceProvider();
_logger = (ILogger<App>)(_services.GetService(typeof(ILogger<App>))!);
_logger = (ILogger<App>)_services.GetService(typeof(ILogger<App>))!;

InitializeComponent();
}
@@ -107,7 +113,7 @@ public async Task ExitApplication()
Environment.Exit(0);
}

protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
_logger.LogInformation("new instance launched");
// Start connecting to the manager in the background.
4 changes: 4 additions & 0 deletions App/Converters/DependencyObjectSelector.cs
Original file line number Diff line number Diff line change
@@ -186,3 +186,7 @@ private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyP
public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem<string, Brush>;

public sealed class StringToBrushSelector : DependencyObjectSelector<string, Brush>;

public sealed class StringToStringSelectorItem : DependencyObjectSelectorItem<string, string>;

public sealed class StringToStringSelector : DependencyObjectSelector<string, string>;
2 changes: 1 addition & 1 deletion App/Program.cs
Original file line number Diff line number Diff line change
@@ -27,7 +27,7 @@ private static void Main(string[] args)
try
{
ComWrappersSupport.InitializeComWrappers();
AppInstance mainInstance = GetMainInstance();
var mainInstance = GetMainInstance();
if (!mainInstance.IsCurrent)
{
var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
1 change: 1 addition & 0 deletions App/Services/CredentialManager.cs
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 11 additions & 4 deletions App/Services/MutagenController.cs
Original file line number Diff line number Diff line change
@@ -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;
@@ -85,7 +86,9 @@ public interface ISyncSessionController : IAsyncDisposable
/// </summary>
Task<SyncSessionControllerStateModel> RefreshState(CancellationToken ct = default);

Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, Action<string> progressCallback, CancellationToken ct = default);
Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, Action<string> progressCallback,
CancellationToken ct = default);

Task<SyncSessionModel> PauseSyncSession(string identifier, CancellationToken ct = default);
Task<SyncSessionModel> ResumeSyncSession(string identifier, CancellationToken ct = default);
Task TerminateSyncSession(string identifier, CancellationToken ct = default);
@@ -200,7 +203,8 @@ public async Task<SyncSessionControllerStateModel> RefreshState(CancellationToke
return state;
}

public async Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, Action<string>? progressCallback = null, CancellationToken ct = default)
public async Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req,
Action<string>? progressCallback = null, CancellationToken ct = default)
{
using var _ = await _lock.LockAsync(ct);
var client = await EnsureDaemon(ct);
@@ -216,8 +220,11 @@ public async Task<SyncSessionModel> 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(),
},
263 changes: 263 additions & 0 deletions App/ViewModels/DirectoryPickerViewModel.cs
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;
}
}
112 changes: 104 additions & 8 deletions App/ViewModels/FileSyncListViewModel.cs
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());
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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

Copy link
Collaborator

Choose a reason for hiding this comment

The 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

GitHub Actions / test

Possible null reference argument for parameter 'parentWindow' in 'void DirectoryPickerWindow.SetParent(Window parentWindow)'.

Check warning on line 342 in App/ViewModels/FileSyncListViewModel.cs

GitHub Actions / test

Possible null reference argument for parameter 'parentWindow' in 'void DirectoryPickerWindow.SetParent(Window parentWindow)'.

Check warning on line 342 in App/ViewModels/FileSyncListViewModel.cs

GitHub Actions / build

Possible null reference argument for parameter 'parentWindow' in 'void DirectoryPickerWindow.SetParent(Window parentWindow)'.

Check warning on line 342 in App/ViewModels/FileSyncListViewModel.cs

GitHub Actions / build

Possible null reference argument for parameter 'parentWindow' in 'void DirectoryPickerWindow.SetParent(Window parentWindow)'.
_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);
1 change: 1 addition & 0 deletions App/ViewModels/TrayWindowViewModel.cs
Original file line number Diff line number Diff line change
@@ -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),
20 changes: 20 additions & 0 deletions App/Views/DirectoryPickerWindow.xaml
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>
93 changes: 93 additions & 0 deletions App/Views/DirectoryPickerWindow.xaml.cs
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);
}
}
}
2 changes: 1 addition & 1 deletion App/Views/FileSyncListWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -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();
}
179 changes: 179 additions & 0 deletions App/Views/Pages/DirectoryPickerMainPage.xaml
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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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="&#xE974;"
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="&#xE8A5;" />
<!-- Document -->
<converters:StringToStringSelectorItem Key="ParentDirectory"
Value="&#xE72B;" /> <!-- Back -->
<converters:StringToStringSelectorItem Key="Directory" Value="&#xE8B7;" />
<!-- Folder -->
<converters:StringToStringSelectorItem Key="File" Value="&#xE8A5;" />
<!-- 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>
27 changes: 27 additions & 0 deletions App/Views/Pages/DirectoryPickerMainPage.xaml.cs
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);
}
}
69 changes: 44 additions & 25 deletions App/Views/Pages/FileSyncListMainPage.xaml
Original file line number Diff line number Diff line change
@@ -38,21 +38,27 @@
<TextBlock HorizontalAlignment="Center" Text="Loading sync sessions..." />
</Grid>

<StackPanel
<Grid
Visibility="{x:Bind ViewModel.ShowError, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
Orientation="Vertical"
Padding="20">

<TextBlock
Margin="0,0,0,20"
Foreground="Red"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.Error, Mode=OneWay}" />
<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.Error, Mode=OneWay}" />
</ScrollView>

<Button Command="{x:Bind ViewModel.ReloadSessionsCommand, Mode=OneWay}">
<Button Grid.Row="1" Command="{x:Bind ViewModel.ReloadSessionsCommand, Mode=OneWay}">
<TextBlock Text="Reload" />
</Button>
</StackPanel>
</Grid>

<!-- This grid lets us fix the header and only scroll the content. -->
<Grid
@@ -80,7 +86,7 @@
<Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" />
</Style>
<Style TargetType="Border">
<Setter Property="Padding" Value="40,0,0,0" />
<Setter Property="Padding" Value="30,0,0,0" />
</Style>
</Grid.Resources>

@@ -132,7 +138,7 @@
<!-- These are (mostly) from the header Grid and should be copied here -->
<Grid.Resources>
<Style TargetType="Border">
<Setter Property="Padding" Value="40,0,0,0" />
<Setter Property="Padding" Value="30,0,0,0" />
</Style>
</Grid.Resources>
<Grid.ColumnDefinitions>
@@ -266,7 +272,7 @@
<!-- These are (mostly) from the header Grid and should be copied here -->
<Grid.Resources>
<Style TargetType="Border">
<Setter Property="Padding" Value="40,0,0,0" />
<Setter Property="Padding" Value="30,0,0,0" />
</Style>
</Grid.Resources>
<Grid.ColumnDefinitions>
@@ -317,31 +323,44 @@
<Button
Grid.Column="1"
IsEnabled="{x:Bind ViewModel.NewSessionLocalPathDialogOpen, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}"
Command="{x:Bind OpenLocalPathSelectDialogCommand}"
Command="{x:Bind ViewModel.OpenLocalPathSelectDialogCommand}"
VerticalAlignment="Stretch">

<FontIcon Glyph="&#xE838;" FontSize="13" />
</Button>
</Grid>
</Border>
<Border Grid.Column="2">
<!-- TODO: use a combo box for workspace agents -->
<!--
<ComboBox
ItemsSource="{x:Bind WorkspaceAgents}"
IsEnabled="{x:Bind ViewModel.NewSessionRemoteHostEnabled, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableHosts, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.NewSessionRemoteHost, Mode=TwoWay}"
ToolTipService.ToolTip="{x:Bind ViewModel.NewSessionRemoteHost, Mode=OneWay}"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch" />
-->
<TextBox
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
Text="{x:Bind ViewModel.NewSessionRemoteHost, Mode=TwoWay}" />
</Border>
<Border Grid.Column="3">
<TextBox
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
Text="{x:Bind ViewModel.NewSessionRemotePath, Mode=TwoWay}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>

<TextBox
Grid.Column="0"
Margin="0,0,5,0"
VerticalAlignment="Stretch"
Text="{x:Bind ViewModel.NewSessionRemotePath, Mode=TwoWay}" />

<Button
Grid.Column="1"
IsEnabled="{x:Bind ViewModel.NewSessionRemotePathDialogEnabled, Mode=OneWay}"
Command="{x:Bind ViewModel.OpenRemotePathSelectDialogCommand}"
VerticalAlignment="Stretch">

<FontIcon Glyph="&#xE838;" FontSize="13" />
</Button>
</Grid>
</Border>
<Border Grid.Column="4">
<TextBlock
14 changes: 1 addition & 13 deletions App/Views/Pages/FileSyncListMainPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
using System.Threading.Tasks;
using Coder.Desktop.App.ViewModels;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace Coder.Desktop.App.Views.Pages;
@@ -10,12 +7,9 @@ public sealed partial class FileSyncListMainPage : Page
{
public FileSyncListViewModel ViewModel;

private readonly Window _window;

public FileSyncListMainPage(FileSyncListViewModel viewModel, Window window)
public FileSyncListMainPage(FileSyncListViewModel viewModel)
{
ViewModel = viewModel; // already initialized
_window = window;
InitializeComponent();
}

@@ -31,10 +25,4 @@ private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedCha
};
ToolTipService.SetToolTip(sender, toolTip);
}

[RelayCommand]
public async Task OpenLocalPathSelectDialog()
{
await ViewModel.OpenLocalPathSelectDialog(_window);
}
}
24 changes: 24 additions & 0 deletions App/packages.lock.json
Original file line number Diff line number Diff line change
@@ -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, )",
@@ -127,6 +137,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",
61 changes: 61 additions & 0 deletions CoderSdk/Agent/AgentApiClient.cs
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);
}
}
54 changes: 54 additions & 0 deletions CoderSdk/Agent/ListDirectory.cs
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);
}
}
71 changes: 71 additions & 0 deletions CoderSdk/Coder/CoderApiClient.cs
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);
}
}
2 changes: 1 addition & 1 deletion CoderSdk/Deployment.cs → CoderSdk/Coder/Deployment.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace Coder.Desktop.CoderSdk;
namespace Coder.Desktop.CoderSdk.Coder;

public partial interface ICoderApiClient
{
2 changes: 1 addition & 1 deletion CoderSdk/Users.cs → CoderSdk/Coder/Users.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace Coder.Desktop.CoderSdk;
namespace Coder.Desktop.CoderSdk.Coder;

public partial interface ICoderApiClient
{
119 changes: 0 additions & 119 deletions CoderSdk/CoderApiClient.cs

This file was deleted.

15 changes: 14 additions & 1 deletion CoderSdk/Errors.cs
Original file line number Diff line number Diff line change
@@ -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<ValidationError> Validations { get; set; } = [];
}

[JsonSerializable(typeof(Response))]
[JsonSerializable(typeof(ValidationError))]
public partial class ErrorJsonContext : JsonSerializerContext;

public class CoderApiHttpException : Exception
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
TypeInfoResolver = ErrorJsonContext.Default,
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = new SnakeCaseNamingPolicy(),
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};

private static readonly Dictionary<HttpStatusCode, string> Helpers = new()
{
{ HttpStatusCode.Unauthorized, "Try signing in again" },
@@ -45,7 +58,7 @@ public static async Task<CoderApiHttpException> FromResponse(HttpResponseMessage
Response? responseObject;
try
{
responseObject = JsonSerializer.Deserialize<Response>(content, CoderApiClient.JsonOptions);
responseObject = JsonSerializer.Deserialize<Response>(content, JsonOptions);
}
catch (JsonException)
{
82 changes: 82 additions & 0 deletions CoderSdk/JsonHttpClient.cs
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);
}
}
}
6 changes: 2 additions & 4 deletions Installer/Program.cs
Original file line number Diff line number Diff line change
@@ -2,9 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using CommandLine;
using Microsoft.Extensions.Configuration;
using WixSharp;
using WixSharp.Bootstrapper;
using WixSharp.CommonTasks;
@@ -389,8 +387,8 @@ private static int BuildBundle(BootstrapperOptions opts)
[
new ExePackagePayload
{
SourceFile = opts.WindowsAppSdkPath
}
SourceFile = opts.WindowsAppSdkPath,
},
],
},
new MsiPackage(opts.MsiPath)
2 changes: 1 addition & 1 deletion Tests.App/Services/CredentialManagerTest.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Diagnostics;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
using Coder.Desktop.CoderSdk;
using Coder.Desktop.CoderSdk.Coder;
using Moq;

namespace Coder.Desktop.Tests.App.Services;
1 change: 1 addition & 0 deletions Tests.App/Services/MutagenControllerTest.cs
Original file line number Diff line number Diff line change
@@ -113,6 +113,7 @@ public async Task Ok(CancellationToken ct)
await AssertDaemonStopped(dataDirectory, ct);

var progressMessages = new List<string>();

void OnProgress(string message)
{
TestContext.Out.WriteLine("Create session progress: " + message);
11 changes: 3 additions & 8 deletions Vpn.Service/Downloader.cs
Original file line number Diff line number Diff line change
@@ -297,15 +297,10 @@ public async Task<DownloadTask> StartDownloadAsync(HttpRequestMessage req, strin
// remove the key first, before checking the exception, to ensure
// we still clean up.
_downloads.TryRemove(destinationPath, out _);
if (tsk.Exception == null)
{
return;
}
if (tsk.Exception == null) return;

if (tsk.Exception.InnerException != null)
{
ExceptionDispatchInfo.Capture(tsk.Exception.InnerException).Throw();
}

// not sure if this is hittable, but just in case:
throw tsk.Exception;
@@ -328,7 +323,7 @@ public async Task<DownloadTask> StartDownloadAsync(HttpRequestMessage req, strin
}

/// <summary>
/// TaskOrCancellation waits for either the task to complete, or the given token to be canceled.
/// TaskOrCancellation waits for either the task to complete, or the given token to be canceled.
/// </summary>
internal static async Task TaskOrCancellation(Task task, CancellationToken cancellationToken)
{
@@ -454,7 +449,6 @@ private async Task Start(CancellationToken ct = default)
TotalBytes = (ulong)res.Content.Headers.ContentLength;

await Download(res, ct);
return;
}

private async Task Download(HttpResponseMessage res, CancellationToken ct)
@@ -472,6 +466,7 @@ private async Task Download(HttpResponseMessage res, CancellationToken ct)
_logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", TempDestinationPath);
throw;
}

await using (tempFile)
{
var stream = await res.Content.ReadAsStreamAsync(ct);
2 changes: 1 addition & 1 deletion Vpn.Service/Manager.cs
Original file line number Diff line number Diff line change
@@ -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;

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

GitHub Actions / test

Cannot convert null literal to non-nullable reference type.

Check warning on line 32 in Tests.App/Converters/FriendlyByteConverterTest.cs

GitHub Actions / test

Cannot convert null literal to non-nullable reference type.
Assert.That(actual, Is.EqualTo(expected), $"case ({input.GetType()}){input}");
}
}