diff --git a/App/App.csproj b/App/App.csproj
index 2a15166..982612f 100644
--- a/App/App.csproj
+++ b/App/App.csproj
@@ -56,15 +56,19 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
+
+
+
+
+
+
diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index 4a35a0f..2c7e87e 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -1,18 +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 Serilog;
+using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs;
namespace Coder.Desktop.App;
@@ -21,22 +29,41 @@ public partial class App : Application
private readonly IServiceProvider _services;
private bool _handleWindowClosed = true;
+ private const string MutagenControllerConfigSection = "MutagenController";
#if !DEBUG
- private const string MutagenControllerConfigSection = "AppMutagenController";
+ private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\App";
+ private const string logFilename = "app.log";
#else
- private const string MutagenControllerConfigSection = "DebugAppMutagenController";
+ private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugApp";
+ private const string logFilename = "debug-app.log";
#endif
+ private readonly ILogger _logger;
+
public App()
{
var builder = Host.CreateApplicationBuilder();
+ var configBuilder = builder.Configuration as IConfigurationBuilder;
- (builder.Configuration as IConfigurationBuilder).Add(
- new RegistryConfigurationSource(Registry.LocalMachine, @"SOFTWARE\Coder Desktop"));
+ // Add config in increasing order of precedence: first builtin defaults, then HKLM, finally HKCU
+ // so that the user's settings in the registry take precedence.
+ AddDefaultConfig(configBuilder);
+ configBuilder.Add(
+ new RegistryConfigurationSource(Registry.LocalMachine, ConfigSubKey));
+ configBuilder.Add(
+ new RegistryConfigurationSource(Registry.CurrentUser, ConfigSubKey));
var services = builder.Services;
+ // Logging
+ builder.Services.AddSerilog((_, loggerConfig) =>
+ {
+ loggerConfig.ReadFrom.Configuration(builder.Configuration);
+ });
+
+ services.AddSingleton();
+
services.AddSingleton();
services.AddSingleton();
@@ -53,6 +80,8 @@ public App()
// FileSyncListMainPage is created by FileSyncListWindow.
services.AddTransient();
+ // DirectoryPickerWindow views and view models are created by FileSyncListViewModel.
+
// TrayWindow views and view models
services.AddTransient();
services.AddTransient();
@@ -66,12 +95,14 @@ public App()
services.AddTransient();
_services = services.BuildServiceProvider();
+ _logger = (ILogger)_services.GetService(typeof(ILogger))!;
InitializeComponent();
}
public async Task ExitApplication()
{
+ _logger.LogDebug("exiting app");
_handleWindowClosed = false;
Exit();
var syncController = _services.GetRequiredService();
@@ -84,36 +115,39 @@ public async Task ExitApplication()
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
+ _logger.LogInformation("new instance launched");
// Start connecting to the manager in the background.
var rpcController = _services.GetRequiredService();
if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
// Passing in a CT with no cancellation is desired here, because
// the named pipe open will block until the pipe comes up.
- // TODO: log
- _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t =>
+ _logger.LogDebug("reconnecting with VPN service");
+ _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t =>
+ {
+ if (t.Exception != null)
{
+ _logger.LogError(t.Exception, "failed to connect to VPN service");
#if DEBUG
- if (t.Exception != null)
- {
- Debug.WriteLine(t.Exception);
- Debugger.Break();
- }
+ Debug.WriteLine(t.Exception);
+ Debugger.Break();
#endif
- });
+ }
+ });
// Load the credentials in the background.
var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var credentialManager = _services.GetRequiredService();
_ = credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t =>
{
- // TODO: log
-#if DEBUG
if (t.Exception != null)
{
+ _logger.LogError(t.Exception, "failed to load credentials");
+#if DEBUG
Debug.WriteLine(t.Exception);
Debugger.Break();
- }
#endif
+ }
+
credentialManagerCts.Dispose();
}, CancellationToken.None);
@@ -122,10 +156,14 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
var syncSessionController = _services.GetRequiredService();
_ = syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(t =>
{
- // TODO: log
+ if (t.IsCanceled || t.Exception != null)
+ {
+ _logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled);
#if DEBUG
- if (t.IsCanceled || t.Exception != null) Debugger.Break();
+ Debugger.Break();
#endif
+ }
+
syncSessionCts.Dispose();
}, CancellationToken.None);
@@ -138,4 +176,51 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
trayWindow.AppWindow.Hide();
};
}
+
+ public void OnActivated(object? sender, AppActivationArguments args)
+ {
+ switch (args.Kind)
+ {
+ case ExtendedActivationKind.Protocol:
+ var protoArgs = args.Data as IProtocolActivatedEventArgs;
+ if (protoArgs == null)
+ {
+ _logger.LogWarning("URI activation with null data");
+ return;
+ }
+
+ HandleURIActivation(protoArgs.Uri);
+ break;
+
+ default:
+ _logger.LogWarning("activation for {kind}, which is unhandled", args.Kind);
+ break;
+ }
+ }
+
+ public void HandleURIActivation(Uri uri)
+ {
+ // don't log the query string as that's where we include some sensitive information like passwords
+ _logger.LogInformation("handling URI activation for {path}", uri.AbsolutePath);
+ }
+
+ private static void AddDefaultConfig(IConfigurationBuilder builder)
+ {
+ var logPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "CoderDesktop",
+ logFilename);
+ builder.AddInMemoryCollection(new Dictionary
+ {
+ [MutagenControllerConfigSection + ":MutagenExecutablePath"] = @"C:\mutagen.exe",
+ ["Serilog:Using:0"] = "Serilog.Sinks.File",
+ ["Serilog:MinimumLevel"] = "Information",
+ ["Serilog:Enrich:0"] = "FromLogContext",
+ ["Serilog:WriteTo:0:Name"] = "File",
+ ["Serilog:WriteTo:0:Args:path"] = logPath,
+ ["Serilog:WriteTo:0:Args:outputTemplate"] =
+ "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}",
+ ["Serilog:WriteTo:0:Args:rollingInterval"] = "Day",
+ });
+ }
}
diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs
index 8c1570f..a31c33b 100644
--- a/App/Converters/DependencyObjectSelector.cs
+++ b/App/Converters/DependencyObjectSelector.cs
@@ -186,3 +186,7 @@ private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyP
public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem;
public sealed class StringToBrushSelector : DependencyObjectSelector;
+
+public sealed class StringToStringSelectorItem : DependencyObjectSelectorItem;
+
+public sealed class StringToStringSelector : DependencyObjectSelector;
diff --git a/App/Package.appxmanifest b/App/Package.appxmanifest
deleted file mode 100644
index e3ad480..0000000
--- a/App/Package.appxmanifest
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
- Coder Desktop (Package)
- Coder Technologies Inc.
- Images\StoreLogo.png
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/App/Program.cs b/App/Program.cs
index 2918caa..1a54b2b 100644
--- a/App/Program.cs
+++ b/App/Program.cs
@@ -1,4 +1,5 @@
using System;
+using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.UI.Dispatching;
@@ -26,7 +27,23 @@ private static void Main(string[] args)
try
{
ComWrappersSupport.InitializeComWrappers();
- if (!CheckSingleInstance()) return;
+ var mainInstance = GetMainInstance();
+ if (!mainInstance.IsCurrent)
+ {
+ var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
+ mainInstance.RedirectActivationToAsync(activationArgs).AsTask().Wait();
+ return;
+ }
+
+ // Register for URI handling (known as "protocol activation")
+#if DEBUG
+ const string scheme = "coder-debug";
+#else
+ const string scheme = "coder";
+#endif
+ var thisBin = Assembly.GetExecutingAssembly().Location;
+ ActivationRegistrationManager.RegisterForProtocolActivation(scheme, thisBin + ",1", "Coder Desktop", "");
+
Application.Start(p =>
{
var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
@@ -38,6 +55,9 @@ private static void Main(string[] args)
e.Handled = true;
ShowExceptionAndCrash(e.Exception);
};
+
+ // redirections via RedirectActivationToAsync above get routed to the App
+ mainInstance.Activated += app.OnActivated;
});
}
catch (Exception e)
@@ -46,8 +66,7 @@ private static void Main(string[] args)
}
}
- [STAThread]
- private static bool CheckSingleInstance()
+ private static AppInstance GetMainInstance()
{
#if !DEBUG
const string appInstanceName = "Coder.Desktop.App";
@@ -55,11 +74,9 @@ private static bool CheckSingleInstance()
const string appInstanceName = "Coder.Desktop.App.Debug";
#endif
- var instance = AppInstance.FindOrRegisterForKey(appInstanceName);
- return instance.IsCurrent;
+ return AppInstance.FindOrRegisterForKey(appInstanceName);
}
- [STAThread]
private static void ShowExceptionAndCrash(Exception e)
{
const string title = "Coder Desktop Fatal Error";
diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs
index 41a8dc7..a2f6567 100644
--- a/App/Services/CredentialManager.cs
+++ b/App/Services/CredentialManager.cs
@@ -7,6 +7,7 @@
using System.Threading.Tasks;
using Coder.Desktop.App.Models;
using Coder.Desktop.CoderSdk;
+using Coder.Desktop.CoderSdk.Coder;
using Coder.Desktop.Vpn.Utilities;
namespace Coder.Desktop.App.Services;
diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs
index dd489df..3931b66 100644
--- a/App/Services/MutagenController.cs
+++ b/App/Services/MutagenController.cs
@@ -12,10 +12,14 @@
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;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using Serilog;
using DaemonTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Daemon.TerminateRequest;
using MutagenProtocol = Coder.Desktop.MutagenSdk.Proto.Url.Protocol;
using SynchronizationTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Synchronization.TerminateRequest;
@@ -85,7 +89,9 @@ public interface ISyncSessionController : IAsyncDisposable
///
Task RefreshState(CancellationToken ct = default);
- Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default);
+ Task CreateSyncSession(CreateSyncSessionRequest req, Action progressCallback,
+ CancellationToken ct = default);
+
Task PauseSyncSession(string identifier, CancellationToken ct = default);
Task ResumeSyncSession(string identifier, CancellationToken ct = default);
Task TerminateSyncSession(string identifier, CancellationToken ct = default);
@@ -110,6 +116,8 @@ public sealed class MutagenController : ISyncSessionController
// Protects all private non-readonly class members.
private readonly RaiiSemaphoreSlim _lock = new(1, 1);
+ private readonly ILogger _logger;
+
private readonly CancellationTokenSource _stateUpdateCts = new();
private Task? _stateUpdateTask;
@@ -139,15 +147,19 @@ public sealed class MutagenController : ISyncSessionController
private string MutagenDaemonLog => Path.Combine(_mutagenDataDirectory, "daemon.log");
- public MutagenController(IOptions config)
+ public MutagenController(IOptions config, ILogger logger)
{
_mutagenExecutablePath = config.Value.MutagenExecutablePath;
+ _logger = logger;
}
public MutagenController(string executablePath, string dataDirectory)
{
_mutagenExecutablePath = executablePath;
_mutagenDataDirectory = dataDirectory;
+ var builder = Host.CreateApplicationBuilder();
+ builder.Services.AddSerilog();
+ _logger = (ILogger)builder.Build().Services.GetService(typeof(ILogger))!;
}
public event EventHandler? StateChanged;
@@ -200,12 +212,16 @@ public async Task RefreshState(CancellationToke
return state;
}
- public async Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default)
+ public async Task CreateSyncSession(CreateSyncSessionRequest req,
+ Action? progressCallback = null, CancellationToken ct = default)
{
using var _ = await _lock.LockAsync(ct);
var client = await EnsureDaemon(ct);
await using var prompter = await Prompter.Create(client, true, ct);
+ if (progressCallback != null)
+ prompter.OnProgress += (_, progress) => progressCallback(progress);
+
var createRes = await client.Synchronization.CreateAsync(new CreateRequest
{
Prompter = prompter.Identifier,
@@ -213,8 +229,11 @@ public async Task CreateSyncSession(CreateSyncSessionRequest r
{
Alpha = req.Alpha.MutagenUrl,
Beta = req.Beta.MutagenUrl,
- // TODO: probably should set these at some point
- Configuration = new Configuration(),
+ // TODO: probably should add a configuration page for these at some point
+ Configuration = new Configuration
+ {
+ IgnoreVCSMode = IgnoreVCSMode.Ignore,
+ },
ConfigurationAlpha = new Configuration(),
ConfigurationBeta = new Configuration(),
},
@@ -437,9 +456,9 @@ private async Task EnsureDaemon(CancellationToken ct)
{
await StopDaemon(cts.Token);
}
- catch
+ catch (Exception stopEx)
{
- // ignored
+ _logger.LogError(stopEx, "failed to stop daemon");
}
ReplaceState(new SyncSessionControllerStateModel
@@ -491,6 +510,8 @@ private async Task StartDaemon(CancellationToken ct)
}
catch (Exception e) when (e is not OperationCanceledException)
{
+ _logger.LogWarning(e, "failed to start daemon process, attempt {attempt} of {maxAttempts}", attempts,
+ maxAttempts);
if (attempts == maxAttempts)
throw;
// back off a little and try again.
@@ -535,21 +556,62 @@ private void StartDaemonProcess()
var logPath = Path.Combine(_mutagenDataDirectory, "daemon.log");
var logStream = new StreamWriter(logPath, true);
- _daemonProcess = new Process();
- _daemonProcess.StartInfo.FileName = _mutagenExecutablePath;
- _daemonProcess.StartInfo.Arguments = "daemon run";
- _daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory);
+ _logger.LogInformation("starting mutagen daemon process with executable path '{path}'", _mutagenExecutablePath);
+ _logger.LogInformation("mutagen data directory '{path}'", _mutagenDataDirectory);
+ _logger.LogInformation("mutagen daemon log path '{path}'", logPath);
+
+ var daemonProcess = new Process();
+ daemonProcess.StartInfo.FileName = _mutagenExecutablePath;
+ daemonProcess.StartInfo.Arguments = "daemon run";
+ daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory);
+ daemonProcess.StartInfo.Environment.Add("MUTAGEN_SSH_CONFIG_PATH", "none"); // do not use ~/.ssh/config
// hide the console window
- _daemonProcess.StartInfo.CreateNoWindow = true;
+ daemonProcess.StartInfo.CreateNoWindow = true;
// shell needs to be disabled since we set the environment
// https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.environment?view=net-8.0
- _daemonProcess.StartInfo.UseShellExecute = false;
- _daemonProcess.StartInfo.RedirectStandardError = true;
- // TODO: log exited process
- // _daemonProcess.Exited += ...
- if (!_daemonProcess.Start())
- throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false");
+ daemonProcess.StartInfo.UseShellExecute = false;
+ daemonProcess.StartInfo.RedirectStandardError = true;
+ daemonProcess.EnableRaisingEvents = true;
+ daemonProcess.Exited += (_, _) =>
+ {
+ var exitCode = -1;
+ try
+ {
+ // ReSharper disable once AccessToDisposedClosure
+ exitCode = daemonProcess.ExitCode;
+ }
+ catch
+ {
+ // ignored
+ }
+
+ _logger.LogInformation("mutagen daemon exited with code {exitCode}", exitCode);
+ };
+ try
+ {
+ if (!daemonProcess.Start())
+ throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false");
+ }
+ catch (Exception e)
+ {
+ _logger.LogWarning(e, "mutagen daemon failed to start");
+
+ logStream.Dispose();
+ try
+ {
+ daemonProcess.Kill();
+ }
+ catch
+ {
+ // ignored, the process likely doesn't exist
+ }
+
+ daemonProcess.Dispose();
+ throw;
+ }
+
+ _daemonProcess = daemonProcess;
var writer = new LogWriter(_daemonProcess.StandardError, logStream);
Task.Run(() => { _ = writer.Run(); });
_logWriter = writer;
@@ -561,6 +623,7 @@ private void StartDaemonProcess()
///
private async Task StopDaemon(CancellationToken ct)
{
+ _logger.LogDebug("stopping mutagen daemon");
var process = _daemonProcess;
var client = _mutagenClient;
var writer = _logWriter;
@@ -573,17 +636,21 @@ private async Task StopDaemon(CancellationToken ct)
if (client == null)
{
if (process == null) return;
+ _logger.LogDebug("no client; killing daemon process");
process.Kill(true);
}
else
{
try
{
+ _logger.LogDebug("sending DaemonTerminateRequest");
await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct);
}
- catch
+ catch (Exception e)
{
+ _logger.LogError(e, "failed to gracefully terminate agent");
if (process == null) return;
+ _logger.LogDebug("killing daemon process after failed graceful termination");
process.Kill(true);
}
}
@@ -591,10 +658,12 @@ private async Task StopDaemon(CancellationToken ct)
if (process == null) return;
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(5));
+ _logger.LogDebug("waiting for process to exit");
await process.WaitForExitAsync(cts.Token);
}
finally
{
+ _logger.LogDebug("cleaning up daemon process objects");
client?.Dispose();
process?.Dispose();
writer?.Dispose();
@@ -603,6 +672,8 @@ private async Task StopDaemon(CancellationToken ct)
private class Prompter : IAsyncDisposable
{
+ public event EventHandler? OnProgress;
+
private readonly AsyncDuplexStreamingCall _dup;
private readonly CancellationTokenSource _cts;
private readonly Task _handleRequestsTask;
@@ -684,6 +755,9 @@ private async Task HandleRequests(CancellationToken ct)
if (response.Message == null)
throw new InvalidOperationException("Prompting.Host response stream returned a null message");
+ if (!response.IsPrompt)
+ OnProgress?.Invoke(this, response.Message);
+
// Currently we only reply to SSH fingerprint messages with
// "yes" and send an empty reply for everything else.
var reply = "";
diff --git a/App/ViewModels/DirectoryPickerViewModel.cs b/App/ViewModels/DirectoryPickerViewModel.cs
new file mode 100644
index 0000000..131934f
--- /dev/null
+++ b/App/ViewModels/DirectoryPickerViewModel.cs
@@ -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 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 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? PathSelected;
+
+ private const int RequestTimeoutMilliseconds = 15_000;
+
+ private readonly IAgentApiClient _client;
+
+ private Window? _window;
+ private DispatcherQueue? _dispatcherQueue;
+
+ public readonly string AgentFqdn;
+
+ // The initial loading screen is differentiated from subsequent loading
+ // screens because:
+ // 1. We don't want to show a broken state while the page is loading.
+ // 2. An error dialog allows the user to get to a broken state with no
+ // breadcrumbs, no items, etc. with no chance to reload.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))]
+ [NotifyPropertyChangedFor(nameof(ShowListScreen))]
+ public partial bool InitialLoading { get; set; } = true;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))]
+ [NotifyPropertyChangedFor(nameof(ShowErrorScreen))]
+ [NotifyPropertyChangedFor(nameof(ShowListScreen))]
+ public partial string? InitialLoadError { get; set; } = null;
+
+ [ObservableProperty] public partial bool NavigatingLoading { get; set; } = false;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsSelectable))]
+ public partial string CurrentDirectory { get; set; } = "";
+
+ [ObservableProperty] public partial IReadOnlyList Breadcrumbs { get; set; } = [];
+
+ [ObservableProperty] public partial IReadOnlyList Items { get; set; } = [];
+
+ public bool ShowLoadingScreen => InitialLoadError == null && InitialLoading;
+ public bool ShowErrorScreen => InitialLoadError != null;
+ public bool ShowListScreen => InitialLoadError == null && !InitialLoading;
+
+ // The "root" directory on Windows isn't a real thing, but in our model
+ // it's a drive listing. We don't allow users to select the fake drive
+ // listing directory.
+ //
+ // On Linux, this will never be empty since the highest you can go is "/".
+ public bool IsSelectable => CurrentDirectory != "";
+
+ public DirectoryPickerViewModel(IAgentApiClientFactory clientFactory, string agentFqdn)
+ {
+ _client = clientFactory.Create(agentFqdn);
+ AgentFqdn = agentFqdn;
+ }
+
+ public void Initialize(Window window, DispatcherQueue dispatcherQueue)
+ {
+ _window = window;
+ _dispatcherQueue = dispatcherQueue;
+ if (!_dispatcherQueue.HasThreadAccess)
+ throw new InvalidOperationException("Initialize must be called from the UI thread");
+
+ InitialLoading = true;
+ InitialLoadError = null;
+ // Initial load is in the home directory.
+ _ = BackgroundLoad(ListDirectoryRelativity.Home, []).ContinueWith(ContinueInitialLoad);
+ }
+
+ [RelayCommand]
+ private void RetryLoad()
+ {
+ InitialLoading = true;
+ InitialLoadError = null;
+ // Subsequent loads after the initial failure are always in the root
+ // directory in case there's a permanent issue preventing listing the
+ // home directory.
+ _ = BackgroundLoad(ListDirectoryRelativity.Root, []).ContinueWith(ContinueInitialLoad);
+ }
+
+ private async Task BackgroundLoad(ListDirectoryRelativity relativity, List path)
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ return await _client.ListDirectory(new ListDirectoryRequest
+ {
+ Path = path,
+ Relativity = relativity,
+ }, cts.Token);
+ }
+
+ private void ContinueInitialLoad(Task task)
+ {
+ // Ensure we're on the UI thread.
+ if (_dispatcherQueue == null) return;
+ if (!_dispatcherQueue.HasThreadAccess)
+ {
+ _dispatcherQueue.TryEnqueue(() => ContinueInitialLoad(task));
+ return;
+ }
+
+ if (task.IsCompletedSuccessfully)
+ {
+ ProcessResponse(task.Result);
+ return;
+ }
+
+ InitialLoadError = "Could not list home directory in workspace: ";
+ if (task.IsCanceled) InitialLoadError += new TaskCanceledException();
+ else if (task.IsFaulted) InitialLoadError += task.Exception;
+ else InitialLoadError += "no successful result or error";
+ InitialLoading = false;
+ }
+
+ [RelayCommand]
+ public async Task ListPath(IReadOnlyList path)
+ {
+ if (_window is null || NavigatingLoading) return;
+ NavigatingLoading = true;
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(RequestTimeoutMilliseconds));
+ try
+ {
+ var res = await _client.ListDirectory(new ListDirectoryRequest
+ {
+ Path = path.ToList(),
+ Relativity = ListDirectoryRelativity.Root,
+ }, cts.Token);
+ ProcessResponse(res);
+ }
+ catch (Exception e)
+ {
+ // Subsequent listing errors are just shown as dialog boxes.
+ var dialog = new ContentDialog
+ {
+ Title = "Failed to list remote directory",
+ Content = $"{e}",
+ CloseButtonText = "Ok",
+ XamlRoot = _window.Content.XamlRoot,
+ };
+ _ = await dialog.ShowAsync();
+ }
+ finally
+ {
+ NavigatingLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ public void Cancel()
+ {
+ PathSelected?.Invoke(this, null);
+ _window?.Close();
+ }
+
+ [RelayCommand]
+ public void Select()
+ {
+ if (CurrentDirectory == "") return;
+ PathSelected?.Invoke(this, CurrentDirectory);
+ _window?.Close();
+ }
+
+ private void ProcessResponse(ListDirectoryResponse res)
+ {
+ InitialLoading = false;
+ InitialLoadError = null;
+ NavigatingLoading = false;
+
+ var breadcrumbs = new List(res.AbsolutePath.Count + 1)
+ {
+ new()
+ {
+ 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(res.Contents.Count + 1);
+ if (res.AbsolutePath.Count != 0)
+ items.Add(new DirectoryPickerItem
+ {
+ Kind = DirectoryPickerItemKind.ParentDirectory,
+ Name = "..",
+ AbsolutePathSegments = res.AbsolutePath[..^1],
+ ViewModel = this,
+ });
+
+ foreach (var item in res.Contents)
+ {
+ if (item.Name.StartsWith(".")) continue;
+ items.Add(new DirectoryPickerItem
+ {
+ Kind = item.IsDir ? DirectoryPickerItemKind.Directory : DirectoryPickerItemKind.File,
+ Name = item.Name,
+ AbsolutePathSegments = res.AbsolutePath.Append(item.Name).ToList(),
+ ViewModel = this,
+ });
+ }
+
+ CurrentDirectory = res.AbsolutePathString;
+ Breadcrumbs = breadcrumbs;
+ Items = items;
+ }
+}
diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs
index 7fdd881..9235141 100644
--- a/App/ViewModels/FileSyncListViewModel.cs
+++ b/App/ViewModels/FileSyncListViewModel.cs
@@ -6,6 +6,8 @@
using Windows.Storage.Pickers;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
+using Coder.Desktop.App.Views;
+using Coder.Desktop.CoderSdk.Agent;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Dispatching;
@@ -19,10 +21,12 @@ public partial class FileSyncListViewModel : ObservableObject
{
private Window? _window;
private DispatcherQueue? _dispatcherQueue;
+ private 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 @@ public partial class FileSyncListViewModel : ObservableObject
[ObservableProperty] public partial bool OperationInProgress { get; set; } = false;
- [ObservableProperty] public partial List Sessions { get; set; } = [];
+ [ObservableProperty] public partial IReadOnlyList Sessions { get; set; } = [];
[ObservableProperty] public partial bool CreatingNewSession { get; set; } = false;
@@ -58,14 +62,30 @@ public partial class FileSyncListViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
public partial bool NewSessionLocalPathDialogOpen { get; set; } = false;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))]
+ public partial IReadOnlyList AvailableHosts { get; set; } = [];
+
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
- public partial string NewSessionRemoteHost { get; set; } = "";
+ [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))]
+ public partial string? NewSessionRemoteHost { get; set; } = null;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
public partial string NewSessionRemotePath { get; set; } = "";
- // TODO: NewSessionRemotePathDialogOpen for remote path
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
+ [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))]
+ public partial bool NewSessionRemotePathDialogOpen { get; set; } = false;
+
+ public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0;
+
+ public bool NewSessionRemotePathDialogEnabled =>
+ !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen;
+
+ [ObservableProperty] public partial string NewSessionStatus { get; set; } = "";
public bool NewSessionCreateEnabled
{
@@ -75,6 +95,7 @@ public bool NewSessionCreateEnabled
if (NewSessionLocalPathDialogOpen) return false;
if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) return false;
if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false;
+ if (NewSessionRemotePathDialogOpen) return false;
return true;
}
}
@@ -86,11 +107,12 @@ public bool NewSessionCreateEnabled
public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null;
public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController,
- ICredentialManager credentialManager)
+ ICredentialManager credentialManager, IAgentApiClientFactory agentApiClientFactory)
{
_syncSessionController = syncSessionController;
_rpcController = rpcController;
_credentialManager = credentialManager;
+ _agentApiClientFactory = agentApiClientFactory;
}
public void Initialize(Window window, DispatcherQueue dispatcherQueue)
@@ -103,6 +125,14 @@ public void Initialize(Window window, DispatcherQueue dispatcherQueue)
_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();
@@ -171,8 +201,13 @@ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel crede
else
{
UnavailableMessage = null;
+ // Reload if we transitioned from unavailable to available.
if (oldMessage != null) ReloadSessions();
}
+
+ // When transitioning from available to unavailable:
+ if (oldMessage == null && UnavailableMessage != null)
+ ClearNewForm();
}
private void UpdateSyncSessionState(SyncSessionControllerStateModel syncSessionState)
@@ -187,6 +222,8 @@ private void ClearNewForm()
NewSessionLocalPath = "";
NewSessionRemoteHost = "";
NewSessionRemotePath = "";
+ NewSessionStatus = "";
+ _remotePickerWindow?.Close();
}
[RelayCommand]
@@ -223,21 +260,50 @@ private void HandleRefresh(Task t)
Loading = false;
}
+ // Overriding AvailableHosts seems to make the ComboBox clear its value, so
+ // we only do this while the create form is not open.
+ // Must be called in UI thread.
+ private void SetAvailableHostsFromRpcModel(RpcModel rpcModel)
+ {
+ var hosts = new List(rpcModel.Agents.Count);
+ // Agents will only contain started agents.
+ foreach (var agent in rpcModel.Agents)
+ {
+ var fqdn = agent.Fqdn
+ .Select(a => a.Trim('.'))
+ .Where(a => !string.IsNullOrWhiteSpace(a))
+ .Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b);
+ if (string.IsNullOrWhiteSpace(fqdn))
+ continue;
+ hosts.Add(fqdn);
+ }
+
+ NewSessionRemoteHost = null;
+ AvailableHosts = hosts;
+ }
+
[RelayCommand]
private void StartCreatingNewSession()
{
ClearNewForm();
+ // Ensure we have a fresh hosts list before we open the form. 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());
CreatingNewSession = true;
}
- public async Task OpenLocalPathSelectDialog(Window window)
+ [RelayCommand]
+ public async Task OpenLocalPathSelectDialog()
{
+ if (_window is null) return;
+
var picker = new FolderPicker
{
SuggestedStartLocation = PickerLocationId.ComputerFolder,
};
- var hwnd = WindowNative.GetWindowHandle(window);
+ var hwnd = WindowNative.GetWindowHandle(_window);
InitializeWithWindow.Initialize(picker, hwnd);
NewSessionLocalPathDialogOpen = true;
@@ -257,19 +323,66 @@ public async Task OpenLocalPathSelectDialog(Window window)
}
}
+ [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);
+ _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()
{
ClearNewForm();
}
+ private void OnCreateSessionProgress(string message)
+ {
+ // Ensure we're on the UI thread.
+ if (_dispatcherQueue == null) return;
+ if (!_dispatcherQueue.HasThreadAccess)
+ {
+ _dispatcherQueue.TryEnqueue(() => OnCreateSessionProgress(message));
+ return;
+ }
+
+ NewSessionStatus = message;
+ }
+
[RelayCommand]
private async Task ConfirmNewSession()
{
if (OperationInProgress || !NewSessionCreateEnabled) return;
OperationInProgress = true;
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
try
{
// The controller will send us a state changed event.
@@ -283,10 +396,10 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest
Beta = new CreateSyncSessionRequest.Endpoint
{
Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh,
- Host = NewSessionRemoteHost,
+ Host = NewSessionRemoteHost!,
Path = NewSessionRemotePath,
},
- }, cts.Token);
+ }, OnCreateSessionProgress, cts.Token);
ClearNewForm();
}
@@ -304,6 +417,7 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest
finally
{
OperationInProgress = false;
+ NewSessionStatus = "";
}
}
diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs
index 532bfe4..f845521 100644
--- a/App/ViewModels/TrayWindowViewModel.cs
+++ b/App/ViewModels/TrayWindowViewModel.cs
@@ -178,6 +178,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
{
// We just assume that it's a single-agent workspace.
Hostname = workspace.Name,
+ // TODO: this needs to get the suffix from the server
HostnameSuffix = ".coder",
ConnectionStatus = AgentConnectionStatus.Gray,
DashboardUrl = WorkspaceUri(coderUri, workspace.Name),
diff --git a/App/Views/DirectoryPickerWindow.xaml b/App/Views/DirectoryPickerWindow.xaml
new file mode 100644
index 0000000..8a107cb
--- /dev/null
+++ b/App/Views/DirectoryPickerWindow.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs
new file mode 100644
index 0000000..6ed5f43
--- /dev/null
+++ b/App/Views/DirectoryPickerWindow.xaml.cs
@@ -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);
+ }
+ }
+}
diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs
index 8a409d7..428363b 100644
--- a/App/Views/FileSyncListWindow.xaml.cs
+++ b/App/Views/FileSyncListWindow.xaml.cs
@@ -16,7 +16,7 @@ public FileSyncListWindow(FileSyncListViewModel viewModel)
SystemBackdrop = new DesktopAcrylicBackdrop();
ViewModel.Initialize(this, DispatcherQueue);
- RootFrame.Content = new FileSyncListMainPage(ViewModel, this);
+ RootFrame.Content = new FileSyncListMainPage(ViewModel);
this.CenterOnScreen();
}
diff --git a/App/Views/Pages/DirectoryPickerMainPage.xaml b/App/Views/Pages/DirectoryPickerMainPage.xaml
new file mode 100644
index 0000000..dd08c46
--- /dev/null
+++ b/App/Views/Pages/DirectoryPickerMainPage.xaml
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/DirectoryPickerMainPage.xaml.cs b/App/Views/Pages/DirectoryPickerMainPage.xaml.cs
new file mode 100644
index 0000000..4e26200
--- /dev/null
+++ b/App/Views/Pages/DirectoryPickerMainPage.xaml.cs
@@ -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);
+ }
+}
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml
index d38bc29..cb9f2bb 100644
--- a/App/Views/Pages/FileSyncListMainPage.xaml
+++ b/App/Views/Pages/FileSyncListMainPage.xaml
@@ -38,21 +38,27 @@
-
-
+
+
+
+
+
+
+
+
-
+
@@ -132,7 +138,7 @@
@@ -266,7 +272,7 @@
@@ -274,8 +280,11 @@
-
-
+
+
@@ -314,7 +323,7 @@
@@ -322,23 +331,43 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml.cs b/App/Views/Pages/FileSyncListMainPage.xaml.cs
index c54c29e..a677522 100644
--- a/App/Views/Pages/FileSyncListMainPage.xaml.cs
+++ b/App/Views/Pages/FileSyncListMainPage.xaml.cs
@@ -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);
- }
}
diff --git a/App/packages.lock.json b/App/packages.lock.json
index 405ea61..1541d01 100644
--- a/App/packages.lock.json
+++ b/App/packages.lock.json
@@ -8,6 +8,16 @@
"resolved": "8.4.0",
"contentHash": "tqVU8yc/ADO9oiTRyTnwhFN68hCwvkliMierptWOudIAvWY1mWCh5VFh+guwHJmpMwfg0J0rY+yyd5Oy7ty9Uw=="
},
+ "CommunityToolkit.WinUI.Controls.Primitives": {
+ "type": "Direct",
+ "requested": "[8.2.250402, )",
+ "resolved": "8.2.250402",
+ "contentHash": "Wx3t1zADrzBWDar45uRl+lmSxDO5Vx7tTMFm/mNgl3fs5xSQ1ySPdGqD10EFov3rkKc5fbpHGW5xj8t62Yisvg==",
+ "dependencies": {
+ "CommunityToolkit.WinUI.Extensions": "8.2.250402",
+ "Microsoft.WindowsAppSDK": "1.6.250108002"
+ }
+ },
"DependencyPropertyGenerator": {
"type": "Direct",
"requested": "[1.5.0, )",
@@ -28,51 +38,51 @@
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Direct",
- "requested": "[9.0.1, )",
- "resolved": "9.0.1",
- "contentHash": "qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==",
+ "requested": "[9.0.4, )",
+ "resolved": "9.0.4",
+ "contentHash": "f2MTUaS2EQ3lX4325ytPAISZqgBfXmY0WvgD80ji6Z20AoDNiCESxsqo6mFRwHJD/jfVKRw9FsW6+86gNre3ug==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1"
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4"
}
},
"Microsoft.Extensions.Hosting": {
"type": "Direct",
- "requested": "[9.0.1, )",
- "resolved": "9.0.1",
- "contentHash": "3wZNcVvC8RW44HDqqmIq+BqF5pgmTQdbNdR9NyYw33JSMnJuclwoJ2PEkrJ/KvD1U/hmqHVL3l5If+Hn3D1fWA==",
- "dependencies": {
- "Microsoft.Extensions.Configuration": "9.0.1",
- "Microsoft.Extensions.Configuration.Abstractions": "9.0.1",
- "Microsoft.Extensions.Configuration.Binder": "9.0.1",
- "Microsoft.Extensions.Configuration.CommandLine": "9.0.1",
- "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.1",
- "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1",
- "Microsoft.Extensions.Configuration.Json": "9.0.1",
- "Microsoft.Extensions.Configuration.UserSecrets": "9.0.1",
- "Microsoft.Extensions.DependencyInjection": "9.0.1",
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
- "Microsoft.Extensions.Diagnostics": "9.0.1",
- "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1",
- "Microsoft.Extensions.FileProviders.Physical": "9.0.1",
- "Microsoft.Extensions.Hosting.Abstractions": "9.0.1",
- "Microsoft.Extensions.Logging": "9.0.1",
- "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
- "Microsoft.Extensions.Logging.Configuration": "9.0.1",
- "Microsoft.Extensions.Logging.Console": "9.0.1",
- "Microsoft.Extensions.Logging.Debug": "9.0.1",
- "Microsoft.Extensions.Logging.EventLog": "9.0.1",
- "Microsoft.Extensions.Logging.EventSource": "9.0.1",
- "Microsoft.Extensions.Options": "9.0.1"
+ "requested": "[9.0.4, )",
+ "resolved": "9.0.4",
+ "contentHash": "1rZwLE+tTUIyZRUzmlk/DQj+v+Eqox+rjb+X7Fi+cYTbQfIZPYwpf1pVybsV3oje8+Pe4GaNukpBVUlPYeQdeQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "9.0.4",
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Configuration.Binder": "9.0.4",
+ "Microsoft.Extensions.Configuration.CommandLine": "9.0.4",
+ "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.4",
+ "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4",
+ "Microsoft.Extensions.Configuration.Json": "9.0.4",
+ "Microsoft.Extensions.Configuration.UserSecrets": "9.0.4",
+ "Microsoft.Extensions.DependencyInjection": "9.0.4",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Diagnostics": "9.0.4",
+ "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4",
+ "Microsoft.Extensions.FileProviders.Physical": "9.0.4",
+ "Microsoft.Extensions.Hosting.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Logging": "9.0.4",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Logging.Configuration": "9.0.4",
+ "Microsoft.Extensions.Logging.Console": "9.0.4",
+ "Microsoft.Extensions.Logging.Debug": "9.0.4",
+ "Microsoft.Extensions.Logging.EventLog": "9.0.4",
+ "Microsoft.Extensions.Logging.EventSource": "9.0.4",
+ "Microsoft.Extensions.Options": "9.0.4"
}
},
"Microsoft.Extensions.Options": {
"type": "Direct",
- "requested": "[9.0.1, )",
- "resolved": "9.0.1",
- "contentHash": "nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==",
+ "requested": "[9.0.4, )",
+ "resolved": "9.0.4",
+ "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
- "Microsoft.Extensions.Primitives": "9.0.1"
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Primitives": "9.0.4"
}
},
"Microsoft.WindowsAppSDK": {
@@ -85,6 +95,39 @@
"Microsoft.Windows.SDK.BuildTools": "10.0.22621.756"
}
},
+ "Serilog.Extensions.Hosting": {
+ "type": "Direct",
+ "requested": "[9.0.0, )",
+ "resolved": "9.0.0",
+ "contentHash": "u2TRxuxbjvTAldQn7uaAwePkWxTHIqlgjelekBtilAGL5sYyF3+65NWctN4UrwwGLsDC7c3Vz3HnOlu+PcoxXg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
+ "Microsoft.Extensions.Hosting.Abstractions": "9.0.0",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.0",
+ "Serilog": "4.2.0",
+ "Serilog.Extensions.Logging": "9.0.0"
+ }
+ },
+ "Serilog.Settings.Configuration": {
+ "type": "Direct",
+ "requested": "[9.0.0, )",
+ "resolved": "9.0.0",
+ "contentHash": "4/Et4Cqwa+F88l5SeFeNZ4c4Z6dEAIKbu3MaQb2Zz9F/g27T5a3wvfMcmCOaAiACjfUb4A6wrlTVfyYUZk3RRQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Binder": "9.0.0",
+ "Microsoft.Extensions.DependencyModel": "9.0.0",
+ "Serilog": "4.2.0"
+ }
+ },
+ "Serilog.Sinks.File": {
+ "type": "Direct",
+ "requested": "[6.0.0, )",
+ "resolved": "6.0.0",
+ "contentHash": "lxjg89Y8gJMmFxVkbZ+qDgjl+T4yC5F7WSLTvA+5q0R04tfKVLRL/EHpYoJ/MEQd2EeCKDuylBIVnAYMotmh2A==",
+ "dependencies": {
+ "Serilog": "4.0.0"
+ }
+ },
"WinUIEx": {
"type": "Direct",
"requested": "[2.5.1, )",
@@ -94,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",
@@ -139,240 +196,249 @@
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==",
+ "resolved": "9.0.4",
+ "contentHash": "KIVBrMbItnCJDd1RF4KEaE8jZwDJcDUJW5zXpbwQ05HNYTK1GveHxHK0B3SjgDJuR48GRACXAO+BLhL8h34S7g==",
"dependencies": {
- "Microsoft.Extensions.Configuration.Abstractions": "9.0.1",
- "Microsoft.Extensions.Primitives": "9.0.1"
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Primitives": "9.0.4"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==",
+ "resolved": "9.0.4",
+ "contentHash": "0LN/DiIKvBrkqp7gkF3qhGIeZk6/B63PthAHjQsxymJfIBcz0kbf4/p/t4lMgggVxZ+flRi5xvTwlpPOoZk8fg==",
"dependencies": {
- "Microsoft.Extensions.Primitives": "9.0.1"
+ "Microsoft.Extensions.Primitives": "9.0.4"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "w7kAyu1Mm7eParRV6WvGNNwA8flPTub16fwH49h7b/yqJZFTgYxnOVCuiah3G2bgseJMEq4DLjjsyQRvsdzRgA==",
+ "resolved": "9.0.4",
+ "contentHash": "cdrjcl9RIcwt3ECbnpP0Gt1+pkjdW90mq5yFYy8D9qRj2NqFFcv3yDp141iEamsd9E218sGxK8WHaIOcrqgDJg==",
"dependencies": {
- "Microsoft.Extensions.Configuration.Abstractions": "9.0.1"
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4"
}
},
"Microsoft.Extensions.Configuration.CommandLine": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "5WC1OsXfljC1KHEyL0yefpAyt1UZjrZ0/xyOqFowc5VntbE79JpCYOTSYFlxEuXm3Oq5xsgU2YXeZLTgAAX+DA==",
+ "resolved": "9.0.4",
+ "contentHash": "TbM2HElARG7z1gxwakdppmOkm1SykPqDcu3EF97daEwSb/+TXnRrFfJtF+5FWWxcsNhbRrmLfS2WszYcab7u1A==",
"dependencies": {
- "Microsoft.Extensions.Configuration": "9.0.1",
- "Microsoft.Extensions.Configuration.Abstractions": "9.0.1"
+ "Microsoft.Extensions.Configuration": "9.0.4",
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4"
}
},
"Microsoft.Extensions.Configuration.EnvironmentVariables": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "5HShUdF8KFAUSzoEu0DOFbX09FlcFtHxEalowyjM7Kji0EjdF0DLjHajb2IBvoqsExAYox+Z2GfbfGF7dH7lKQ==",
+ "resolved": "9.0.4",
+ "contentHash": "2IGiG3FtVnD83IA6HYGuNei8dOw455C09yEhGl8bjcY6aGZgoC6yhYvDnozw8wlTowfoG9bxVrdTsr2ACZOYHg==",
"dependencies": {
- "Microsoft.Extensions.Configuration": "9.0.1",
- "Microsoft.Extensions.Configuration.Abstractions": "9.0.1"
+ "Microsoft.Extensions.Configuration": "9.0.4",
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "QBOI8YVAyKqeshYOyxSe6co22oag431vxMu5xQe1EjXMkYE4xK4J71xLCW3/bWKmr9Aoy1VqGUARSLFnotk4Bg==",
+ "resolved": "9.0.4",
+ "contentHash": "UY864WQ3AS2Fkc8fYLombWnjrXwYt+BEHHps0hY4sxlgqaVW06AxbpgRZjfYf8PyRbplJqruzZDB/nSLT+7RLQ==",
"dependencies": {
- "Microsoft.Extensions.Configuration": "9.0.1",
- "Microsoft.Extensions.Configuration.Abstractions": "9.0.1",
- "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1",
- "Microsoft.Extensions.FileProviders.Physical": "9.0.1",
- "Microsoft.Extensions.Primitives": "9.0.1"
+ "Microsoft.Extensions.Configuration": "9.0.4",
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4",
+ "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4",
+ "Microsoft.Extensions.FileProviders.Physical": "9.0.4",
+ "Microsoft.Extensions.Primitives": "9.0.4"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "z+g+lgPET1JRDjsOkFe51rkkNcnJgvOK5UIpeTfF1iAi0GkBJz5/yUuTa8a9V8HUh4gj4xFT5WGoMoXoSDKfGg==",
+ "resolved": "9.0.4",
+ "contentHash": "vVXI70CgT/dmXV3MM+n/BR2rLXEoAyoK0hQT+8MrbCMuJBiLRxnTtSrksNiASWCwOtxo/Tyy7CO8AGthbsYxnw==",
"dependencies": {
- "Microsoft.Extensions.Configuration": "9.0.1",
- "Microsoft.Extensions.Configuration.Abstractions": "9.0.1",
- "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1",
- "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1",
- "System.Text.Json": "9.0.1"
+ "Microsoft.Extensions.Configuration": "9.0.4",
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4",
+ "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4",
+ "System.Text.Json": "9.0.4"
}
},
"Microsoft.Extensions.Configuration.UserSecrets": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "esGPOgLZ1tZddEomexhrU+LJ5YIsuJdkh0tU7r4WVpNZ15dLuMPqPW4Xe4txf3T2PDUX2ILe3nYQEDjZjfSEJg==",
+ "resolved": "9.0.4",
+ "contentHash": "zuvyC72gJkJyodyGowCuz3EQ1QvzNXJtKusuRzmjoHr17aeB3X0aSiKFB++HMHnQIWWlPOBf9YHTQfEqzbgl1g==",
"dependencies": {
- "Microsoft.Extensions.Configuration.Abstractions": "9.0.1",
- "Microsoft.Extensions.Configuration.Json": "9.0.1",
- "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1",
- "Microsoft.Extensions.FileProviders.Physical": "9.0.1"
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Configuration.Json": "9.0.4",
+ "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4",
+ "Microsoft.Extensions.FileProviders.Physical": "9.0.4"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA=="
+ "resolved": "9.0.4",
+ "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg=="
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA==",
+ "dependencies": {
+ "System.Text.Encodings.Web": "9.0.0",
+ "System.Text.Json": "9.0.0"
+ }
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "4ZmP6turxMFsNwK/MCko2fuIITaYYN/eXyyIRq1FjLDKnptdbn6xMb7u0zfSMzCGpzkx4RxH/g1jKN2IchG7uA==",
+ "resolved": "9.0.4",
+ "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==",
"dependencies": {
- "Microsoft.Extensions.Configuration": "9.0.1",
- "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1",
- "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1"
+ "Microsoft.Extensions.Configuration": "9.0.4",
+ "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "pfAPuVtHvG6dvZtAa0OQbXdDqq6epnr8z0/IIUjdmV0tMeI8Aj9KxDXvdDvqr+qNHTkmA7pZpChNxwNZt4GXVg==",
+ "resolved": "9.0.4",
+ "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
- "Microsoft.Extensions.Options": "9.0.1",
- "System.Diagnostics.DiagnosticSource": "9.0.1"
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Options": "9.0.4",
+ "System.Diagnostics.DiagnosticSource": "9.0.4"
}
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "DguZYt1DWL05+8QKWL3b6bW7A2pC5kYFMY5iXM6W2M23jhvcNa8v6AU8PvVJBcysxHwr9/jax0agnwoBumsSwg==",
+ "resolved": "9.0.4",
+ "contentHash": "gQN2o/KnBfVk6Bd71E2YsvO5lsqrqHmaepDGk+FB/C4aiQY9B0XKKNKfl5/TqcNOs9OEithm4opiMHAErMFyEw==",
"dependencies": {
- "Microsoft.Extensions.Primitives": "9.0.1"
+ "Microsoft.Extensions.Primitives": "9.0.4"
}
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "TKDMNRS66UTMEVT38/tU9hA63UTMvzI3DyNm5mx8+JCf3BaOtxgrvWLCI1y3J52PzT5yNl/T2KN5Z0KbApLZcg==",
+ "resolved": "9.0.4",
+ "contentHash": "qkQ9V7KFZdTWNThT7ke7E/Jad38s46atSs3QUYZB8f3thBTrcrousdY4Y/tyCtcH5YjsPSiByjuN+L8W/ThMQg==",
"dependencies": {
- "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1",
- "Microsoft.Extensions.FileSystemGlobbing": "9.0.1",
- "Microsoft.Extensions.Primitives": "9.0.1"
+ "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4",
+ "Microsoft.Extensions.FileSystemGlobbing": "9.0.4",
+ "Microsoft.Extensions.Primitives": "9.0.4"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "Mxcp9NXuQMvAnudRZcgIb5SqlWrlullQzntBLTwuv0MPIJ5LqiGwbRqiyxgdk+vtCoUkplb0oXy5kAw1t469Ug=="
+ "resolved": "9.0.4",
+ "contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ=="
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "CwSMhLNe8HLkfbFzdz0CHWJhtWH3TtfZSicLBd/itFD+NqQtfGHmvqXHQbaFFl3mQB5PBb2gxwzWQyW2pIj7PA==",
+ "resolved": "9.0.4",
+ "contentHash": "bXkwRPMo4x19YKH6/V9XotU7KYQJlihXhcWO1RDclAY3yfY3XNg4QtSEBvng4kK/DnboE0O/nwSl+6Jiv9P+FA==",
"dependencies": {
- "Microsoft.Extensions.Configuration.Abstractions": "9.0.1",
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
- "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1",
- "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1",
- "Microsoft.Extensions.Logging.Abstractions": "9.0.1"
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4",
+ "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.4"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "E/k5r7S44DOW+08xQPnNbO8DKAQHhkspDboTThNJ6Z3/QBb4LC6gStNWzVmy3IvW7sUD+iJKf4fj0xEkqE7vnQ==",
+ "resolved": "9.0.4",
+ "contentHash": "xW6QPYsqhbuWBO9/1oA43g/XPKbohJx+7G8FLQgQXIriYvY7s+gxr2wjQJfRoPO900dvvv2vVH7wZovG+M1m6w==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection": "9.0.1",
- "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
- "Microsoft.Extensions.Options": "9.0.1"
+ "Microsoft.Extensions.DependencyInjection": "9.0.4",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Options": "9.0.4"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "w2gUqXN/jNIuvqYwX3lbXagsizVNXYyt6LlF57+tMve4JYCEgCMMAjRce6uKcDASJgpMbErRT1PfHy2OhbkqEA==",
+ "resolved": "9.0.4",
+ "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
- "System.Diagnostics.DiagnosticSource": "9.0.1"
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "System.Diagnostics.DiagnosticSource": "9.0.4"
}
},
"Microsoft.Extensions.Logging.Configuration": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "MeZePlyu3/74Wk4FHYSzXijADJUhWa7gxtaphLxhS8zEPWdJuBCrPo0sezdCSZaKCL+cZLSLobrb7xt2zHOxZQ==",
+ "resolved": "9.0.4",
+ "contentHash": "/kF+rSnoo3/nIwGzWsR4RgBnoTOdZ3lzz2qFRyp/GgaNid4j6hOAQrs/O+QHXhlcAdZxjg37MvtIE+pAvIgi9g==",
"dependencies": {
- "Microsoft.Extensions.Configuration": "9.0.1",
- "Microsoft.Extensions.Configuration.Abstractions": "9.0.1",
- "Microsoft.Extensions.Configuration.Binder": "9.0.1",
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
- "Microsoft.Extensions.Logging": "9.0.1",
- "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
- "Microsoft.Extensions.Options": "9.0.1",
- "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1"
+ "Microsoft.Extensions.Configuration": "9.0.4",
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Configuration.Binder": "9.0.4",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Logging": "9.0.4",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Options": "9.0.4",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4"
}
},
"Microsoft.Extensions.Logging.Console": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "YUzguHYlWfp4upfYlpVe3dnY59P25wc+/YLJ9/NQcblT3EvAB1CObQulClll7NtnFbbx4Js0a0UfyS8SbRsWXQ==",
+ "resolved": "9.0.4",
+ "contentHash": "cI0lQe0js65INCTCtAgnlVJWKgzgoRHVAW1B1zwCbmcliO4IZoTf92f1SYbLeLk7FzMJ/GlCvjLvJegJ6kltmQ==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
- "Microsoft.Extensions.Logging": "9.0.1",
- "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
- "Microsoft.Extensions.Logging.Configuration": "9.0.1",
- "Microsoft.Extensions.Options": "9.0.1",
- "System.Text.Json": "9.0.1"
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Logging": "9.0.4",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Logging.Configuration": "9.0.4",
+ "Microsoft.Extensions.Options": "9.0.4",
+ "System.Text.Json": "9.0.4"
}
},
"Microsoft.Extensions.Logging.Debug": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "pzdyibIV8k4sym0Sszcp2MJCuXrpOGs9qfOvY+hCRu8k4HbdVoeKOLnacxHK6vEPITX5o5FjjsZW2zScLXTjYA==",
+ "resolved": "9.0.4",
+ "contentHash": "D1jy+jy+huUUxnkZ0H480RZK8vqKn8NsQxYpMpPL/ALPPh1WATVLcr/uXI3RUBB45wMW5265O+hk9x3jnnXFuA==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
- "Microsoft.Extensions.Logging": "9.0.1",
- "Microsoft.Extensions.Logging.Abstractions": "9.0.1"
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Logging": "9.0.4",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.4"
}
},
"Microsoft.Extensions.Logging.EventLog": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "+a4RlbwFWjsMujNNhf1Jy9Nm5CpMT+nxXxfgrkRSloPo0OAWhPSPsrFo6VWpvgIPPS41qmfAVWr3DqAmOoVZgQ==",
+ "resolved": "9.0.4",
+ "contentHash": "bApxdklf7QTsONOLR5ow6SdDFXR5ncHvumSEg2+QnCvxvkzc2z5kNn7yQCyupRLRN4jKbnlTkVX8x9qLlwL6Qg==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
- "Microsoft.Extensions.Logging": "9.0.1",
- "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
- "Microsoft.Extensions.Options": "9.0.1",
- "System.Diagnostics.EventLog": "9.0.1"
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Logging": "9.0.4",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Options": "9.0.4",
+ "System.Diagnostics.EventLog": "9.0.4"
}
},
"Microsoft.Extensions.Logging.EventSource": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "d47ZRZUOg1dGOX+yisWScQ7w4+92OlR9beS2UXaiadUCA3RFoZzobzVgrzBX7Oo/qefx9LxdRcaeFpWKb3BNBw==",
+ "resolved": "9.0.4",
+ "contentHash": "R600zTxVJNw2IeAEOvdOJGNA1lHr1m3vo460hSF5G1DjwP0FNpyeH4lpLDMuf34diKwB1LTt5hBw1iF1/iuwsQ==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
- "Microsoft.Extensions.Logging": "9.0.1",
- "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
- "Microsoft.Extensions.Options": "9.0.1",
- "Microsoft.Extensions.Primitives": "9.0.1",
- "System.Text.Json": "9.0.1"
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Logging": "9.0.4",
+ "Microsoft.Extensions.Logging.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Options": "9.0.4",
+ "Microsoft.Extensions.Primitives": "9.0.4",
+ "System.Text.Json": "9.0.4"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "8RRKWtuU4fR+8MQLR/8CqZwZ9yc2xCpllw/WPRY7kskIqEq0hMcEI4AfUJO72yGiK2QJkrsDcUvgB5Yc+3+lyg==",
+ "resolved": "9.0.4",
+ "contentHash": "aridVhAT3Ep+vsirR1pzjaOw0Jwiob6dc73VFQn2XmDfBA2X98M8YKO1GarvsXRX7gX1Aj+hj2ijMzrMHDOm0A==",
"dependencies": {
- "Microsoft.Extensions.Configuration.Abstractions": "9.0.1",
- "Microsoft.Extensions.Configuration.Binder": "9.0.1",
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
- "Microsoft.Extensions.Options": "9.0.1",
- "Microsoft.Extensions.Primitives": "9.0.1"
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Configuration.Binder": "9.0.4",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Options": "9.0.4",
+ "Microsoft.Extensions.Primitives": "9.0.4"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g=="
+ "resolved": "9.0.4",
+ "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA=="
},
"Microsoft.Web.WebView2": {
"type": "Transitive",
@@ -397,6 +463,20 @@
"Microsoft.Extensions.Primitives": "5.0.1"
}
},
+ "Serilog": {
+ "type": "Transitive",
+ "resolved": "4.2.0",
+ "contentHash": "gmoWVOvKgbME8TYR+gwMf7osROiWAURterc6Rt2dQyX7wtjZYpqFiA/pY6ztjGQKKV62GGCyOcmtP1UKMHgSmA=="
+ },
+ "Serilog.Extensions.Logging": {
+ "type": "Transitive",
+ "resolved": "9.0.0",
+ "contentHash": "NwSSYqPJeKNzl5AuXVHpGbr6PkZJFlNa14CdIebVjK3k/76kYj/mz5kiTRNVSsSaxM8kAIa1kpy/qyT9E4npRQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging": "9.0.0",
+ "Serilog": "4.2.0"
+ }
+ },
"System.Collections.Immutable": {
"type": "Transitive",
"resolved": "9.0.0",
@@ -404,13 +484,13 @@
},
"System.Diagnostics.DiagnosticSource": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "yOcDWx4P/s1I83+7gQlgQLmhny2eNcU0cfo1NBWi+en4EAI38Jau+/neT85gUW6w1s7+FUJc2qNOmmwGLIREqA=="
+ "resolved": "9.0.4",
+ "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ=="
+ "resolved": "9.0.4",
+ "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ=="
},
"System.Drawing.Common": {
"type": "Transitive",
@@ -422,8 +502,8 @@
},
"System.IO.Pipelines": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg=="
+ "resolved": "9.0.4",
+ "contentHash": "luF2Xba+lTe2GOoNQdZLe8q7K6s7nSpWZl9jIwWNMszN4/Yv0lmxk9HISgMmwdyZ83i3UhAGXaSY9o6IJBUuuA=="
},
"System.Reflection.Metadata": {
"type": "Transitive",
@@ -435,16 +515,16 @@
},
"System.Text.Encodings.Web": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg=="
+ "resolved": "9.0.4",
+ "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg=="
},
"System.Text.Json": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "eqWHDZqYPv1PvuvoIIx5pF74plL3iEOZOl/0kQP+Y0TEbtgNnM2W6k8h8EPYs+LTJZsXuWa92n5W5sHTWvE3VA==",
+ "resolved": "9.0.4",
+ "contentHash": "pYtmpcO6R3Ef1XilZEHgXP2xBPVORbYEzRP7dl0IAAbN8Dm+kfwio8aCKle97rAWXOExr292MuxWYurIuwN62g==",
"dependencies": {
- "System.IO.Pipelines": "9.0.1",
- "System.Text.Encodings.Web": "9.0.1"
+ "System.IO.Pipelines": "9.0.4",
+ "System.Text.Encodings.Web": "9.0.4"
}
},
"Coder.Desktop.CoderSdk": {
@@ -496,13 +576,13 @@
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ=="
+ "resolved": "9.0.4",
+ "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg=="
+ "resolved": "9.0.4",
+ "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg=="
}
},
"net8.0-windows10.0.19041/win-x64": {
@@ -528,13 +608,13 @@
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ=="
+ "resolved": "9.0.4",
+ "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg=="
+ "resolved": "9.0.4",
+ "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg=="
}
},
"net8.0-windows10.0.19041/win-x86": {
@@ -560,13 +640,13 @@
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ=="
+ "resolved": "9.0.4",
+ "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
- "resolved": "9.0.1",
- "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg=="
+ "resolved": "9.0.4",
+ "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg=="
}
}
}
diff --git a/CoderSdk/Agent/AgentApiClient.cs b/CoderSdk/Agent/AgentApiClient.cs
new file mode 100644
index 0000000..27eaea3
--- /dev/null
+++ b/CoderSdk/Agent/AgentApiClient.cs
@@ -0,0 +1,61 @@
+using System.Text.Json.Serialization;
+
+namespace Coder.Desktop.CoderSdk.Agent;
+
+public interface IAgentApiClientFactory
+{
+ public IAgentApiClient Create(string hostname);
+}
+
+public class AgentApiClientFactory : IAgentApiClientFactory
+{
+ public IAgentApiClient Create(string hostname)
+ {
+ return new AgentApiClient(hostname);
+ }
+}
+
+public partial interface IAgentApiClient
+{
+}
+
+[JsonSerializable(typeof(ListDirectoryRequest))]
+[JsonSerializable(typeof(ListDirectoryResponse))]
+[JsonSerializable(typeof(Response))]
+public partial class AgentApiJsonContext : JsonSerializerContext;
+
+public partial class AgentApiClient : IAgentApiClient
+{
+ private const int AgentApiPort = 4;
+
+ private readonly JsonHttpClient _httpClient;
+
+ public AgentApiClient(string hostname) : this(new UriBuilder
+ {
+ Scheme = "http",
+ Host = hostname,
+ Port = AgentApiPort,
+ Path = "/",
+ }.Uri)
+ {
+ }
+
+ public AgentApiClient(Uri baseUrl)
+ {
+ if (baseUrl.PathAndQuery != "/")
+ throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl));
+ _httpClient = new JsonHttpClient(baseUrl, AgentApiJsonContext.Default);
+ }
+
+ private async Task SendRequestNoBodyAsync(HttpMethod method, string path,
+ CancellationToken ct = default)
+ {
+ return await SendRequestAsync