diff --git a/csharp/ExcelAddIn/DeephavenExcelFunctions.cs b/csharp/ExcelAddIn/DeephavenExcelFunctions.cs
new file mode 100644
index 00000000000..d80d6627c85
--- /dev/null
+++ b/csharp/ExcelAddIn/DeephavenExcelFunctions.cs
@@ -0,0 +1,67 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using Deephaven.ExcelAddIn.ExcelDna;
+using Deephaven.ExcelAddIn.Factories;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Operations;
+using Deephaven.ExcelAddIn.Providers;
+using Deephaven.ExcelAddIn.Viewmodels;
+using Deephaven.ExcelAddIn.Views;
+using ExcelDna.Integration;
+
+namespace Deephaven.ExcelAddIn;
+
+public static class DeephavenExcelFunctions {
+ private static readonly StateManager StateManager = new();
+
+ [ExcelCommand(MenuName = "Deephaven", MenuText = "Connections")]
+ public static void ShowConnectionsDialog() {
+ ConnectionManagerDialogFactory.CreateAndShow(StateManager);
+ }
+
+ [ExcelFunction(Description = "Snapshots a table", IsThreadSafe = true)]
+ public static object DEEPHAVEN_SNAPSHOT(string tableDescriptor, object filter, object wantHeaders) {
+ if (!TryInterpretCommonArgs(tableDescriptor, filter, wantHeaders, out var td, out var filt, out var wh, out var errorText)) {
+ return errorText;
+ }
+
+ // These two are used by ExcelDNA to share results for identical invocations. The functionName is arbitary but unique.
+ const string functionName = "Deephaven.ExcelAddIn.DeephavenExcelFunctions.DEEPHAVEN_SNAPSHOT";
+ var parms = new[] { tableDescriptor, filter, wantHeaders };
+ ExcelObservableSource eos = () => new SnapshotOperation(td!, filt, wh, StateManager);
+ return ExcelAsyncUtil.Observe(functionName, parms, eos);
+ }
+
+ [ExcelFunction(Description = "Subscribes to a table", IsThreadSafe = true)]
+ public static object DEEPHAVEN_SUBSCRIBE(string tableDescriptor, object filter, object wantHeaders) {
+ if (!TryInterpretCommonArgs(tableDescriptor, filter, wantHeaders, out var td, out var filt, out var wh, out string errorText)) {
+ return errorText;
+ }
+ // These two are used by ExcelDNA to share results for identical invocations. The functionName is arbitary but unique.
+ const string functionName = "Deephaven.ExcelAddIn.DeephavenExcelFunctions.DEEPHAVEN_SUBSCRIBE";
+ var parms = new[] { tableDescriptor, filter, wantHeaders };
+ ExcelObservableSource eos = () => new SubscribeOperation(td, filt, wh, StateManager);
+ return ExcelAsyncUtil.Observe(functionName, parms, eos);
+ }
+
+ private static bool TryInterpretCommonArgs(string tableDescriptor, object filter, object wantHeaders,
+ [NotNullWhen(true)]out TableTriple? tableDescriptorResult, out string filterResult, out bool wantHeadersResult, out string errorText) {
+ filterResult = "";
+ wantHeadersResult = false;
+ if (!TableTriple.TryParse(tableDescriptor, out tableDescriptorResult, out errorText)) {
+ return false;
+ }
+
+ if (!ExcelDnaHelpers.TryInterpretAs(filter, "", out filterResult)) {
+ errorText = "Can't interpret FILTER argument";
+ return false;
+ }
+
+
+ if (!ExcelDnaHelpers.TryInterpretAs(wantHeaders, false, out wantHeadersResult)) {
+ errorText = "Can't interpret WANT_HEADERS argument";
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/csharp/ExcelAddIn/ExcelAddIn.csproj b/csharp/ExcelAddIn/ExcelAddIn.csproj
new file mode 100644
index 00000000000..4858c64169e
--- /dev/null
+++ b/csharp/ExcelAddIn/ExcelAddIn.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0-windows
+ enable
+ enable
+ true
+ True
+
+
+
+ True
+
+
+
+ True
+
+
+
+
+
+
+
+
diff --git a/csharp/ExcelAddIn/ExcelAddIn.csproj.user b/csharp/ExcelAddIn/ExcelAddIn.csproj.user
new file mode 100644
index 00000000000..f39927bb679
--- /dev/null
+++ b/csharp/ExcelAddIn/ExcelAddIn.csproj.user
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Form
+
+
+ Form
+
+
+
\ No newline at end of file
diff --git a/csharp/ExcelAddIn/ExcelAddIn.sln b/csharp/ExcelAddIn/ExcelAddIn.sln
new file mode 100644
index 00000000000..1b006efe92c
--- /dev/null
+++ b/csharp/ExcelAddIn/ExcelAddIn.sln
@@ -0,0 +1,31 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.7.34221.43
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExcelAddIn", "ExcelAddIn.csproj", "{08852A0D-DB96-404E-B3CE-BF30F2AD3F74}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DeephavenClient", "..\client\DeephavenClient\DeephavenClient.csproj", "{6848407D-1CEF-4433-92F4-6047AE3D2C52}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {08852A0D-DB96-404E-B3CE-BF30F2AD3F74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {08852A0D-DB96-404E-B3CE-BF30F2AD3F74}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {08852A0D-DB96-404E-B3CE-BF30F2AD3F74}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {08852A0D-DB96-404E-B3CE-BF30F2AD3F74}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6848407D-1CEF-4433-92F4-6047AE3D2C52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6848407D-1CEF-4433-92F4-6047AE3D2C52}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6848407D-1CEF-4433-92F4-6047AE3D2C52}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6848407D-1CEF-4433-92F4-6047AE3D2C52}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {A22A4DB3-DD84-46EB-96A6-7935E9E59356}
+ EndGlobalSection
+EndGlobal
diff --git a/csharp/ExcelAddIn/Properties/launchSettings.json b/csharp/ExcelAddIn/Properties/launchSettings.json
new file mode 100644
index 00000000000..5a0aac58161
--- /dev/null
+++ b/csharp/ExcelAddIn/Properties/launchSettings.json
@@ -0,0 +1,9 @@
+{
+ "profiles": {
+ "Excel": {
+ "commandName": "Executable",
+ "executablePath": "C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE",
+ "commandLineArgs": "/x \"ExcelAddIn-AddIn64.xll\""
+ }
+ }
+}
\ No newline at end of file
diff --git a/csharp/ExcelAddIn/README.md b/csharp/ExcelAddIn/README.md
new file mode 100644
index 00000000000..9b957b00615
--- /dev/null
+++ b/csharp/ExcelAddIn/README.md
@@ -0,0 +1,200 @@
+# Building the Excel Add-In on Windows 10 / 11.
+
+These instructions show how to install and run the Deephaven Excel Add-In
+on Windows. These instructions also happen to build the Deephaven C# Client as a
+side-effect. However if your goal is to build the Deephaven C# Client,
+please see [repository root]/csharp/client/README.md (does not exist yet).
+
+We have tested these instructions on Windows 10 and 11 with Visual Studio
+Community Edition.
+
+# Before using the Excel Add-In
+
+To actually use the Deephaven Excel Add-In, you will eventually need to have
+at least one Community Core or Enterprise Core+ server running. You don't need
+the server yet, and you can successfully follow these build instructions
+without a server. However, you will eventually need a server when you want to
+run it.
+
+If you don't have a Deephaven Community Core server installation,
+you can use these instructions to build one.
+https://deephaven.io/core/docs/how-to-guides/launch-build/
+
+Note that it is only possible to build a server on Linux. Building a server
+on Windows is not currently supported.
+
+For Deephaven Enterprise Core+, contact your IT administrator.
+
+# Building the Excel Add-In on Windows 10 / Windows 11
+
+## Prerequisites
+
+## Build machine specifications
+
+In our experience following this instructions on a fresh Windows 11 VM
+required a total of 125G of disk space to install and build everything.
+We recommend a machine with at least 200G of free disk space in order to
+leave a comfortable margin.
+
+Also, building the dependencies with vcpkg is very resource-intensive.
+A machine that has more cores will be able to finish faster.
+We recommend at least 16 cores for the build process. Note that *running*
+the Excel Add-In does not require that many cores.
+
+## Tooling
+
+### Excel
+
+You will need a recent version of Excel installed. We recommend Office 21
+or Office 365. Note that the Add-In only works with locally-installed
+versions of Excel (i.e. software installed on your computer). It does not
+work with the browser-based web version of Excel.
+
+### .NET
+
+Install the .NET Core SDK, version 8.0.
+Look for the "Windows | x64" link
+[here](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
+
+### Visual Studio
+
+Install Visual Studio 2022 Community Edition (or Professional, or Enterprise)
+from [here](https://visualstudio.microsoft.com/downloads/)
+
+When the installer runs, select both workloads
+"Desktop development with C++" and ".NET desktop development".
+
+If Visual Studio is already installed, use Tools -> Get Tools and Features
+to add those workloads if necessary.
+
+### git
+
+Use your preferred version of git, or install Git from
+[here](https://git-scm.com/download/win)
+
+## C++ client
+
+The Deephaven Excel Add-In relies on the Deephaven C# Client, which in turn
+requires the Deephaven C++ Client (Community Core version). Furthermore, if
+you want to use Enterprise Core+ features, you also need the Deephaven C++
+Client for Enterprise Core+.
+
+The instructions below describe how to build these libraries.
+
+### Build the Deephaven C++ Client (Community Core version)
+
+Follow the instructions at [repository root]/cpp-client/README.md under the
+section, under "Building the C++ client on Windows 10 / Windows 11".
+
+When that process is done, you will have C++ client binaries in the
+directory you specified in your DHINSTALL environment variable.
+
+### (Optional) build the Deephaven C++ Client (Enterprise Core+ version)
+
+To access Enterprise features, build the Enterprise Core+ version as well.
+It will also store its binaries in the same DHINSTALL directory.
+
+(instructions TODO)
+
+## Build the Excel Add-In and C# Add-In
+
+You can build the Add-In from inside Visual Studio or from the Visual Studio
+Command Prompt.
+
+### From within Visual Studio
+
+1. Open the Visual Studio solution file
+[repository root]\csharp\ExcelAddIn\ExcelAddIn.sln
+
+2. Click on BUILD -> Build solution
+
+### From the Visual Studio Command Prompt
+
+```
+cd [repository root]\csharp\ExcelAddIn
+devenv ExcelAddIn.sln /build Release
+```
+
+## Run the Add-In
+
+### From within Visual Studio
+
+1. In order to actually function, the Add-In requires the C++ Client binaries
+ built in the above steps. The easiest thing to do is simply copy all the
+ binaries into your Visual Studio build directory:
+
+Assuming a Debug build:
+
+copy /Y %DHINSTALL%\bin [repository root]\csharp\ExcelAddIn\bin\Debug\net8.0-windows
+
+If you are doing a Release build, change "Debug" to "Release" in the above path.
+
+2. Inside Visual Studio Select Debug -> Start Debugging
+
+Visual Studio will launch Excel automatically. Excel will launch with a
+Security Notice because the add-in is not signed. Click "Enable this add-in
+for this session only."
+
+### From standalone Excel
+
+To install the add-in into Excel, we need put the relevant files into a
+directory and then point to that directory. For simplicity, we will use
+the already-established %DHINSTALL%\bin directory, which already has all the
+relevant files except for the add-in's XLL file.
+
+```
+copy [repository root]\csharp\ExcelAddIn\bin\Debug\net8.0-windows\publish\ExcelAddIn-Addin64-packed.xll %DHINSTALL%\bin
+```
+
+Note the above file comes from the "publish" subdirectory.
+
+Then, run Excel and follow the following steps.
+
+1. Click on "Options" at the lower left of the window.
+2. Click on "Add-ins" on the left, second from the bottom.
+3. At the bottom of the screen click, near "Manage", select "Excel Add-ins"
+ from the pulldown, and then click "Go..."
+4. In the next screen click "Browse..."
+5. Navigate to your %DHINSTALL%\bin directory and click on the ExcelAddIn-Addin64-packed.xll file that you recently copied there
+6. Click OK
+7. Click OK
+
+
+## Test the add-in
+
+### Without connecting to a Deephaven server
+
+1. In Excel, click on Add-ins -> Deephaven -> Connections. This should bring
+ up a Connections form. If so, the C# code is functioning correctly.
+
+2. In the following steps we deliberately use nonsense connection settings
+ in order to quickly trigger an error. Even thought he connection settings
+ are nonsense, getting an error quickly confirms that the functionality
+ of the C++ code is working.
+
+3. Inside Connections, click the "New Connection" button. A "Credentials
+ Editor" window will pop up. Inside this window, enter "con1" for the
+ Connection ID, select the "Community Core" button, and enter a nonsense
+ endpoint address like "abc.def"
+
+4. Press Test Credentials. You should immediately see an error like
+ "Can't get configuration constants. Error 14: DNS resolution failed for
+ abc.def"
+
+5. Enterprise users can do a similar test by selecting the Enterprise Core+
+ button. Putting a nonsense protocol like abc://def in the JSON URl field
+ will quickly lead to an error.
+
+### By connecting to a Deephaven server
+
+If the above tests pass, the add-in is probably installed correctly.
+
+To test the add-in with a Deephaven server, you will need the following
+information.
+
+1. For Community Core, you will need a Connection string in the form of
+ address:port. For example, 10.0.1.50:10000
+
+2. For Enterprise Core+, you will need a JSON URL that references your
+ Core+ installation. For example
+ https://10.0.1.50:8123/iris/connection.json
diff --git a/csharp/ExcelAddIn/StateManager.cs b/csharp/ExcelAddIn/StateManager.cs
new file mode 100644
index 00000000000..c1b849b7b34
--- /dev/null
+++ b/csharp/ExcelAddIn/StateManager.cs
@@ -0,0 +1,78 @@
+using Deephaven.DeephavenClient.ExcelAddIn.Util;
+using Deephaven.DeephavenClient;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Providers;
+using Deephaven.ExcelAddIn.Util;
+
+namespace Deephaven.ExcelAddIn;
+
+public class StateManager {
+ public readonly WorkerThread WorkerThread = WorkerThread.Create();
+ private readonly SessionProviders _sessionProviders;
+
+ public StateManager() {
+ _sessionProviders = new SessionProviders(WorkerThread);
+ }
+
+ public IDisposable SubscribeToSessions(IObserver> observer) {
+ return _sessionProviders.Subscribe(observer);
+ }
+
+ public IDisposable SubscribeToSession(EndpointId endpointId, IObserver> observer) {
+ return _sessionProviders.SubscribeToSession(endpointId, observer);
+ }
+
+ public IDisposable SubscribeToCredentials(EndpointId endpointId, IObserver> observer) {
+ return _sessionProviders.SubscribeToCredentials(endpointId, observer);
+ }
+
+ public IDisposable SubscribeToDefaultSession(IObserver> observer) {
+ return _sessionProviders.SubscribeToDefaultSession(observer);
+ }
+
+ public IDisposable SubscribeToDefaultCredentials(IObserver> observer) {
+ return _sessionProviders.SubscribeToDefaultCredentials(observer);
+ }
+
+ public IDisposable SubscribeToTableTriple(TableTriple descriptor, string filter,
+ IObserver> observer) {
+ // There is a chain with three elements.
+ // The final observer (i.e. the argument to this method) will be a subscriber to a TableHandleProvider that we create here.
+ // That TableHandleProvider will in turn be a subscriber to a session.
+
+ // So:
+ // 1. Make a TableHandleProvider
+ // 2. Subscribe it to either the session provider named by the endpoint id
+ // or to the default session provider
+ // 3. Subscribe our observer to it
+ // 4. Return a dispose action that disposes both Subscribes
+
+ var thp = new TableHandleProvider(WorkerThread, descriptor, filter);
+ var disposer1 = descriptor.EndpointId == null ?
+ SubscribeToDefaultSession(thp) :
+ SubscribeToSession(descriptor.EndpointId, thp);
+ var disposer2 = thp.Subscribe(observer);
+
+ // The disposer for this needs to dispose both "inner" disposers.
+ return ActionAsDisposable.Create(() => {
+ WorkerThread.Invoke(() => {
+ var temp1 = Utility.Exchange(ref disposer1, null);
+ var temp2 = Utility.Exchange(ref disposer2, null);
+ temp2?.Dispose();
+ temp1?.Dispose();
+ });
+ });
+ }
+
+ public void SetCredentials(CredentialsBase credentials) {
+ _sessionProviders.SetCredentials(credentials);
+ }
+
+ public void SetDefaultCredentials(CredentialsBase credentials) {
+ _sessionProviders.SetDefaultCredentials(credentials);
+ }
+
+ public void Reconnect(EndpointId id) {
+ _sessionProviders.Reconnect(id);
+ }
+}
diff --git a/csharp/ExcelAddIn/exceldna/ExcelDnaHelpers.cs b/csharp/ExcelAddIn/exceldna/ExcelDnaHelpers.cs
new file mode 100644
index 00000000000..4ceb44805a3
--- /dev/null
+++ b/csharp/ExcelAddIn/exceldna/ExcelDnaHelpers.cs
@@ -0,0 +1,42 @@
+using Deephaven.ExcelAddIn.Util;
+using ExcelDna.Integration;
+
+namespace Deephaven.ExcelAddIn.ExcelDna;
+
+internal class ExcelDnaHelpers {
+ public static bool TryInterpretAs(object value, T defaultValue, out T result) {
+ result = defaultValue;
+ if (value is ExcelMissing) {
+ return true;
+ }
+
+ if (value is T tValue) {
+ result = tValue;
+ return true;
+ }
+
+ return false;
+ }
+
+ public static IObserver> WrapExcelObserver(IExcelObserver inner) {
+ return new ExcelObserverWrapper(inner);
+ }
+
+ private class ExcelObserverWrapper(IExcelObserver inner) : IObserver> {
+ public void OnNext(StatusOr sov) {
+ if (!sov.GetValueOrStatus(out var value, out var status)) {
+ // Reformat the status text as an object[,] 2D array so Excel renders it as 1x1 "table".
+ value = new object[,] { { status } };
+ }
+ inner.OnNext(value);
+ }
+
+ public void OnCompleted() {
+ inner.OnCompleted();
+ }
+
+ public void OnError(Exception error) {
+ inner.OnError(error);
+ }
+ }
+}
diff --git a/csharp/ExcelAddIn/factories/ConnectionManagerDialogFactory.cs b/csharp/ExcelAddIn/factories/ConnectionManagerDialogFactory.cs
new file mode 100644
index 00000000000..cdf6053f327
--- /dev/null
+++ b/csharp/ExcelAddIn/factories/ConnectionManagerDialogFactory.cs
@@ -0,0 +1,102 @@
+using Deephaven.ExcelAddIn.Viewmodels;
+using Deephaven.ExcelAddIn.ViewModels;
+using Deephaven.ExcelAddIn.Views;
+using System.Diagnostics;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Util;
+
+namespace Deephaven.ExcelAddIn.Factories;
+
+internal static class ConnectionManagerDialogFactory {
+ public static void CreateAndShow(StateManager sm) {
+ // The "new" button creates a "New/Edit Credentials" dialog
+ void OnNewButtonClicked() {
+ var cvm = CredentialsDialogViewModel.OfEmpty();
+ var dialog = CredentialsDialogFactory.Create(sm, cvm);
+ dialog.Show();
+ }
+
+ var cmDialog = new ConnectionManagerDialog(OnNewButtonClicked);
+ cmDialog.Show();
+ var cmso = new ConnectionManagerSessionObserver(sm, cmDialog);
+ var disposer = sm.SubscribeToSessions(cmso);
+
+ cmDialog.Closed += (_, _) => {
+ disposer.Dispose();
+ cmso.Dispose();
+ };
+ }
+}
+
+internal class ConnectionManagerSessionObserver(
+ StateManager stateManager,
+ ConnectionManagerDialog cmDialog) : IObserver>, IDisposable {
+ private readonly List _disposables = new();
+
+ public void OnNext(AddOrRemove aor) {
+ if (!aor.IsAdd) {
+ // TODO(kosak)
+ Debug.WriteLine("Remove is not handled");
+ return;
+ }
+
+ var endpointId = aor.Value;
+
+ var statusRow = new ConnectionManagerDialogRow(endpointId.Id, stateManager);
+ // We watch for session and credential state changes in our ID
+ var sessDisposable = stateManager.SubscribeToSession(endpointId, statusRow);
+ var credDisposable = stateManager.SubscribeToCredentials(endpointId, statusRow);
+
+ // And we also watch for credentials changes in the default session (just to keep
+ // track of whether we are still the default)
+ var dct = new DefaultCredentialsTracker(statusRow);
+ var defaultCredDisposable = stateManager.SubscribeToDefaultCredentials(dct);
+
+ // We'll do our AddRow on the GUI thread, and, while we're on the GUI thread, we'll add
+ // our disposables to our saved disposables.
+ cmDialog.Invoke(() => {
+ _disposables.Add(sessDisposable);
+ _disposables.Add(credDisposable);
+ _disposables.Add(defaultCredDisposable);
+ cmDialog.AddRow(statusRow);
+ });
+ }
+
+ public void Dispose() {
+ // Since the GUI thread is where we added these disposables, the GUI thread is where we will
+ // access and dispose them.
+ cmDialog.Invoke(() => {
+ var temp = _disposables.ToArray();
+ _disposables.Clear();
+ foreach (var disposable in temp) {
+ disposable.Dispose();
+ }
+ });
+ }
+
+ public void OnCompleted() {
+ // TODO(kosak)
+ throw new NotImplementedException();
+ }
+
+ public void OnError(Exception error) {
+ // TODO(kosak)
+ throw new NotImplementedException();
+ }
+}
+
+internal class DefaultCredentialsTracker(ConnectionManagerDialogRow statusRow) : IObserver> {
+ public void OnNext(StatusOr value) {
+ statusRow.SetDefaultCredentials(value);
+ }
+
+ public void OnCompleted() {
+ // TODO(kosak)
+ throw new NotImplementedException();
+ }
+
+ public void OnError(Exception error) {
+ // TODO(kosak)
+ throw new NotImplementedException();
+ }
+}
\ No newline at end of file
diff --git a/csharp/ExcelAddIn/factories/CredentialsDialogFactory.cs b/csharp/ExcelAddIn/factories/CredentialsDialogFactory.cs
new file mode 100644
index 00000000000..4212c250c31
--- /dev/null
+++ b/csharp/ExcelAddIn/factories/CredentialsDialogFactory.cs
@@ -0,0 +1,53 @@
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Providers;
+using Deephaven.ExcelAddIn.ViewModels;
+using ExcelAddIn.views;
+
+namespace Deephaven.ExcelAddIn.Factories;
+
+internal static class CredentialsDialogFactory {
+ public static CredentialsDialog Create(StateManager sm, CredentialsDialogViewModel cvm) {
+ CredentialsDialog? credentialsDialog = null;
+
+ void OnSetCredentialsButtonClicked() {
+ if (!cvm.TryMakeCredentials(out var newCreds, out var error)) {
+ ShowMessageBox(error);
+ return;
+ }
+
+ sm.SetCredentials(newCreds);
+ if (cvm.IsDefault) {
+ sm.SetDefaultCredentials(newCreds);
+ }
+ }
+
+ void OnTestCredentialsButtonClicked() {
+ if (!cvm.TryMakeCredentials(out var newCreds, out var error)) {
+ ShowMessageBox(error);
+ return;
+ }
+
+ credentialsDialog!.SetTestResultsBox("Checking credentials");
+
+ sm.WorkerThread.Invoke(() => {
+ var state = "OK";
+ try {
+ var temp = SessionBaseFactory.Create(newCreds, sm.WorkerThread);
+ temp.Dispose();
+ } catch (Exception ex) {
+ state = ex.Message;
+ }
+
+ credentialsDialog!.SetTestResultsBox(state);
+ });
+ }
+
+ // Save in captured variable so that the lambdas can access it.
+ credentialsDialog = new CredentialsDialog(cvm, OnSetCredentialsButtonClicked, OnTestCredentialsButtonClicked);
+ return credentialsDialog;
+ }
+
+ private static void ShowMessageBox(string error) {
+ MessageBox.Show(error, "Please provide missing fields", MessageBoxButtons.OK);
+ }
+}
diff --git a/csharp/ExcelAddIn/factories/SessionBaseFactory.cs b/csharp/ExcelAddIn/factories/SessionBaseFactory.cs
new file mode 100644
index 00000000000..0719e58fc2f
--- /dev/null
+++ b/csharp/ExcelAddIn/factories/SessionBaseFactory.cs
@@ -0,0 +1,25 @@
+using Deephaven.DeephavenClient;
+using Deephaven.DheClient.Session;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Util;
+
+namespace Deephaven.ExcelAddIn.Factories;
+
+internal static class SessionBaseFactory {
+ public static SessionBase Create(CredentialsBase credentials, WorkerThread workerThread) {
+ return credentials.AcceptVisitor(
+ core => {
+ var client = Client.Connect(core.ConnectionString, new ClientOptions());
+ return new CoreSession(client);
+ },
+
+ corePlus => {
+ var session = SessionManager.FromUrl("Deephaven Excel", corePlus.JsonUrl);
+ if (!session.PasswordAuthentication(corePlus.User, corePlus.Password, corePlus.OperateAs)) {
+ throw new Exception("Authentication failed");
+ }
+
+ return new CorePlusSession(session, workerThread);
+ });
+ }
+}
diff --git a/csharp/ExcelAddIn/models/Credentials.cs b/csharp/ExcelAddIn/models/Credentials.cs
new file mode 100644
index 00000000000..3368a59de04
--- /dev/null
+++ b/csharp/ExcelAddIn/models/Credentials.cs
@@ -0,0 +1,37 @@
+namespace Deephaven.ExcelAddIn.Models;
+
+public abstract class CredentialsBase(EndpointId id) {
+ public readonly EndpointId Id = id;
+
+ public static CredentialsBase OfCore(EndpointId id, string connectionString) {
+ return new CoreCredentials(id, connectionString);
+ }
+
+ public static CredentialsBase OfCorePlus(EndpointId id, string jsonUrl, string userId,
+ string password, string operateAs) {
+ return new CorePlusCredentials(id, jsonUrl, userId, password, operateAs);
+ }
+
+ public abstract T AcceptVisitor(Func ofCore,
+ Func ofCorePlus);
+}
+
+public sealed class CoreCredentials(EndpointId id, string connectionString) : CredentialsBase(id) {
+ public readonly string ConnectionString = connectionString;
+
+ public override T AcceptVisitor(Func ofCore, Func ofCorePlus) {
+ return ofCore(this);
+ }
+}
+
+public sealed class CorePlusCredentials(EndpointId id, string jsonUrl, string user, string password,
+ string operateAs) : CredentialsBase(id) {
+ public readonly string JsonUrl = jsonUrl;
+ public readonly string User = user;
+ public readonly string Password = password;
+ public readonly string OperateAs = operateAs;
+
+ public override T AcceptVisitor(Func ofCore, Func ofCorePlus) {
+ return ofCorePlus(this);
+ }
+}
diff --git a/csharp/ExcelAddIn/models/Session.cs b/csharp/ExcelAddIn/models/Session.cs
new file mode 100644
index 00000000000..a3cbfa1998f
--- /dev/null
+++ b/csharp/ExcelAddIn/models/Session.cs
@@ -0,0 +1,90 @@
+using Deephaven.DeephavenClient.ExcelAddIn.Util;
+using Deephaven.DeephavenClient;
+using Deephaven.DheClient.Session;
+using Deephaven.ExcelAddIn.Providers;
+using Deephaven.ExcelAddIn.Util;
+
+namespace Deephaven.ExcelAddIn.Models;
+
+///
+/// A "Session" is an abstraction meant to represent a Core or Core+ "session".
+/// For Core, this means having a valid Client.
+/// For Core+, this means having a SessionManager, through which you can subscribe to PQs and get Clients.
+///
+public abstract class SessionBase : IDisposable {
+ ///
+ /// This is meant to act like a Visitor pattern with lambdas.
+ ///
+ public abstract T Visit(Func onCore, Func onCorePlus);
+
+ public abstract void Dispose();
+}
+
+public sealed class CoreSession(Client client) : SessionBase {
+ public Client? Client = client;
+
+ public override T Visit(Func onCore, Func onCorePlus) {
+ return onCore(this);
+ }
+
+ public override void Dispose() {
+ Utility.Exchange(ref Client, null)?.Dispose();
+ }
+}
+
+public sealed class CorePlusSession(SessionManager sessionManager, WorkerThread workerThread) : SessionBase {
+ private SessionManager? _sessionManager = sessionManager;
+ private readonly Dictionary _clientProviders = new();
+
+ public override T Visit(Func onCore, Func onCorePlus) {
+ return onCorePlus(this);
+ }
+
+ public IDisposable SubscribeToPq(PersistentQueryId persistentQueryId,
+ IObserver> observer) {
+ if (_sessionManager == null) {
+ throw new Exception("Object has been disposed");
+ }
+
+ CorePlusClientProvider? cp = null;
+ IDisposable? disposer = null;
+
+ workerThread.Invoke(() => {
+ if (!_clientProviders.TryGetValue(persistentQueryId, out cp)) {
+ cp = CorePlusClientProvider.Create(workerThread, _sessionManager, persistentQueryId);
+ _clientProviders.Add(persistentQueryId, cp);
+ }
+
+ disposer = cp.Subscribe(observer);
+ });
+
+ return ActionAsDisposable.Create(() => {
+ workerThread.Invoke(() => {
+ var old = Utility.Exchange(ref disposer, null);
+ // Do nothing if caller Disposes me multiple times.
+ if (old == null) {
+ return;
+ }
+ old.Dispose();
+
+ // Slightly weird. If "old.Dispose()" has removed the last subscriber,
+ // then dispose it and remove it from our dictionary.
+ cp!.DisposeIfEmpty(() => _clientProviders.Remove(persistentQueryId));
+ });
+ });
+ }
+
+ public override void Dispose() {
+ if (workerThread.InvokeIfRequired(Dispose)) {
+ return;
+ }
+
+ var localCps = _clientProviders.Values.ToArray();
+ _clientProviders.Clear();
+ Utility.Exchange(ref _sessionManager, null)?.Dispose();
+
+ foreach (var cp in localCps) {
+ cp.Dispose();
+ }
+ }
+}
diff --git a/csharp/ExcelAddIn/models/SimpleModels.cs b/csharp/ExcelAddIn/models/SimpleModels.cs
new file mode 100644
index 00000000000..02a89b370b7
--- /dev/null
+++ b/csharp/ExcelAddIn/models/SimpleModels.cs
@@ -0,0 +1,13 @@
+namespace Deephaven.ExcelAddIn.Models;
+
+public record AddOrRemove(bool IsAdd, T Value) {
+ public static AddOrRemove OfAdd(T value) {
+ return new AddOrRemove(true, value);
+ }
+}
+
+public record EndpointId(string Id) {
+ public override string ToString() => Id;
+}
+
+public record PersistentQueryId(string Id);
diff --git a/csharp/ExcelAddIn/models/TableTriple.cs b/csharp/ExcelAddIn/models/TableTriple.cs
new file mode 100644
index 00000000000..95d7e7847b5
--- /dev/null
+++ b/csharp/ExcelAddIn/models/TableTriple.cs
@@ -0,0 +1,37 @@
+namespace Deephaven.ExcelAddIn.Models;
+
+public record TableTriple(
+ EndpointId? EndpointId,
+ PersistentQueryId? PersistentQueryId,
+ string TableName) {
+
+ public static bool TryParse(string text, out TableTriple result, out string errorText) {
+ // Accepts strings of the following form
+ // 1. "table" (becomes null, null, "table")
+ // 2. "endpoint:table" (becomes endpoint, null, table)
+ // 3. "pq/table" (becomes null, pq, table)
+ // 4. "endpoint:pq/table" (becomes endpoint, pq, table)
+ EndpointId? epId = null;
+ PersistentQueryId? pqid = null;
+ var tableName = "";
+ var colonIndex = text.IndexOf(':');
+ if (colonIndex > 0) {
+ // cases 2 and 4: pull out the endpointId, and then reduce to cases 1 and 3
+ epId = new EndpointId(text[..colonIndex]);
+ text = text[(colonIndex + 1)..];
+ }
+
+ var slashIndex = text.IndexOf('/');
+ if (slashIndex > 0) {
+ // case 3: pull out the slash, and reduce to case 1
+ pqid = new PersistentQueryId(text[..slashIndex]);
+ text = text[(slashIndex + 1)..];
+ }
+
+ tableName = text;
+ result = new TableTriple(epId, pqid, tableName);
+ errorText = "";
+ // This version never fails to parse, but we leave open the option in our API to do so.
+ return true;
+ }
+}
diff --git a/csharp/ExcelAddIn/operations/SnapshotOperation.cs b/csharp/ExcelAddIn/operations/SnapshotOperation.cs
new file mode 100644
index 00000000000..70b577de0e3
--- /dev/null
+++ b/csharp/ExcelAddIn/operations/SnapshotOperation.cs
@@ -0,0 +1,81 @@
+using Deephaven.DeephavenClient;
+using Deephaven.DeephavenClient.ExcelAddIn.Util;
+using Deephaven.ExcelAddIn.ExcelDna;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Util;
+using ExcelDna.Integration;
+
+namespace Deephaven.ExcelAddIn.Operations;
+
+internal class SnapshotOperation : IExcelObservable, IObserver> {
+ private readonly TableTriple _tableDescriptor;
+ private readonly string _filter;
+ private readonly bool _wantHeaders;
+ private readonly StateManager _stateManager;
+ private readonly ObserverContainer> _observers = new();
+ private readonly WorkerThread _workerThread;
+ private IDisposable? _filteredTableDisposer = null;
+
+ public SnapshotOperation(TableTriple tableDescriptor, string filter, bool wantHeaders,
+ StateManager stateManager) {
+ _tableDescriptor = tableDescriptor;
+ _filter = filter;
+ _wantHeaders = wantHeaders;
+ _stateManager = stateManager;
+ // Convenience
+ _workerThread = _stateManager.WorkerThread;
+ }
+
+ public IDisposable Subscribe(IExcelObserver observer) {
+ var wrappedObserver = ExcelDnaHelpers.WrapExcelObserver(observer);
+ _workerThread.Invoke(() => {
+ _observers.Add(wrappedObserver, out var isFirst);
+
+ if (isFirst) {
+ _filteredTableDisposer = _stateManager.SubscribeToTableTriple(_tableDescriptor, _filter, this);
+ }
+ });
+
+ return ActionAsDisposable.Create(() => {
+ _workerThread.Invoke(() => {
+ _observers.Remove(wrappedObserver, out var wasLast);
+ if (!wasLast) {
+ return;
+ }
+
+ Utility.Exchange(ref _filteredTableDisposer, null)?.Dispose();
+ });
+ });
+ }
+
+ public void OnNext(StatusOr soth) {
+ if (_workerThread.InvokeIfRequired(() => OnNext(soth))) {
+ return;
+ }
+
+ if (!soth.GetValueOrStatus(out var tableHandle, out var status)) {
+ _observers.SendStatus(status);
+ return;
+ }
+
+ _observers.SendStatus($"Snapshotting \"{_tableDescriptor.TableName}\"");
+
+ try {
+ using var ct = tableHandle.ToClientTable();
+ var result = Renderer.Render(ct, _wantHeaders);
+ _observers.SendValue(result);
+ } catch (Exception ex) {
+ _observers.SendStatus(ex.Message);
+ }
+ }
+
+ void IObserver>.OnCompleted() {
+ // TODO(kosak): TODO
+ throw new NotImplementedException();
+ }
+
+ void IObserver>.OnError(Exception error) {
+ // TODO(kosak): TODO
+ throw new NotImplementedException();
+ }
+}
diff --git a/csharp/ExcelAddIn/operations/SubscribeOperation.cs b/csharp/ExcelAddIn/operations/SubscribeOperation.cs
new file mode 100644
index 00000000000..e451861546a
--- /dev/null
+++ b/csharp/ExcelAddIn/operations/SubscribeOperation.cs
@@ -0,0 +1,121 @@
+using Deephaven.DeephavenClient;
+using Deephaven.DeephavenClient.ExcelAddIn.Util;
+using Deephaven.ExcelAddIn.ExcelDna;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Providers;
+using Deephaven.ExcelAddIn.Util;
+using ExcelDna.Integration;
+
+namespace Deephaven.ExcelAddIn.Operations;
+
+internal class SubscribeOperation : IExcelObservable, IObserver> {
+ private readonly TableTriple _tableDescriptor;
+ private readonly string _filter;
+ private readonly bool _wantHeaders;
+ private readonly StateManager _stateManager;
+ private readonly ObserverContainer> _observers = new();
+ private readonly WorkerThread _workerThread;
+ private IDisposable? _filteredTableDisposer = null;
+ private TableHandle? _currentTableHandle = null;
+ private SubscriptionHandle? _currentSubHandle = null;
+
+ public SubscribeOperation(TableTriple tableDescriptor, string filter, bool wantHeaders,
+ StateManager stateManager) {
+ _tableDescriptor = tableDescriptor;
+ _filter = filter;
+ _wantHeaders = wantHeaders;
+ _stateManager = stateManager;
+ // Convenience
+ _workerThread = _stateManager.WorkerThread;
+ }
+
+ public IDisposable Subscribe(IExcelObserver observer) {
+ var wrappedObserver = ExcelDnaHelpers.WrapExcelObserver(observer);
+ _workerThread.Invoke(() => {
+ _observers.Add(wrappedObserver, out var isFirst);
+
+ if (isFirst) {
+ _filteredTableDisposer = _stateManager.SubscribeToTableTriple(_tableDescriptor, _filter, this);
+ }
+ });
+
+ return ActionAsDisposable.Create(() => {
+ _workerThread.Invoke(() => {
+ _observers.Remove(wrappedObserver, out var wasLast);
+ if (!wasLast) {
+ return;
+ }
+
+ var temp = _filteredTableDisposer;
+ _filteredTableDisposer = null;
+ temp?.Dispose();
+ });
+ });
+ }
+
+ public void OnNext(StatusOr soth) {
+ if (_workerThread.InvokeIfRequired(() => OnNext(soth))) {
+ return;
+ }
+
+ // First tear down old state
+ if (_currentTableHandle != null) {
+ _currentTableHandle.Unsubscribe(_currentSubHandle!);
+ _currentSubHandle!.Dispose();
+ _currentTableHandle = null;
+ _currentSubHandle = null;
+ }
+
+ if (!soth.GetValueOrStatus(out var tableHandle, out var status)) {
+ _observers.SendStatus(status);
+ return;
+ }
+
+ _observers.SendStatus($"Subscribing to \"{_tableDescriptor.TableName}\"");
+
+ _currentTableHandle = tableHandle;
+ _currentSubHandle = _currentTableHandle.Subscribe(new MyTickingCallback(_observers, _wantHeaders));
+
+ try {
+ using var ct = tableHandle.ToClientTable();
+ var result = Renderer.Render(ct, _wantHeaders);
+ _observers.SendValue(result);
+ } catch (Exception ex) {
+ _observers.SendStatus(ex.Message);
+ }
+ }
+
+ void IObserver>.OnCompleted() {
+ // TODO(kosak): TODO
+ throw new NotImplementedException();
+ }
+
+ void IObserver>.OnError(Exception error) {
+ // TODO(kosak): TODO
+ throw new NotImplementedException();
+ }
+
+ private class MyTickingCallback : ITickingCallback {
+ private readonly ObserverContainer> _observers;
+ private readonly bool _wantHeaders;
+
+ public MyTickingCallback(ObserverContainer> observers,
+ bool wantHeaders) {
+ _observers = observers;
+ _wantHeaders = wantHeaders;
+ }
+
+ public void OnTick(TickingUpdate update) {
+ try {
+ var results = Renderer.Render(update.Current, _wantHeaders);
+ _observers.SendValue(results);
+ } catch (Exception e) {
+ _observers.SendStatus(e.Message);
+ }
+ }
+
+ public void OnFailure(string errorText) {
+ _observers.SendStatus(errorText);
+ }
+ }
+}
diff --git a/csharp/ExcelAddIn/providers/CorePlusClientProvider.cs b/csharp/ExcelAddIn/providers/CorePlusClientProvider.cs
new file mode 100644
index 00000000000..f7f9f90c7f5
--- /dev/null
+++ b/csharp/ExcelAddIn/providers/CorePlusClientProvider.cs
@@ -0,0 +1,73 @@
+using Deephaven.DeephavenClient.ExcelAddIn.Util;
+using Deephaven.DeephavenClient;
+using Deephaven.DheClient.Session;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Util;
+
+namespace Deephaven.ExcelAddIn.Providers;
+
+///
+/// This Observable provides StatusOr<Client> objects for Core+.
+/// If it can successfully connect to a PQ on Core+, it will send a Client.
+/// In the future it will be an Observer of PQ up/down messages.
+///
+internal class CorePlusClientProvider : IObservable>, IDisposable {
+ public static CorePlusClientProvider Create(WorkerThread workerThread, SessionManager sessionManager,
+ PersistentQueryId persistentQueryId) {
+ var self = new CorePlusClientProvider(workerThread);
+ workerThread.Invoke(() => {
+ try {
+ var dndClient = sessionManager.ConnectToPqByName(persistentQueryId.Id, false);
+ self._client = StatusOr.OfValue(dndClient);
+ } catch (Exception ex) {
+ self._client = StatusOr.OfStatus(ex.Message);
+ }
+ });
+ return self;
+ }
+
+ private readonly WorkerThread _workerThread;
+ private readonly ObserverContainer> _observers = new();
+ private StatusOr _client = StatusOr.OfStatus("Not connected");
+
+ private CorePlusClientProvider(WorkerThread workerThread) {
+ _workerThread = workerThread;
+ }
+
+ public IDisposable Subscribe(IObserver> observer) {
+ _workerThread.Invoke(() => {
+ // New observer gets added to the collection and then notified of the current status.
+ _observers.Add(observer, out _);
+ observer.OnNext(_client);
+ });
+
+ return ActionAsDisposable.Create(() => {
+ _workerThread.Invoke(() => {
+ _observers.Remove(observer, out _);
+ });
+ });
+ }
+
+ public void Dispose() {
+ if (_workerThread.InvokeIfRequired(Dispose)) {
+ return;
+ }
+
+ _ = _client.GetValueOrStatus(out var c, out _);
+ _client = StatusOr.OfStatus("Disposed");
+ c?.Dispose();
+ }
+
+ public void DisposeIfEmpty(Action onEmpty) {
+ if (_workerThread.InvokeIfRequired(() => DisposeIfEmpty(onEmpty))) {
+ return;
+ }
+
+ if (_observers.Count != 0) {
+ return;
+ }
+
+ Dispose();
+ onEmpty();
+ }
+}
diff --git a/csharp/ExcelAddIn/providers/DefaultSessionProvider.cs b/csharp/ExcelAddIn/providers/DefaultSessionProvider.cs
new file mode 100644
index 00000000000..f38f8bc8a8e
--- /dev/null
+++ b/csharp/ExcelAddIn/providers/DefaultSessionProvider.cs
@@ -0,0 +1,88 @@
+using Deephaven.DeephavenClient.ExcelAddIn.Util;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Util;
+
+namespace Deephaven.ExcelAddIn.Providers;
+
+internal class DefaultSessionProvider(WorkerThread workerThread) :
+ IObserver>, IObserver>,
+ IObservable>, IObservable> {
+ private StatusOr _credentials = StatusOr.OfStatus("[Not set]");
+ private StatusOr _session = StatusOr.OfStatus("[Not connected]");
+ private readonly ObserverContainer> _credentialsObservers = new();
+ private readonly ObserverContainer> _sessionObservers = new();
+ private SessionProvider? _parent = null;
+ private IDisposable? _credentialsSubDisposer = null;
+ private IDisposable? _sessionSubDisposer = null;
+
+ public IDisposable Subscribe(IObserver> observer) {
+ workerThread.Invoke(() => {
+ // New observer gets added to the collection and then notified of the current status.
+ _credentialsObservers.Add(observer, out _);
+ observer.OnNext(_credentials);
+ });
+
+ return ActionAsDisposable.Create(() => {
+ workerThread.Invoke(() => {
+ _credentialsObservers.Remove(observer, out _);
+ });
+ });
+ }
+
+ public IDisposable Subscribe(IObserver> observer) {
+ workerThread.Invoke(() => {
+ // New observer gets added to the collection and then notified of the current status.
+ _sessionObservers.Add(observer, out _);
+ observer.OnNext(_session);
+ });
+
+ return ActionAsDisposable.Create(() => {
+ workerThread.Invoke(() => {
+ _sessionObservers.Remove(observer, out _);
+ });
+ });
+ }
+
+ public void OnNext(StatusOr value) {
+ if (workerThread.InvokeIfRequired(() => OnNext(value))) {
+ return;
+ }
+ _credentials = value;
+ _credentialsObservers.OnNext(_credentials);
+ }
+
+ public void OnNext(StatusOr value) {
+ if (workerThread.InvokeIfRequired(() => OnNext(value))) {
+ return;
+ }
+ _session = value;
+ _sessionObservers.OnNext(_session);
+ }
+
+ public void OnCompleted() {
+ // TODO(kosak)
+ throw new NotImplementedException();
+ }
+
+ public void OnError(Exception error) {
+ // TODO(kosak)
+ throw new NotImplementedException();
+ }
+
+ public void SetParent(SessionProvider? newParent) {
+ if (workerThread.InvokeIfRequired(() => SetParent(newParent))) {
+ return;
+ }
+
+ _parent = newParent;
+ Utility.Exchange(ref _credentialsSubDisposer, null)?.Dispose();
+ Utility.Exchange(ref _sessionSubDisposer, null)?.Dispose();
+
+ if (_parent == null) {
+ return;
+ }
+
+ _credentialsSubDisposer = _parent.Subscribe((IObserver>)this);
+ _sessionSubDisposer = _parent.Subscribe((IObserver>)this);
+ }
+}
diff --git a/csharp/ExcelAddIn/providers/SessionProvider.cs b/csharp/ExcelAddIn/providers/SessionProvider.cs
new file mode 100644
index 00000000000..35e98926360
--- /dev/null
+++ b/csharp/ExcelAddIn/providers/SessionProvider.cs
@@ -0,0 +1,115 @@
+using Deephaven.DeephavenClient.ExcelAddIn.Util;
+using Deephaven.ExcelAddIn.Factories;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Util;
+using System.Net;
+
+namespace Deephaven.ExcelAddIn.Providers;
+
+internal class SessionProvider(WorkerThread workerThread) : IObservable>, IObservable>, IDisposable {
+ private StatusOr _credentials = StatusOr.OfStatus("[Not set]");
+ private StatusOr _session = StatusOr.OfStatus("[Not connected]");
+ private readonly ObserverContainer> _credentialsObservers = new();
+ private readonly ObserverContainer> _sessionObservers = new();
+
+ public void Dispose() {
+ // Get on the worker thread if not there already.
+ if (workerThread.InvokeIfRequired(Dispose)) {
+ return;
+ }
+
+ // TODO(kosak)
+ // I feel like we should send an OnComplete to any remaining observers
+
+ if (!_session.GetValueOrStatus(out var sess, out _)) {
+ return;
+ }
+
+ _sessionObservers.SetAndSendStatus(ref _session, "Disposing");
+ sess.Dispose();
+ }
+
+ ///
+ /// Subscribe to credentials changes
+ ///
+ ///
+ ///
+ public IDisposable Subscribe(IObserver> observer) {
+ workerThread.Invoke(() => {
+ // New observer gets added to the collection and then notified of the current status.
+ _credentialsObservers.Add(observer, out _);
+ observer.OnNext(_credentials);
+ });
+
+ return ActionAsDisposable.Create(() => {
+ workerThread.Invoke(() => {
+ _credentialsObservers.Remove(observer, out _);
+ });
+ });
+ }
+
+ ///
+ /// Subscribe to session changes
+ ///
+ ///
+ ///
+ public IDisposable Subscribe(IObserver> observer) {
+ workerThread.Invoke(() => {
+ // New observer gets added to the collection and then notified of the current status.
+ _sessionObservers.Add(observer, out _);
+ observer.OnNext(_session);
+ });
+
+ return ActionAsDisposable.Create(() => {
+ workerThread.Invoke(() => {
+ _sessionObservers.Remove(observer, out _);
+ });
+ });
+ }
+
+ public void SetCredentials(CredentialsBase credentials) {
+ // Get on the worker thread if not there already.
+ if (workerThread.InvokeIfRequired(() => SetCredentials(credentials))) {
+ return;
+ }
+
+ // Dispose existing session
+ if (_session.GetValueOrStatus(out var sess, out _)) {
+ _sessionObservers.SetAndSendStatus(ref _session, "Disposing session");
+ sess.Dispose();
+ }
+
+ _credentialsObservers.SetAndSendValue(ref _credentials, credentials);
+
+ _sessionObservers.SetAndSendStatus(ref _session, "Trying to connect");
+
+ try {
+ var sb = SessionBaseFactory.Create(credentials, workerThread);
+ _sessionObservers.SetAndSendValue(ref _session, sb);
+ } catch (Exception ex) {
+ _sessionObservers.SetAndSendStatus(ref _session, ex.Message);
+ }
+ }
+
+ public void Reconnect() {
+ // Get on the worker thread if not there already.
+ if (workerThread.InvokeIfRequired(Reconnect)) {
+ return;
+ }
+
+ // We implement this as a SetCredentials call, with credentials we already have.
+ if (_credentials.GetValueOrStatus(out var creds, out _)) {
+ SetCredentials(creds);
+ }
+ }
+
+ public void OnCompleted() {
+ // TODO(kosak)
+ throw new NotImplementedException();
+ }
+
+ public void OnError(Exception error) {
+ // TODO(kosak)
+ throw new NotImplementedException();
+ }
+}
diff --git a/csharp/ExcelAddIn/providers/SessionProviders.cs b/csharp/ExcelAddIn/providers/SessionProviders.cs
new file mode 100644
index 00000000000..5e5db2366e3
--- /dev/null
+++ b/csharp/ExcelAddIn/providers/SessionProviders.cs
@@ -0,0 +1,108 @@
+using Deephaven.DeephavenClient.ExcelAddIn.Util;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Util;
+
+namespace Deephaven.ExcelAddIn.Providers;
+
+internal class SessionProviders(WorkerThread workerThread) : IObservable> {
+ private readonly DefaultSessionProvider _defaultProvider = new(workerThread);
+ private readonly Dictionary _providerMap = new();
+ private readonly ObserverContainer> _endpointsObservers = new();
+
+ public IDisposable Subscribe(IObserver> observer) {
+ IDisposable? disposable = null;
+ // We need to run this on our worker thread because we want to protect
+ // access to our dictionary.
+ workerThread.Invoke(() => {
+ _endpointsObservers.Add(observer, out _);
+ // To avoid any further possibility of reentrancy while iterating over the dict,
+ // make a copy of the keys
+ var keys = _providerMap.Keys.ToArray();
+ foreach (var endpointId in keys) {
+ observer.OnNext(AddOrRemove.OfAdd(endpointId));
+ }
+ });
+
+ return ActionAsDisposable.Create(() => {
+ workerThread.Invoke(() => {
+ Utility.Exchange(ref disposable, null)?.Dispose();
+ });
+ });
+ }
+
+ public IDisposable SubscribeToSession(EndpointId id, IObserver> observer) {
+ IDisposable? disposable = null;
+ ApplyTo(id, sp => disposable = sp.Subscribe(observer));
+
+ return ActionAsDisposable.Create(() => {
+ workerThread.Invoke(() => {
+ Utility.Exchange(ref disposable, null)?.Dispose();
+ });
+ });
+ }
+
+ public IDisposable SubscribeToCredentials(EndpointId id, IObserver> observer) {
+ IDisposable? disposable = null;
+ ApplyTo(id, sp => disposable = sp.Subscribe(observer));
+
+ return ActionAsDisposable.Create(() => {
+ workerThread.Invoke(() => {
+ Utility.Exchange(ref disposable, null)?.Dispose();
+ });
+ });
+ }
+
+ public IDisposable SubscribeToDefaultSession(IObserver> observer) {
+ IDisposable? disposable = null;
+ workerThread.Invoke(() => {
+ disposable = _defaultProvider.Subscribe(observer);
+ });
+
+ return ActionAsDisposable.Create(() => {
+ workerThread.Invoke(() => {
+ Utility.Exchange(ref disposable, null)?.Dispose();
+ });
+ });
+ }
+
+ public IDisposable SubscribeToDefaultCredentials(IObserver> observer) {
+ IDisposable? disposable = null;
+ workerThread.Invoke(() => {
+ disposable = _defaultProvider.Subscribe(observer);
+ });
+
+ return ActionAsDisposable.Create(() => {
+ workerThread.Invoke(() => {
+ Utility.Exchange(ref disposable, null)?.Dispose();
+ });
+ });
+ }
+
+ public void SetCredentials(CredentialsBase credentials) {
+ ApplyTo(credentials.Id, sp => {
+ sp.SetCredentials(credentials);
+ });
+ }
+
+ public void SetDefaultCredentials(CredentialsBase credentials) {
+ ApplyTo(credentials.Id, _defaultProvider.SetParent);
+ }
+
+ public void Reconnect(EndpointId id) {
+ ApplyTo(id, sp => sp.Reconnect());
+ }
+
+ private void ApplyTo(EndpointId id, Action action) {
+ if (workerThread.InvokeIfRequired(() => ApplyTo(id, action))) {
+ return;
+ }
+
+ if (!_providerMap.TryGetValue(id, out var sp)) {
+ sp = new SessionProvider(workerThread);
+ _providerMap.Add(id, sp);
+ _endpointsObservers.OnNext(AddOrRemove.OfAdd(id));
+ }
+
+ action(sp);
+ }
+}
diff --git a/csharp/ExcelAddIn/providers/TableHandleProvider.cs b/csharp/ExcelAddIn/providers/TableHandleProvider.cs
new file mode 100644
index 00000000000..e45ee7ce5df
--- /dev/null
+++ b/csharp/ExcelAddIn/providers/TableHandleProvider.cs
@@ -0,0 +1,142 @@
+using Deephaven.DeephavenClient;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Util;
+using Deephaven.DeephavenClient.ExcelAddIn.Util;
+
+namespace Deephaven.ExcelAddIn.Providers;
+
+internal class TableHandleProvider(
+ WorkerThread workerThread,
+ TableTriple descriptor,
+ string filter) : IObserver>, IObserver>,
+ IObservable>, IDisposable {
+
+ private readonly ObserverContainer> _observers = new();
+ private IDisposable? _pqDisposable = null;
+ private StatusOr _tableHandle = StatusOr.OfStatus("[no TableHandle]");
+
+ public IDisposable Subscribe(IObserver> observer) {
+ // We need to run this on our worker thread because we want to protect
+ // access to our dictionary.
+ workerThread.Invoke(() => {
+ _observers.Add(observer, out _);
+ observer.OnNext(_tableHandle);
+ });
+
+ return ActionAsDisposable.Create(() => {
+ workerThread.Invoke(() => {
+ _observers.Remove(observer, out _);
+ });
+ });
+ }
+
+ public void Dispose() {
+ // Get onto the worker thread if we're not already on it.
+ if (workerThread.InvokeIfRequired(Dispose)) {
+ return;
+ }
+
+ DisposePqAndThState();
+ }
+
+ public void OnNext(StatusOr session) {
+ // Get onto the worker thread if we're not already on it.
+ if (workerThread.InvokeIfRequired(() => OnNext(session))) {
+ return;
+ }
+
+ try {
+ // Dispose whatever state we had before.
+ DisposePqAndThState();
+
+ // If the new state is just a status message, make that our status and transmit to our observers
+ if (!session.GetValueOrStatus(out var sb, out var status)) {
+ _observers.SetAndSendStatus(ref _tableHandle, status);
+ return;
+ }
+
+ // New state is a Core or CorePlus Session.
+ _ = sb.Visit(coreSession => {
+ // It's a Core session so just forward its client field to our own OnNext(Client) method.
+ // We test against null in the unlikely/impossible case that the session is Disposed
+ if (coreSession.Client != null) {
+ OnNext(StatusOr.OfValue(coreSession.Client));
+ }
+
+ return Unit.Instance; // Essentially a "void" value that is ignored.
+ }, corePlusSession => {
+ // It's a CorePlus session so subscribe us to its PQ observer for the appropriate PQ ID
+ // If no PQ id was provided, that's a problem
+ var pqid = descriptor.PersistentQueryId;
+ if (pqid == null) {
+ throw new Exception("PQ id is required");
+ }
+ _observers.SetAndSendStatus(ref _tableHandle, $"Subscribing to PQ \"{pqid}\"");
+ _pqDisposable = corePlusSession.SubscribeToPq(pqid, this);
+ return Unit.Instance;
+ });
+ } catch (Exception ex) {
+ _observers.SetAndSendStatus(ref _tableHandle, ex.Message);
+ }
+ }
+
+ public void OnNext(StatusOr client) {
+ // Get onto the worker thread if we're not already on it.
+ if (workerThread.InvokeIfRequired(() => OnNext(client))) {
+ return;
+ }
+
+ try {
+ // Dispose whatever state we had before.
+ DisposePqAndThState();
+
+ // If the new state is just a status message, make that our state and transmit to our observers
+ if (!client.GetValueOrStatus(out var cli, out var status)) {
+ _observers.SetAndSendStatus(ref _tableHandle, status);
+ return;
+ }
+
+ // It's a real client so start fetching the table. First notify our observers.
+ _observers.SetAndSendStatus(ref _tableHandle, $"Fetching \"{descriptor.TableName}\"");
+
+ // Now fetch the table. This might block but we're on the worker thread. In the future
+ // we might move this to yet another thread.
+ var th = cli.Manager.FetchTable(descriptor.TableName);
+ if (filter != "") {
+ // If there's a filter, take this table handle and surround it with a Where.
+ var temp = th;
+ th = temp.Where(filter);
+ temp.Dispose();
+ }
+
+ // Success! Make this our state and send the table handle to our observers.
+ _observers.SetAndSendValue(ref _tableHandle, th);
+ } catch (Exception ex) {
+ // Some exception. Make the exception message our state and send it to our observers.
+ _observers.SetAndSendStatus(ref _tableHandle, ex.Message);
+ }
+ }
+
+ private void DisposePqAndThState() {
+ _ = _tableHandle.GetValueOrStatus(out var oldTh, out var _);
+ var oldPq = Utility.Exchange(ref _pqDisposable, null);
+
+ if (oldTh != null) {
+ _observers.SetAndSendStatus(ref _tableHandle, "Disposing TableHandle");
+ oldTh.Dispose();
+ }
+
+ if (oldPq != null) {
+ _observers.SetAndSendStatus(ref _tableHandle, "Disposing PQ");
+ oldPq.Dispose();
+ }
+ }
+
+ public void OnCompleted() {
+ throw new NotImplementedException();
+ }
+
+ public void OnError(Exception error) {
+ throw new NotImplementedException();
+ }
+}
diff --git a/csharp/ExcelAddIn/util/ActionAsDisposable.cs b/csharp/ExcelAddIn/util/ActionAsDisposable.cs
new file mode 100644
index 00000000000..e195d2aa7f6
--- /dev/null
+++ b/csharp/ExcelAddIn/util/ActionAsDisposable.cs
@@ -0,0 +1,21 @@
+namespace Deephaven.DeephavenClient.ExcelAddIn.Util;
+
+internal class ActionAsDisposable : IDisposable {
+ public static IDisposable Create(Action action) {
+ return new ActionAsDisposable(action);
+ }
+
+ private Action? _action;
+
+ private ActionAsDisposable(Action action) => _action = action;
+
+ public void Dispose() {
+ var temp = _action;
+ if (temp == null) {
+ return;
+ }
+
+ _action = null;
+ temp();
+ }
+}
diff --git a/csharp/ExcelAddIn/util/ObserverContainer.cs b/csharp/ExcelAddIn/util/ObserverContainer.cs
new file mode 100644
index 00000000000..e1d0e98cf42
--- /dev/null
+++ b/csharp/ExcelAddIn/util/ObserverContainer.cs
@@ -0,0 +1,52 @@
+namespace Deephaven.ExcelAddIn.Util;
+
+public sealed class ObserverContainer : IObserver {
+ private readonly object _sync = new();
+ private readonly HashSet> _observers = new();
+
+ public int Count {
+ get {
+ lock (_sync) {
+ return _observers.Count;
+ }
+ }
+ }
+
+ public void Add(IObserver observer, out bool isFirst) {
+ lock (_sync) {
+ isFirst = _observers.Count == 0;
+ _observers.Add(observer);
+ }
+ }
+
+ public void Remove(IObserver observer, out bool wasLast) {
+ lock (_sync) {
+ var removed = _observers.Remove(observer);
+ wasLast = removed && _observers.Count == 0;
+ }
+ }
+
+ public void OnNext(T result) {
+ foreach (var observer in SafeCopyObservers()) {
+ observer.OnNext(result);
+ }
+ }
+
+ public void OnError(Exception ex) {
+ foreach (var observer in SafeCopyObservers()) {
+ observer.OnError(ex);
+ }
+ }
+
+ public void OnCompleted() {
+ foreach (var observer in SafeCopyObservers()) {
+ observer.OnCompleted();
+ }
+ }
+
+ private IObserver[] SafeCopyObservers() {
+ lock (_sync) {
+ return _observers.ToArray();
+ }
+ }
+}
diff --git a/csharp/ExcelAddIn/util/Renderer.cs b/csharp/ExcelAddIn/util/Renderer.cs
new file mode 100644
index 00000000000..b0fba4fecc7
--- /dev/null
+++ b/csharp/ExcelAddIn/util/Renderer.cs
@@ -0,0 +1,33 @@
+using Deephaven.DeephavenClient;
+
+namespace Deephaven.ExcelAddIn.Util;
+
+internal static class Renderer {
+ public static object?[,] Render(ClientTable table, bool wantHeaders) {
+ var numRows = table.NumRows;
+ var numCols = table.NumCols;
+ var effectiveNumRows = wantHeaders ? numRows + 1 : numRows;
+ var result = new object?[effectiveNumRows, numCols];
+
+ var headers = table.Schema.Names;
+ for (var colIndex = 0; colIndex != numCols; ++colIndex) {
+ var destIndex = 0;
+ if (wantHeaders) {
+ result[destIndex++, colIndex] = headers[colIndex];
+ }
+
+ var (col, nulls) = table.GetColumn(colIndex);
+ for (var i = 0; i != numRows; ++i) {
+ var temp = nulls[i] ? null : col.GetValue(i);
+ // sad hack, wrong place, inefficient
+ if (temp is DhDateTime dh) {
+ temp = dh.DateTime.ToString("s", System.Globalization.CultureInfo.InvariantCulture);
+ }
+
+ result[destIndex++, colIndex] = temp;
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/csharp/ExcelAddIn/util/StatusOr.cs b/csharp/ExcelAddIn/util/StatusOr.cs
new file mode 100644
index 00000000000..4aeae778e98
--- /dev/null
+++ b/csharp/ExcelAddIn/util/StatusOr.cs
@@ -0,0 +1,57 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Deephaven.ExcelAddIn.Util;
+
+public sealed class StatusOr {
+ private readonly string? _status;
+ private readonly T? _value;
+
+ public static StatusOr OfStatus(string status) {
+ return new StatusOr(status, default);
+ }
+
+ public static StatusOr OfValue(T value) {
+ return new StatusOr(null, value);
+ }
+
+ private StatusOr(string? status, T? value) {
+ _status = status;
+ _value = value;
+ }
+
+ public bool GetValueOrStatus(
+ [NotNullWhen(true)]out T? value,
+ [NotNullWhen(false)]out string? status) {
+ status = _status;
+ value = _value;
+ return value != null;
+ }
+
+ public U AcceptVisitor(Func onValue, Func onStatus) {
+ return _value != null ? onValue(_value) : onStatus(_status!);
+ }
+}
+
+public static class ObserverStatusOr_Extensions {
+ public static void SendStatus(this IObserver> observer, string message) {
+ var so = StatusOr.OfStatus(message);
+ observer.OnNext(so);
+ }
+
+ public static void SetAndSendStatus(this IObserver> observer, ref StatusOr sor,
+ string message) {
+ sor = StatusOr.OfStatus(message);
+ observer.OnNext(sor);
+ }
+
+ public static void SendValue(this IObserver> observer, T value) {
+ var so = StatusOr.OfValue(value);
+ observer.OnNext(so);
+ }
+
+ public static void SetAndSendValue(this IObserver> observer, ref StatusOr sor,
+ T value) {
+ sor = StatusOr.OfValue(value);
+ observer.OnNext(sor);
+ }
+}
diff --git a/csharp/ExcelAddIn/util/TableDescriptor.cs b/csharp/ExcelAddIn/util/TableDescriptor.cs
new file mode 100644
index 00000000000..caa52c74e2a
--- /dev/null
+++ b/csharp/ExcelAddIn/util/TableDescriptor.cs
@@ -0,0 +1,2 @@
+namespace Deephaven.ExcelAddIn.Util;
+
diff --git a/csharp/ExcelAddIn/util/Utility.cs b/csharp/ExcelAddIn/util/Utility.cs
new file mode 100644
index 00000000000..0fce1f7a6f9
--- /dev/null
+++ b/csharp/ExcelAddIn/util/Utility.cs
@@ -0,0 +1,17 @@
+
+namespace Deephaven.ExcelAddIn.Util;
+
+internal static class Utility {
+ public static T Exchange(ref T item, T newValue) {
+ var result = item;
+ item = newValue;
+ return result;
+ }
+}
+
+public class Unit {
+ public static readonly Unit Instance = new Unit();
+
+ private Unit() {
+ }
+}
diff --git a/csharp/ExcelAddIn/util/WorkerThread.cs b/csharp/ExcelAddIn/util/WorkerThread.cs
new file mode 100644
index 00000000000..dc1d2858754
--- /dev/null
+++ b/csharp/ExcelAddIn/util/WorkerThread.cs
@@ -0,0 +1,64 @@
+using System.Diagnostics;
+
+namespace Deephaven.ExcelAddIn.Util;
+
+public class WorkerThread {
+ public static WorkerThread Create() {
+ var result = new WorkerThread();
+ var t = new Thread(result.Doit) { IsBackground = true };
+ result._thisThread = t;
+ t.Start();
+ return result;
+ }
+
+ private readonly object _sync = new();
+ private readonly Queue _queue = new();
+ private Thread? _thisThread;
+
+ private WorkerThread() {
+ }
+
+ public void Invoke(Action action) {
+ if (!InvokeIfRequired(action)) {
+ action();
+ }
+ }
+
+ public bool InvokeIfRequired(Action action) {
+ if (ReferenceEquals(Thread.CurrentThread, _thisThread)) {
+ // Appending to thread queue was not required. Return false.
+ return false;
+ }
+
+ lock (_sync) {
+ _queue.Enqueue(action);
+ if (_queue.Count == 1) {
+ // Only need to pulse on transition from 0 to 1, because the
+ // Doit method only Waits if the queue is empty.
+ Monitor.PulseAll(_sync);
+ }
+ }
+
+ // Appending to thread queue was required.
+ return true;
+ }
+
+ private void Doit() {
+ while (true) {
+ Action action;
+ lock (_sync) {
+ while (_queue.Count == 0) {
+ Monitor.Wait(_sync);
+ }
+
+ action = _queue.Dequeue();
+ }
+
+ try {
+ action();
+ } catch (Exception ex) {
+ Debug.WriteLine($"Swallowing exception {ex}");
+ }
+ }
+ }
+}
diff --git a/csharp/ExcelAddIn/viewmodels/ConnectionManagerDialogRow.cs b/csharp/ExcelAddIn/viewmodels/ConnectionManagerDialogRow.cs
new file mode 100644
index 00000000000..0e544131282
--- /dev/null
+++ b/csharp/ExcelAddIn/viewmodels/ConnectionManagerDialogRow.cs
@@ -0,0 +1,126 @@
+using Deephaven.ExcelAddIn.Factories;
+using Deephaven.ExcelAddIn.ViewModels;
+using System.ComponentModel;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Util;
+
+namespace Deephaven.ExcelAddIn.Viewmodels;
+
+public sealed class ConnectionManagerDialogRow(string id, StateManager stateManager) :
+ IObserver>, IObserver>,
+ INotifyPropertyChanged {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private readonly object _sync = new();
+ private StatusOr _credentials = StatusOr.OfStatus("[Not set]");
+ private StatusOr _session = StatusOr.OfStatus("[Not connected]");
+ private StatusOr _defaultCredentials = StatusOr.OfStatus("[Not set]");
+
+ public string Id { get; init; } = id;
+
+ public string Status {
+ get {
+ var session = GetSessionSynced();
+ // If we have a valid session, return "[Connected]", otherwise pass through the status text we have.
+ return session.AcceptVisitor(
+ _ => "[Connected]",
+ status => status);
+ }
+ }
+
+ public string ServerType {
+ get {
+ var creds = GetCredentialsSynced();
+ // Nested AcceptVisitor!!
+ // If we have valid credentials, determine whether they are for Core or Core+ and return the appropriate string.
+ // Otherwise (if we have invalid credentials), ignore their status text and just say "[Unknown]".
+ return creds.AcceptVisitor(
+ crs => crs.AcceptVisitor(_ => "Core", _ => "Core+"),
+ _ => "[Unknown]");
+
+ }
+ }
+
+ public bool IsDefault =>
+ _credentials.GetValueOrStatus(out var creds1, out _) &&
+ _defaultCredentials.GetValueOrStatus(out var creds2, out _) &&
+ creds1.Id == creds2.Id;
+
+ public void SettingsClicked() {
+ var creds = GetCredentialsSynced();
+ // If we have valid credentials,
+ var cvm = creds.AcceptVisitor(
+ crs => CredentialsDialogViewModel.OfIdAndCredentials(Id, crs),
+ _ => CredentialsDialogViewModel.OfIdButOtherwiseEmpty(Id));
+ var cd = CredentialsDialogFactory.Create(stateManager, cvm);
+ cd.Show();
+ }
+
+ public void ReconnectClicked() {
+ stateManager.Reconnect(new EndpointId(Id));
+ }
+
+ public void IsDefaultClicked() {
+ // If the box is already checked, do nothing.
+ if (IsDefault) {
+ return;
+ }
+
+ // If we don't have credentials, then we can't make them the default.
+ if (!_credentials.GetValueOrStatus(out var creds, out _)) {
+ return;
+ }
+
+ stateManager.SetDefaultCredentials(creds);
+ }
+
+ public void OnNext(StatusOr value) {
+ lock (_sync) {
+ _credentials = value;
+ }
+
+ OnPropertyChanged(nameof(ServerType));
+ OnPropertyChanged(nameof(IsDefault));
+ }
+
+ public void OnNext(StatusOr value) {
+ lock (_sync) {
+ _session = value;
+ }
+
+ OnPropertyChanged(nameof(Status));
+ }
+
+ public void SetDefaultCredentials(StatusOr creds) {
+ lock (_sync) {
+ _defaultCredentials = creds;
+ }
+ OnPropertyChanged(nameof(IsDefault));
+ }
+
+ public void OnCompleted() {
+ // TODO(kosak)
+ throw new NotImplementedException();
+ }
+
+ public void OnError(Exception error) {
+ // TODO(kosak)
+ throw new NotImplementedException();
+ }
+
+ private StatusOr GetCredentialsSynced() {
+ lock (_sync) {
+ return _credentials;
+ }
+ }
+
+ private StatusOr GetSessionSynced() {
+ lock (_sync) {
+ return _session;
+ }
+ }
+
+ private void OnPropertyChanged(string name) {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ }
+}
diff --git a/csharp/ExcelAddIn/viewmodels/CredentialsDialogViewModel.cs b/csharp/ExcelAddIn/viewmodels/CredentialsDialogViewModel.cs
new file mode 100644
index 00000000000..c25f6b2faa7
--- /dev/null
+++ b/csharp/ExcelAddIn/viewmodels/CredentialsDialogViewModel.cs
@@ -0,0 +1,207 @@
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using Deephaven.ExcelAddIn.Models;
+using Deephaven.ExcelAddIn.Util;
+
+namespace Deephaven.ExcelAddIn.ViewModels;
+
+public sealed class CredentialsDialogViewModel : INotifyPropertyChanged {
+ public static CredentialsDialogViewModel OfEmpty() {
+ return new CredentialsDialogViewModel();
+ }
+
+ public static CredentialsDialogViewModel OfIdButOtherwiseEmpty(string id) {
+ return new CredentialsDialogViewModel { Id = id };
+ }
+
+ public static CredentialsDialogViewModel OfIdAndCredentials(string id, CredentialsBase credentials) {
+ var result = new CredentialsDialogViewModel { Id = id };
+ _ = credentials.AcceptVisitor(
+ core => {
+ result._isCorePlus = false;
+ result.ConnectionString = core.ConnectionString;
+ return Unit.Instance;
+ },
+ corePlus => {
+ result._isCorePlus = true;
+ result.JsonUrl = corePlus.JsonUrl;
+ result.UserId = corePlus.User;
+ result.Password = corePlus.Password;
+ result.OperateAs = corePlus.OperateAs;
+ return Unit.Instance;
+ });
+
+ return result;
+ }
+
+ private string _id = "";
+ private bool _isDefault = false;
+ private bool _isCorePlus = true;
+
+ // Core properties
+ private string _connectionString = "";
+
+ // Core+ properties
+ private string _jsonUrl = "";
+ private string _userId = "";
+ private string _password = "";
+ private string _operateAs = "";
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public bool TryMakeCredentials([NotNullWhen(true)] out CredentialsBase? result,
+ [NotNullWhen(false)] out string? errorText) {
+ result = null;
+ errorText = null;
+
+ var missingFields = new List();
+ void CheckMissing(string field, string name) {
+ if (field.Length == 0) {
+ missingFields.Add(name);
+ }
+ }
+
+ CheckMissing(_id, "Connection Id");
+
+ if (!_isCorePlus) {
+ CheckMissing(_connectionString, "Connection String");
+ } else {
+ CheckMissing(_jsonUrl, "JSON URL");
+ CheckMissing(_userId, "User Id");
+ CheckMissing(_password, "Password");
+ CheckMissing(_operateAs, "Operate As");
+ }
+
+ if (missingFields.Count > 0) {
+ errorText = string.Join(", ", missingFields);
+ return false;
+ }
+
+ var epId = new EndpointId(_id);
+ result = _isCorePlus
+ ? CredentialsBase.OfCorePlus(epId, _jsonUrl, _userId, _password, _operateAs)
+ : CredentialsBase.OfCore(epId, _connectionString);
+ return true;
+ }
+
+ public string Id {
+ get => _id;
+ set {
+ if (value == _id) {
+ return;
+ }
+
+ _id = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsDefault {
+ get => _isDefault;
+ set {
+ if (value == _isDefault) {
+ return;
+ }
+
+ _isDefault = value;
+ OnPropertyChanged();
+ }
+ }
+
+ /**
+ * I don't know if I have to do it this way, but I bind IsCore and IsCorePlus to the
+ * same underlying variable. The property "IsCore" maps to the inverse of the variable
+ * _isCorePlus, meanwhile the property "IsCorePlus" maps to the normal sense of the
+ * variable. Setters on either one trigger property change events for both.
+ */
+ public bool IsCore {
+ get => !_isCorePlus;
+ set {
+ if (_isCorePlus == !value) {
+ return;
+ }
+
+ _isCorePlus = !value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsCorePlus));
+ }
+ }
+
+ public bool IsCorePlus {
+ get => _isCorePlus;
+ set {
+ if (_isCorePlus == value) {
+ return;
+ }
+
+ _isCorePlus = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsCore));
+ }
+ }
+
+ public string ConnectionString {
+ get => _connectionString;
+ set {
+ if (_connectionString == value) {
+ return;
+ }
+
+ _connectionString = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string JsonUrl {
+ get => _jsonUrl;
+ set {
+ if (_jsonUrl == value) {
+ return;
+ }
+
+ _jsonUrl = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string UserId {
+ get => _userId;
+ set {
+ if (_userId == value) {
+ return;
+ }
+
+ _userId = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string Password {
+ get => _password;
+ set {
+ if (_password == value) {
+ return;
+ }
+
+ _password = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string OperateAs {
+ get => _operateAs;
+ set {
+ if (_operateAs == value) {
+ return;
+ }
+
+ _operateAs = value;
+ OnPropertyChanged();
+ }
+ }
+
+ private void OnPropertyChanged([CallerMemberName] string? name = null) {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ }
+}
diff --git a/csharp/ExcelAddIn/views/ConnectionManagerDialog.Designer.cs b/csharp/ExcelAddIn/views/ConnectionManagerDialog.Designer.cs
new file mode 100644
index 00000000000..fee2096228f
--- /dev/null
+++ b/csharp/ExcelAddIn/views/ConnectionManagerDialog.Designer.cs
@@ -0,0 +1,87 @@
+namespace Deephaven.ExcelAddIn.Views {
+ partial class ConnectionManagerDialog {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing) {
+ if (disposing && (components != null)) {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ ///
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ ///
+ private void InitializeComponent() {
+ colorDialog1 = new ColorDialog();
+ dataGridView1 = new DataGridView();
+ newButton = new Button();
+ connectionsLabel = new Label();
+ ((System.ComponentModel.ISupportInitialize)dataGridView1).BeginInit();
+ SuspendLayout();
+ //
+ // dataGridView1
+ //
+ dataGridView1.AllowUserToAddRows = false;
+ dataGridView1.AllowUserToDeleteRows = false;
+ dataGridView1.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize;
+ dataGridView1.Location = new Point(68, 83);
+ dataGridView1.Name = "dataGridView1";
+ dataGridView1.ReadOnly = true;
+ dataGridView1.RowHeadersWidth = 62;
+ dataGridView1.Size = new Size(979, 454);
+ dataGridView1.TabIndex = 0;
+ //
+ // newButton
+ //
+ newButton.Location = new Point(869, 560);
+ newButton.Name = "newButton";
+ newButton.Size = new Size(178, 34);
+ newButton.TabIndex = 1;
+ newButton.Text = "New Connection";
+ newButton.UseVisualStyleBackColor = true;
+ newButton.Click += newButton_Click;
+ //
+ // connectionsLabel
+ //
+ connectionsLabel.AutoSize = true;
+ connectionsLabel.Font = new Font("Segoe UI", 12F, FontStyle.Regular, GraphicsUnit.Point, 0);
+ connectionsLabel.Location = new Point(68, 33);
+ connectionsLabel.Name = "connectionsLabel";
+ connectionsLabel.Size = new Size(147, 32);
+ connectionsLabel.TabIndex = 2;
+ connectionsLabel.Text = "Connections";
+ //
+ // ConnectionManagerDialog
+ //
+ AutoScaleDimensions = new SizeF(10F, 25F);
+ AutoScaleMode = AutoScaleMode.Font;
+ ClientSize = new Size(1115, 615);
+ Controls.Add(connectionsLabel);
+ Controls.Add(newButton);
+ Controls.Add(dataGridView1);
+ Name = "ConnectionManagerDialog";
+ Text = "Connection Manager";
+ ((System.ComponentModel.ISupportInitialize)dataGridView1).EndInit();
+ ResumeLayout(false);
+ PerformLayout();
+ }
+
+ #endregion
+
+ private ColorDialog colorDialog1;
+ private DataGridView dataGridView1;
+ private Button newButton;
+ private Label connectionsLabel;
+ }
+}
\ No newline at end of file
diff --git a/csharp/ExcelAddIn/views/ConnectionManagerDialog.cs b/csharp/ExcelAddIn/views/ConnectionManagerDialog.cs
new file mode 100644
index 00000000000..750fd39e0de
--- /dev/null
+++ b/csharp/ExcelAddIn/views/ConnectionManagerDialog.cs
@@ -0,0 +1,75 @@
+using Deephaven.ExcelAddIn.Viewmodels;
+
+namespace Deephaven.ExcelAddIn.Views;
+
+public partial class ConnectionManagerDialog : Form {
+ private const string IsDefaultColumnName = "IsDefault";
+ private const string SettingsButtonColumnName = "settings_button_column";
+ private const string ReconnectButtonColumnName = "reconnect_button_column";
+ private readonly Action _onNewButtonClicked;
+ private readonly BindingSource _bindingSource = new();
+
+ public ConnectionManagerDialog(Action onNewButtonClicked) {
+ _onNewButtonClicked = onNewButtonClicked;
+
+ InitializeComponent();
+
+ _bindingSource.DataSource = typeof(ConnectionManagerDialogRow);
+ dataGridView1.DataSource = _bindingSource;
+
+ var settingsButtonColumn = new DataGridViewButtonColumn {
+ Name = SettingsButtonColumnName,
+ HeaderText = "Credentials",
+ Text = "Edit",
+ UseColumnTextForButtonValue = true
+ };
+
+ var reconnectButtonColumn = new DataGridViewButtonColumn {
+ Name = ReconnectButtonColumnName,
+ HeaderText = "Reconnect",
+ Text = "Reconnect",
+ UseColumnTextForButtonValue = true
+ };
+
+ dataGridView1.Columns.Add(settingsButtonColumn);
+ dataGridView1.Columns.Add(reconnectButtonColumn);
+
+ dataGridView1.CellClick += DataGridView1_CellClick;
+ }
+
+ public void AddRow(ConnectionManagerDialogRow row) {
+ _bindingSource.Add(row);
+ }
+
+ private void DataGridView1_CellClick(object? sender, DataGridViewCellEventArgs e) {
+ if (e.RowIndex < 0) {
+ return;
+ }
+
+ if (_bindingSource[e.RowIndex] is not ConnectionManagerDialogRow row) {
+ return;
+ }
+ var name = dataGridView1.Columns[e.ColumnIndex].Name;
+
+ switch (name) {
+ case SettingsButtonColumnName: {
+ row.SettingsClicked();
+ break;
+ }
+
+ case ReconnectButtonColumnName: {
+ row.ReconnectClicked();
+ break;
+ }
+
+ case IsDefaultColumnName: {
+ row.IsDefaultClicked();
+ break;
+ }
+ }
+ }
+
+ private void newButton_Click(object sender, EventArgs e) {
+ _onNewButtonClicked();
+ }
+}
diff --git a/csharp/ExcelAddIn/views/ConnectionManagerDialog.resx b/csharp/ExcelAddIn/views/ConnectionManagerDialog.resx
new file mode 100644
index 00000000000..b3e33e7e100
--- /dev/null
+++ b/csharp/ExcelAddIn/views/ConnectionManagerDialog.resx
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ 17, 17
+
+
\ No newline at end of file
diff --git a/csharp/ExcelAddIn/views/CredentialsDialog.Designer.cs b/csharp/ExcelAddIn/views/CredentialsDialog.Designer.cs
new file mode 100644
index 00000000000..a554e61e2b2
--- /dev/null
+++ b/csharp/ExcelAddIn/views/CredentialsDialog.Designer.cs
@@ -0,0 +1,330 @@
+namespace ExcelAddIn.views {
+ partial class CredentialsDialog {
+ ///
+ /// Required designer variable.
+ ///
+ private System.ComponentModel.IContainer components = null;
+
+ ///
+ /// Clean up any resources being used.
+ ///
+ /// true if managed resources should be disposed; otherwise, false.
+ protected override void Dispose(bool disposing) {
+ if (disposing && (components != null)) {
+ components.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ #region Windows Form Designer generated code
+
+ ///
+ /// Required method for Designer support - do not modify
+ /// the contents of this method with the code editor.
+ ///
+ private void InitializeComponent() {
+ flowLayoutPanel1 = new FlowLayoutPanel();
+ corePlusPanel = new Panel();
+ operateAsBox = new TextBox();
+ passwordBox = new TextBox();
+ label5 = new Label();
+ label4 = new Label();
+ userIdBox = new TextBox();
+ userIdLabel = new Label();
+ jsonUrlBox = new TextBox();
+ label3 = new Label();
+ corePanel = new Panel();
+ connectionStringBox = new TextBox();
+ label2 = new Label();
+ finalPanel = new Panel();
+ testResultsTextBox = new TextBox();
+ testResultsLabel = new Label();
+ testCredentialsButton = new Button();
+ setCredentialsButton = new Button();
+ isCorePlusRadioButton = new RadioButton();
+ isCoreRadioButton = new RadioButton();
+ endpointIdBox = new TextBox();
+ label1 = new Label();
+ connectionTypeGroup = new GroupBox();
+ makeDefaultCheckBox = new CheckBox();
+ flowLayoutPanel1.SuspendLayout();
+ corePlusPanel.SuspendLayout();
+ corePanel.SuspendLayout();
+ finalPanel.SuspendLayout();
+ connectionTypeGroup.SuspendLayout();
+ SuspendLayout();
+ //
+ // flowLayoutPanel1
+ //
+ flowLayoutPanel1.Controls.Add(corePlusPanel);
+ flowLayoutPanel1.Controls.Add(corePanel);
+ flowLayoutPanel1.Controls.Add(finalPanel);
+ flowLayoutPanel1.Location = new Point(28, 160);
+ flowLayoutPanel1.Name = "flowLayoutPanel1";
+ flowLayoutPanel1.Size = new Size(994, 531);
+ flowLayoutPanel1.TabIndex = 200;
+ //
+ // corePlusPanel
+ //
+ corePlusPanel.Controls.Add(operateAsBox);
+ corePlusPanel.Controls.Add(passwordBox);
+ corePlusPanel.Controls.Add(label5);
+ corePlusPanel.Controls.Add(label4);
+ corePlusPanel.Controls.Add(userIdBox);
+ corePlusPanel.Controls.Add(userIdLabel);
+ corePlusPanel.Controls.Add(jsonUrlBox);
+ corePlusPanel.Controls.Add(label3);
+ corePlusPanel.Location = new Point(3, 3);
+ corePlusPanel.Name = "corePlusPanel";
+ corePlusPanel.Size = new Size(991, 242);
+ corePlusPanel.TabIndex = 210;
+ //
+ // operateAsBox
+ //
+ operateAsBox.Location = new Point(189, 183);
+ operateAsBox.Name = "operateAsBox";
+ operateAsBox.Size = new Size(768, 31);
+ operateAsBox.TabIndex = 214;
+ //
+ // passwordBox
+ //
+ passwordBox.Location = new Point(189, 130);
+ passwordBox.Name = "passwordBox";
+ passwordBox.PasswordChar = '●';
+ passwordBox.Size = new Size(768, 31);
+ passwordBox.TabIndex = 213;
+ //
+ // label5
+ //
+ label5.AutoSize = true;
+ label5.Location = new Point(18, 189);
+ label5.Name = "label5";
+ label5.Size = new Size(96, 25);
+ label5.TabIndex = 5;
+ label5.Text = "OperateAs";
+ //
+ // label4
+ //
+ label4.AutoSize = true;
+ label4.Location = new Point(18, 136);
+ label4.Name = "label4";
+ label4.Size = new Size(87, 25);
+ label4.TabIndex = 4;
+ label4.Text = "Password";
+ //
+ // userIdBox
+ //
+ userIdBox.Location = new Point(189, 82);
+ userIdBox.Name = "userIdBox";
+ userIdBox.Size = new Size(768, 31);
+ userIdBox.TabIndex = 212;
+ //
+ // userIdLabel
+ //
+ userIdLabel.AutoSize = true;
+ userIdLabel.Location = new Point(18, 88);
+ userIdLabel.Name = "userIdLabel";
+ userIdLabel.Size = new Size(63, 25);
+ userIdLabel.TabIndex = 2;
+ userIdLabel.Text = "UserId";
+ //
+ // jsonUrlBox
+ //
+ jsonUrlBox.Location = new Point(189, 33);
+ jsonUrlBox.Name = "jsonUrlBox";
+ jsonUrlBox.Size = new Size(768, 31);
+ jsonUrlBox.TabIndex = 211;
+ //
+ // label3
+ //
+ label3.AutoSize = true;
+ label3.Location = new Point(18, 33);
+ label3.Name = "label3";
+ label3.Size = new Size(91, 25);
+ label3.TabIndex = 0;
+ label3.Text = "JSON URL";
+ //
+ // corePanel
+ //
+ corePanel.Controls.Add(connectionStringBox);
+ corePanel.Controls.Add(label2);
+ corePanel.Location = new Point(3, 251);
+ corePanel.Name = "corePanel";
+ corePanel.Size = new Size(991, 76);
+ corePanel.TabIndex = 220;
+ //
+ // connectionStringBox
+ //
+ connectionStringBox.Location = new Point(189, 20);
+ connectionStringBox.Name = "connectionStringBox";
+ connectionStringBox.Size = new Size(768, 31);
+ connectionStringBox.TabIndex = 221;
+ //
+ // label2
+ //
+ label2.AutoSize = true;
+ label2.Location = new Point(18, 26);
+ label2.Name = "label2";
+ label2.Size = new Size(153, 25);
+ label2.TabIndex = 0;
+ label2.Text = "Connection String";
+ //
+ // finalPanel
+ //
+ finalPanel.Controls.Add(makeDefaultCheckBox);
+ finalPanel.Controls.Add(testResultsTextBox);
+ finalPanel.Controls.Add(testResultsLabel);
+ finalPanel.Controls.Add(testCredentialsButton);
+ finalPanel.Controls.Add(setCredentialsButton);
+ finalPanel.Location = new Point(3, 333);
+ finalPanel.Name = "finalPanel";
+ finalPanel.Size = new Size(991, 132);
+ finalPanel.TabIndex = 230;
+ //
+ // testResultsTextBox
+ //
+ testResultsTextBox.Location = new Point(189, 17);
+ testResultsTextBox.Name = "testResultsTextBox";
+ testResultsTextBox.ReadOnly = true;
+ testResultsTextBox.Size = new Size(768, 31);
+ testResultsTextBox.TabIndex = 7;
+ //
+ // testResultsLabel
+ //
+ testResultsLabel.AutoSize = true;
+ testResultsLabel.Location = new Point(125, 47);
+ testResultsLabel.Name = "testResultsLabel";
+ testResultsLabel.Size = new Size(0, 25);
+ testResultsLabel.TabIndex = 6;
+ //
+ // testCredentialsButton
+ //
+ testCredentialsButton.Location = new Point(8, 17);
+ testCredentialsButton.Name = "testCredentialsButton";
+ testCredentialsButton.Size = new Size(175, 34);
+ testCredentialsButton.TabIndex = 231;
+ testCredentialsButton.Text = "Test Credentials";
+ testCredentialsButton.UseVisualStyleBackColor = true;
+ testCredentialsButton.Click += testCredentialsButton_Click;
+ //
+ // setCredentialsButton
+ //
+ setCredentialsButton.Location = new Point(757, 65);
+ setCredentialsButton.Name = "setCredentialsButton";
+ setCredentialsButton.Size = new Size(200, 34);
+ setCredentialsButton.TabIndex = 232;
+ setCredentialsButton.Text = "Set Credentials";
+ setCredentialsButton.UseVisualStyleBackColor = true;
+ setCredentialsButton.Click += setCredentialsButton_Click;
+ //
+ // isCorePlusRadioButton
+ //
+ isCorePlusRadioButton.AutoSize = true;
+ isCorePlusRadioButton.Location = new Point(6, 39);
+ isCorePlusRadioButton.Name = "isCorePlusRadioButton";
+ isCorePlusRadioButton.Size = new Size(169, 29);
+ isCorePlusRadioButton.TabIndex = 110;
+ isCorePlusRadioButton.TabStop = true;
+ isCorePlusRadioButton.Text = "Enterprise Core+";
+ isCorePlusRadioButton.UseVisualStyleBackColor = true;
+ //
+ // isCoreRadioButton
+ //
+ isCoreRadioButton.AutoSize = true;
+ isCoreRadioButton.Location = new Point(195, 39);
+ isCoreRadioButton.Name = "isCoreRadioButton";
+ isCoreRadioButton.Size = new Size(172, 29);
+ isCoreRadioButton.TabIndex = 111;
+ isCoreRadioButton.TabStop = true;
+ isCoreRadioButton.Text = "Community Core";
+ isCoreRadioButton.UseVisualStyleBackColor = true;
+ //
+ // endpointIdBox
+ //
+ endpointIdBox.Location = new Point(220, 19);
+ endpointIdBox.Name = "endpointIdBox";
+ endpointIdBox.Size = new Size(768, 31);
+ endpointIdBox.TabIndex = 1;
+ //
+ // label1
+ //
+ label1.AutoSize = true;
+ label1.Location = new Point(34, 22);
+ label1.Name = "label1";
+ label1.Size = new Size(125, 25);
+ label1.TabIndex = 5;
+ label1.Text = "Connection ID";
+ //
+ // connectionTypeGroup
+ //
+ connectionTypeGroup.Controls.Add(isCorePlusRadioButton);
+ connectionTypeGroup.Controls.Add(isCoreRadioButton);
+ connectionTypeGroup.Location = new Point(28, 74);
+ connectionTypeGroup.Name = "connectionTypeGroup";
+ connectionTypeGroup.Size = new Size(588, 80);
+ connectionTypeGroup.TabIndex = 100;
+ connectionTypeGroup.TabStop = false;
+ connectionTypeGroup.Text = "Connection Type";
+ //
+ // makeDefaultCheckBox
+ //
+ makeDefaultCheckBox.AutoSize = true;
+ makeDefaultCheckBox.Location = new Point(599, 70);
+ makeDefaultCheckBox.Name = "makeDefaultCheckBox";
+ makeDefaultCheckBox.Size = new Size(143, 29);
+ makeDefaultCheckBox.TabIndex = 234;
+ makeDefaultCheckBox.Text = "Make Default";
+ makeDefaultCheckBox.UseVisualStyleBackColor = true;
+ //
+ // CredentialsDialog
+ //
+ AutoScaleDimensions = new SizeF(10F, 25F);
+ AutoScaleMode = AutoScaleMode.Font;
+ ClientSize = new Size(1086, 714);
+ Controls.Add(connectionTypeGroup);
+ Controls.Add(label1);
+ Controls.Add(endpointIdBox);
+ Controls.Add(flowLayoutPanel1);
+ Name = "CredentialsDialog";
+ Text = "Credentials Editor";
+ flowLayoutPanel1.ResumeLayout(false);
+ corePlusPanel.ResumeLayout(false);
+ corePlusPanel.PerformLayout();
+ corePanel.ResumeLayout(false);
+ corePanel.PerformLayout();
+ finalPanel.ResumeLayout(false);
+ finalPanel.PerformLayout();
+ connectionTypeGroup.ResumeLayout(false);
+ connectionTypeGroup.PerformLayout();
+ ResumeLayout(false);
+ PerformLayout();
+ }
+
+ #endregion
+
+ private FlowLayoutPanel flowLayoutPanel1;
+ private RadioButton isCorePlusRadioButton;
+ private RadioButton isCoreRadioButton;
+ private Button setCredentialsButton;
+ private TextBox endpointIdBox;
+ private Label label1;
+ private GroupBox connectionTypeGroup;
+ private Panel corePlusPanel;
+ private Panel corePanel;
+ private Label label3;
+ private Label label2;
+ private TextBox jsonUrlBox;
+ private TextBox connectionStringBox;
+ private Label label4;
+ private TextBox userIdBox;
+ private Label userIdLabel;
+ private TextBox operateAsBox;
+ private TextBox passwordBox;
+ private Label label5;
+ private Panel finalPanel;
+ private Button testCredentialsButton;
+ private Label testResultsLabel;
+ private TextBox testResultsTextBox;
+ private CheckBox makeDefaultCheckBox;
+ }
+}
\ No newline at end of file
diff --git a/csharp/ExcelAddIn/views/CredentialsDialog.cs b/csharp/ExcelAddIn/views/CredentialsDialog.cs
new file mode 100644
index 00000000000..1453b8703c4
--- /dev/null
+++ b/csharp/ExcelAddIn/views/CredentialsDialog.cs
@@ -0,0 +1,59 @@
+using Deephaven.ExcelAddIn.ViewModels;
+
+namespace ExcelAddIn.views {
+ public partial class CredentialsDialog : Form {
+ private readonly Action _onSetCredentialsButtonClicked;
+ private readonly Action _onTestCredentialsButtonClicked;
+
+ public CredentialsDialog(CredentialsDialogViewModel vm, Action onSetCredentialsButtonClicked,
+ Action onTestCredentialsButtonClicked) {
+ _onSetCredentialsButtonClicked = onSetCredentialsButtonClicked;
+ _onTestCredentialsButtonClicked = onTestCredentialsButtonClicked;
+
+ InitializeComponent();
+ // Need to fire these bindings on property changed rather than simply on validation,
+ // because on validation is not responsive enough. Also, painful technical note:
+ // being a member of connectionTypeGroup *also* ensures that at most one of these buttons
+ // are checked. So you might think databinding is not necessary. However being in
+ // a group does nothing for the initial conditions. So the group doesn't care if
+ // *neither* of them are checked.
+ isCorePlusRadioButton.DataBindings.Add(new Binding(nameof(isCorePlusRadioButton.Checked),
+ vm, nameof(vm.IsCorePlus), false, DataSourceUpdateMode.OnPropertyChanged));
+ isCoreRadioButton.DataBindings.Add(new Binding(nameof(isCorePlusRadioButton.Checked),
+ vm, nameof(vm.IsCore), false, DataSourceUpdateMode.OnPropertyChanged));
+
+ // Make one of the two panels visible, according to the setting of the radio box.
+ corePlusPanel.DataBindings.Add(nameof(corePlusPanel.Visible), vm, nameof(vm.IsCorePlus));
+ corePanel.DataBindings.Add(nameof(corePanel.Visible), vm, nameof(vm.IsCore));
+
+ // Bind the ID
+ endpointIdBox.DataBindings.Add(nameof(endpointIdBox.Text), vm, nameof(vm.Id));
+
+ // Bind the Core+ properties
+ jsonUrlBox.DataBindings.Add(nameof(jsonUrlBox.Text), vm, nameof(vm.JsonUrl));
+ userIdBox.DataBindings.Add(nameof(userIdBox.Text), vm, nameof(vm.UserId));
+ passwordBox.DataBindings.Add(nameof(passwordBox.Text), vm, nameof(vm.Password));
+ operateAsBox.DataBindings.Add(nameof(operateAsBox.Text), vm, nameof(vm.OperateAs));
+
+ // Bind the Core property (there's just one)
+ connectionStringBox.DataBindings.Add(nameof(connectionStringBox.Text),
+ vm, nameof(vm.ConnectionString));
+
+ // Bind the IsDefault property
+ makeDefaultCheckBox.DataBindings.Add(nameof(makeDefaultCheckBox.Checked),
+ vm, nameof(vm.IsDefault));
+ }
+
+ public void SetTestResultsBox(string painState) {
+ Invoke(() => testResultsTextBox.Text = painState);
+ }
+
+ private void setCredentialsButton_Click(object sender, EventArgs e) {
+ _onSetCredentialsButtonClicked();
+ }
+
+ private void testCredentialsButton_Click(object sender, EventArgs e) {
+ _onTestCredentialsButtonClicked();
+ }
+ }
+}
diff --git a/csharp/ExcelAddIn/views/CredentialsDialog.resx b/csharp/ExcelAddIn/views/CredentialsDialog.resx
new file mode 100644
index 00000000000..b92c16350e8
--- /dev/null
+++ b/csharp/ExcelAddIn/views/CredentialsDialog.resx
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
\ No newline at end of file