connection string/url parsing and generation in the server configuration dialog

This commit is contained in:
eelke 2025-08-31 10:12:22 +02:00
parent a5cb6ef7d4
commit 1d53ca2fc2
11 changed files with 410 additions and 62 deletions

View file

@ -37,5 +37,7 @@
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="Avalonia.Headless" Version="11.3.4" />
<PackageVersion Include="Avalonia.Headless.XUnit" Version="11.3.4" />
</ItemGroup>
</Project>

View file

@ -38,30 +38,42 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
// Hosts and Ports
if (dict.TryGetValue("Host", out var hostVal) || dict.TryGetValue("Server", out hostVal) || dict.TryGetValue("Servers", out hostVal))
{
var hosts = SplitList(hostVal).ToList();
List<ushort?> portsPerHost = new();
var rawHosts = SplitList(hostVal).ToList();
var hosts = new List<string>(rawHosts.Count);
var portsPerHost = new List<ushort?>(rawHosts.Count);
// First, extract inline ports from each host entry (e.g., host:5432 or [::1]:5432)
foreach (var raw in rawHosts)
{
ParseHostPort(raw, out var hostOnly, out var inlinePort);
hosts.Add(hostOnly);
portsPerHost.Add(inlinePort);
}
// Then, merge values from Port key: single port applies to all hosts missing a port;
// list of ports applies 1:1 for hosts that still miss a port. Inline ports take precedence.
if (dict.TryGetValue("Port", out var portVal))
{
var ports = SplitList(portVal).ToList();
if (ports.Count == 1 && ushort.TryParse(ports[0], out var singlePort))
if (ports.Count == 1 && ushort.TryParse(ports[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var singlePort))
{
foreach (var _ in hosts) portsPerHost.Add(singlePort);
for (int i = 0; i < portsPerHost.Count; i++)
if (!portsPerHost[i].HasValue)
portsPerHost[i] = singlePort;
}
else if (ports.Count == hosts.Count)
{
foreach (var p in ports)
for (int i = 0; i < ports.Count; i++)
{
if (ushort.TryParse(p, NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
portsPerHost.Add(up);
else
portsPerHost.Add(null);
if (!portsPerHost[i].HasValue && ushort.TryParse(ports[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var up))
portsPerHost[i] = up;
}
}
}
for (int i = 0; i < hosts.Count; i++)
{
ushort? port = i < portsPerHost.Count ? portsPerHost[i] : null;
descriptor.AddHost(hosts[i], port);
descriptor.AddHost(hosts[i], i < portsPerHost.Count ? portsPerHost[i] : null);
}
}
@ -164,6 +176,42 @@ public sealed class NpgsqlCodec : IConnectionStringCodec
return s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static void ParseHostPort(string hostPart, out string host, out ushort? port)
{
host = hostPart;
port = null;
if (string.IsNullOrWhiteSpace(hostPart)) return;
// IPv6 in brackets: [::1]:5432
if (hostPart[0] == '[')
{
int end = hostPart.IndexOf(']');
if (end > 0)
{
host = hostPart.Substring(1, end - 1);
if (end + 1 < hostPart.Length && hostPart[end + 1] == ':')
{
var ps = hostPart.Substring(end + 2);
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
port = p;
}
}
return;
}
// Non-IPv6: split on last ':' and ensure right side is numeric
int colon = hostPart.LastIndexOf(':');
if (colon > 0 && colon < hostPart.Length - 1)
{
var ps = hostPart.Substring(colon + 1);
if (ushort.TryParse(ps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
{
host = hostPart.Substring(0, colon);
port = p;
}
}
}
private static bool TryGetFirst(Dictionary<string, string> dict, out string value, params string[] keys)
{
foreach (var k in keys)

View file

@ -16,6 +16,9 @@ public sealed class LibpqCodec : IConnectionStringCodec
{
try
{
// Reject Npgsql-style strings that use ';' separators when forcing libpq
if (input.IndexOf(';') >= 0)
return Result.Fail<ConnectionDescriptor>("Semicolons are not valid separators in libpq connection strings");
var kv = new PqConnectionStringParser(new PqConnectionStringTokenizer(input)).Parse();
// libpq keywords are case-insensitive; normalize to lower for lookup

View file

@ -67,13 +67,29 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
{
while (position < input.Length && char.IsWhiteSpace(input[position]))
position++;
// If a semicolon is encountered between pairs (which is not valid in libpq),
// treat as immediate EOF so parser stops and leaves trailing data unparsed.
if (position < input.Length && input[position] == ';')
{
position = input.Length; // force EOF
}
}
private string UnquotedString(bool forKeyword)
{
int start = position;
while (++position < input.Length && !char.IsWhiteSpace(input[position]) && (!forKeyword || input[position] != '='))
{ }
while (++position < input.Length)
{
char c = input[position];
// Libpq syntax does not use semicolons as pair separators; treat ';' as invalid here
if (c == ';')
{
// Force tokenizer to stop and later cause a parse error by making GetValue/keyword incomplete
break;
}
if (char.IsWhiteSpace(c)) break;
if (forKeyword && c == '=') break;
}
return input.Substring(start, position - start);
}

View file

@ -0,0 +1,87 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using pgLabII.Model;
using pgLabII.ViewModels;
using pgLabII.Views;
using Xunit;
namespace pgLabII.Tests.Views;
public class EditServerConfigurationWindowTests
{
[AvaloniaFact]
public void Parse_and_Generate_roundtrip_via_UI_bindings()
{
// Arrange: initialize Avalonia headless app once for the test
var vm = new EditServerConfigurationViewModel(new ServerConfiguration());
var window = new EditServerConfigurationWindow(vm);
// Act: set an URL input, auto mode, then parse
vm.InputConnectionString = "postgresql://user:pass@localhost:5433/mydb?sslmode=require";
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto;
vm.ParseConnectionStringCommand.Execute().Subscribe();
// Assert fields updated
Assert.Equal("localhost", vm.Configuration.Host);
Assert.Equal((ushort)5433, vm.Configuration.Port);
Assert.Equal("mydb", vm.Configuration.InitialDatabase);
Assert.Equal("user", vm.Configuration.User.Name);
Assert.Equal("pass", vm.Configuration.User.Password);
Assert.Equal(Npgsql.SslMode.Require, vm.Configuration.DefaultSslMode);
// Generate back as libpq and validate
vm.OutputFormat = pgLabII.PgUtils.ConnectionStrings.ConnStringFormat.Libpq;
vm.GenerateConnectionStringCommand.Execute().Subscribe();
var outStr = vm.OutputConnectionString;
Assert.Contains("host=localhost", outStr);
Assert.Contains("port=5433", outStr);
Assert.Contains("dbname=mydb", outStr);
Assert.Contains("user=user", outStr);
Assert.Contains("password=pass", outStr);
Assert.Contains("sslmode=require", outStr);
window.Close();
}
[AvaloniaFact]
public void Forced_format_overrides_auto_detection()
{
var vm = new EditServerConfigurationViewModel(new ServerConfiguration());
// A semicolon-separated string that could be auto-detected as Npgsql
vm.InputConnectionString = "Host=server;Username=bob;Password=secret;Database=db1;SSL Mode=Require";
// Force interpret as libpq should fail to parse (libpq uses spaces) and keep defaults
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Libpq;
vm.ParseConnectionStringCommand.Execute().Subscribe();
// Since forced libpq parse would fail, configuration should remain default (Host empty)
Assert.True(string.IsNullOrEmpty(vm.Configuration.Host));
// Now set to Auto and parse again -> should detect Npgsql and parse
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto;
vm.ParseConnectionStringCommand.Execute().Subscribe();
Assert.Equal("server", vm.Configuration.Host);
Assert.Equal("db1", vm.Configuration.InitialDatabase);
Assert.Equal("bob", vm.Configuration.User.Name);
}
[AvaloniaFact]
public void Parse_Npgsql_with_inline_host_port_updates_all_fields()
{
var vm = new EditServerConfigurationViewModel(new ServerConfiguration());
vm.InputConnectionString = "Host=host.docker.internal:5432;Database=kms_quartz;Username=postgres;Password=admin;Trust Server Certificate=true";
vm.ForcedFormat = EditServerConfigurationViewModel.ForcedFormatOption.Auto;
vm.ParseConnectionStringCommand.Execute().Subscribe();
Assert.Equal("host.docker.internal", vm.Configuration.Host);
Assert.Equal((ushort)5432, vm.Configuration.Port);
Assert.Equal("kms_quartz", vm.Configuration.InitialDatabase);
Assert.Equal("postgres", vm.Configuration.User.Name);
Assert.Equal("admin", vm.Configuration.User.Password);
}
}

View file

@ -15,6 +15,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" />
</ItemGroup>
<ItemGroup>

View file

@ -24,7 +24,9 @@ public class LocalDb : DbContext
// The following configures EF to create a Sqlite database file in the
// special "local" folder for your platform.
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlite($"Data Source={DbPath}");
{
options.UseSqlite($"Data Source={DbPath}");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{

View file

@ -7,20 +7,20 @@ using Npgsql;
using pgLabII.ViewModels;
using pgLabII.Views;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
namespace pgLabII.Model;
public class ServerConfiguration : ReactiveObject
public partial class ServerConfiguration : ReactiveObject
{
private Color _color;
private bool _colorEnabled = true;
private string initialDatabase = "";
public Guid Id { get; set; } = Guid.NewGuid();
[Reactive] private Guid _id = Guid.NewGuid();
/// <summary>
/// For the user to help him identify the item
/// </summary>
public string Name { get; set; } = "";
[Reactive] private string _name = "";
public Color Color
{
@ -50,21 +50,19 @@ public class ServerConfiguration : ReactiveObject
}
}
public string Host { get; set; } = "";
public ushort Port { get; set; } = 5432;
public string InitialDatabase
[Reactive] private string _host = "";
[Reactive] private ushort _port = 5432;
[Reactive] private string _initialDatabase = "";
[Reactive] private SslMode _sslMode = SslMode.Prefer;
// Explicit property wrapper to make compiled XAML binding findable
public SslMode DefaultSslMode
{
get => initialDatabase;
set
{
if (initialDatabase != value)
{
initialDatabase = value;
this.RaisePropertyChanged();
}
}
get => _sslMode;
set => this.RaiseAndSetIfChanged(ref _sslMode, value);
}
public SslMode DefaultSslMode { get; set; } = SslMode.Prefer;
public IBrush? BackgroundBrush => ColorEnabled ? new SolidColorBrush(Color) : null;
public ServerUser User { get; set; } = new();

View file

@ -1,5 +1,11 @@
using System.Reactive;
using System;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using Npgsql;
using pgLabII.Model;
using pgLabII.PgUtils.ConnectionStrings;
using pgLabII.Services;
using ReactiveUI;
namespace pgLabII.ViewModels;
@ -8,19 +14,86 @@ public class EditServerConfigurationViewModel : ViewModelBase
{
public ServerConfiguration Configuration { get; set; }
// Connection string IO
private string _inputConnectionString = string.Empty;
public string InputConnectionString
{
get => _inputConnectionString;
set
{
this.RaiseAndSetIfChanged(ref _inputConnectionString, value);
// auto-detect when input changes and we are in Auto mode
if (ForcedFormat == ForcedFormatOption.Auto)
{
DetectFormat();
}
}
}
public enum ForcedFormatOption
{
Auto,
Libpq,
Npgsql,
Url
}
private ForcedFormatOption _forcedFormat = ForcedFormatOption.Auto;
public ForcedFormatOption ForcedFormat
{
get => _forcedFormat;
set
{
this.RaiseAndSetIfChanged(ref _forcedFormat, value);
// When forcing off Auto, clear detected label; when switching to Auto, re-detect
if (value == ForcedFormatOption.Auto)
DetectFormat();
else
DetectedFormat = null;
}
}
private ConnStringFormat? _detectedFormat;
public ConnStringFormat? DetectedFormat
{
get => _detectedFormat;
private set => this.RaiseAndSetIfChanged(ref _detectedFormat, value);
}
private ConnStringFormat _outputFormat = ConnStringFormat.Url;
public ConnStringFormat OutputFormat
{
get => _outputFormat;
set => this.RaiseAndSetIfChanged(ref _outputFormat, value);
}
private string _outputConnectionString = string.Empty;
public string OutputConnectionString
{
get => _outputConnectionString;
set => this.RaiseAndSetIfChanged(ref _outputConnectionString, value);
}
public ReactiveCommand<Unit, Unit> ParseConnectionStringCommand { get; }
public ReactiveCommand<Unit, Unit> GenerateConnectionStringCommand { get; }
public ReactiveCommand<Unit, Unit> CopyOutputConnectionStringCommand { get; }
public ReactiveCommand<Unit, Unit> SaveAndCloseCommand { get; }
public ReactiveCommand<Unit, Unit> CloseCommand { get; }
private readonly IConnectionStringService _service;
public EditServerConfigurationViewModel()
{
Configuration = new();
_service = ConnectionStringService.CreateDefault();
SaveAndCloseCommand = ReactiveCommand.Create(() =>
{
});
CloseCommand = ReactiveCommand.Create(() =>
{
});
ParseConnectionStringCommand = ReactiveCommand.Create(ParseConnectionString);
GenerateConnectionStringCommand = ReactiveCommand.Create(GenerateConnectionString);
CopyOutputConnectionStringCommand = ReactiveCommand.Create(() => { /* no-op placeholder */ });
SaveAndCloseCommand = ReactiveCommand.Create(() => { });
CloseCommand = ReactiveCommand.Create(() => { });
}
public EditServerConfigurationViewModel(ServerConfiguration configuration)
@ -29,5 +102,69 @@ public class EditServerConfigurationViewModel : ViewModelBase
Configuration = configuration;
}
private void DetectFormat()
{
if (string.IsNullOrWhiteSpace(InputConnectionString))
{
DetectedFormat = null;
return;
}
var res = _service.DetectFormat(InputConnectionString);
DetectedFormat = res.IsSuccess ? res.Value : null;
}
private void ParseConnectionString()
{
if (string.IsNullOrWhiteSpace(InputConnectionString)) return;
var forced = ForcedFormat;
ConnectionDescriptor? descriptor = null;
if (forced != ForcedFormatOption.Auto)
{
IConnectionStringCodec codec = forced switch
{
ForcedFormatOption.Libpq => new LibpqCodec(),
ForcedFormatOption.Npgsql => new NpgsqlCodec(),
ForcedFormatOption.Url => new UrlCodec(),
_ => new UrlCodec()
};
var r = codec.TryParse(InputConnectionString);
if (r.IsSuccess) descriptor = r.Value;
}
else
{
var r = _service.ParseToDescriptor(InputConnectionString);
if (r.IsSuccess) descriptor = r.Value;
}
if (descriptor != null)
{
// Map into our configuration (update existing)
ServerConfigurationMapping.FromDescriptor(descriptor, Configuration);
// Also set sensible default OutputFormat to the detected/forced one
if (forced == ForcedFormatOption.Auto)
{
if (DetectedFormat.HasValue) OutputFormat = DetectedFormat.Value;
}
else
{
OutputFormat = forced switch
{
ForcedFormatOption.Libpq => ConnStringFormat.Libpq,
ForcedFormatOption.Npgsql => ConnStringFormat.Npgsql,
ForcedFormatOption.Url => ConnStringFormat.Url,
_ => ConnStringFormat.Url
};
}
}
}
private void GenerateConnectionString()
{
// Build descriptor from current configuration
var descriptor = ServerConfigurationMapping.ToDescriptor(Configuration);
var r = _service.FormatFromDescriptor(descriptor, OutputFormat);
if (r.IsSuccess)
OutputConnectionString = r.Value;
}
}

View file

@ -18,6 +18,7 @@ public class ServerListViewModel : ViewModelBase
ColorEnabled = true,
Host = "localhost",
Port = 5434,
InitialDatabase = "dbname",
User = new ()
{
Name = "postgres",
@ -46,8 +47,8 @@ public class ServerListViewModel : ViewModelBase
AddServerCommand = ReactiveCommand.Create(() =>
{
ServerConfiguration sc = new();
EditServerConfigurationWindow window = new() { DataContext = sc, New = true };
EditServerConfigurationViewModel vm = new();
EditServerConfigurationWindow window = new() { DataContext = vm, New = true };
window.Show();
});
}

View file

@ -2,37 +2,89 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="650"
xmlns:vm="clr-namespace:pgLabII.ViewModels"
x:DataType="vm:EditServerConfigurationViewModel"
x:Class="pgLabII.Views.EditServerConfigurationWindow"
Title="EditServerConfiguration"
Title="Edit Server Configuration"
SizeToContent="WidthAndHeight">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:EditServerConfigurationViewModel />
</Design.DataContext>
<StackPanel >
<TextBlock>Name:</TextBlock>
<TextBox Text="{Binding Configuration.Name}"/>
<Grid Margin="12" RowDefinitions="Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="*">
<!-- Basic Details -->
<StackPanel Grid.Row="0" Spacing="6">
<TextBlock FontWeight="Bold" Text="Details" Margin="0,0,0,4"/>
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Name"/>
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding Configuration.Name}"/>
<TextBlock>Color:</TextBlock>
<StackPanel Orientation="Horizontal">
<CheckBox IsChecked="{Binding Configuration.ColorEnabled}"/>
<ColorPicker Color="{Binding Configuration.Color}"
/>
<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Color"/>
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Spacing="6">
<CheckBox IsChecked="{Binding Configuration.ColorEnabled}"/>
<ColorPicker Color="{Binding Configuration.Color}"/>
</StackPanel>
<TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Database"/>
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding Configuration.InitialDatabase}"/>
<TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="SSL Mode"/>
<ComboBox Grid.Row="3" Grid.Column="1" SelectedIndex="{Binding Configuration.DefaultSslMode}">
<!-- Order must match Npgsql.SslMode enum: Disable(0), Allow(1), Prefer(2), Require(3), VerifyCA(4), VerifyFull(5) -->
<ComboBoxItem>Disable</ComboBoxItem>
<ComboBoxItem>Allow</ComboBoxItem>
<ComboBoxItem>Prefer</ComboBoxItem>
<ComboBoxItem>Require</ComboBoxItem>
<ComboBoxItem>VerifyCA</ComboBoxItem>
<ComboBoxItem>VerifyFull</ComboBoxItem>
</ComboBox>
<TextBlock Grid.Row="0" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="Host"/>
<TextBox Grid.Row="0" Grid.Column="3" Text="{Binding Configuration.Host}"/>
<TextBlock Grid.Row="1" Grid.Column="2" VerticalAlignment="Center" Margin="16,0,8,0" Text="Port"/>
<NumericUpDown Grid.Row="1" Grid.Column="3" Value="{Binding Configuration.Port}" Minimum="0" Maximum="65535" Increment="1"/>
</Grid>
</StackPanel>
<TextBlock>Host:</TextBlock>
<TextBox Text="{Binding Configuration.Host}"/>
<!-- Input Connection String -->
<StackPanel Grid.Row="1" Margin="0,12,0,0" Spacing="6">
<TextBlock FontWeight="Bold" Text="Connection string input"/>
<TextBlock Text="Paste an existing connection string (libpq, Npgsql, or URL)."/>
<TextBox TextWrapping="Wrap" AcceptsReturn="True" MinHeight="60" Text="{Binding InputConnectionString}"/>
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Interpret as"/>
<!-- Bind SelectedIndex to enum (Auto=0, Libpq=1, Npgsql=2, Url=3) -->
<ComboBox Grid.Column="1" SelectedIndex="{Binding ForcedFormat}">
<ComboBoxItem>Auto</ComboBoxItem>
<ComboBoxItem>Libpq</ComboBoxItem>
<ComboBoxItem>Npgsql</ComboBoxItem>
<ComboBoxItem>URL</ComboBoxItem>
</ComboBox>
<TextBlock Grid.Column="2" Margin="12,0,8,0" VerticalAlignment="Center" Text="Detected:"/>
<TextBlock Grid.Column="3" VerticalAlignment="Center" Text="{Binding DetectedFormat}"/>
<Button Grid.Column="4" Margin="12,0,0,0" Content="Parse into fields" Command="{Binding ParseConnectionStringCommand}"/>
</Grid>
</StackPanel>
<TextBlock>Port:</TextBlock>
<NumericUpDown Value="{Binding Configuration.Port}" Minimum="0" Maximum="65535" Increment="1"/>
<!-- Output Connection String -->
<StackPanel Grid.Row="2" Margin="0,12,0,0" Spacing="6">
<TextBlock FontWeight="Bold" Text="Connection string output"/>
<Grid ColumnDefinitions="Auto,*,Auto,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0" Text="Format"/>
<!-- ConnStringFormat: Libpq=0, Npgsql=1, Url=2 -->
<ComboBox Grid.Column="1" SelectedIndex="{Binding OutputFormat}">
<ComboBoxItem>Libpq</ComboBoxItem>
<ComboBoxItem>Npgsql</ComboBoxItem>
<ComboBoxItem>URL</ComboBoxItem>
</ComboBox>
<Button Grid.Column="2" Margin="12,0,0,0" Content="Generate" Command="{Binding GenerateConnectionStringCommand}"/>
<Button Grid.Column="3" Margin="6,0,0,0" Content="Copy" Command="{Binding CopyOutputConnectionStringCommand}"/>
</Grid>
<TextBox TextWrapping="Wrap" AcceptsReturn="True" MinHeight="60" IsReadOnly="True" Text="{Binding OutputConnectionString}"/>
</StackPanel>
<TextBlock>Database:</TextBlock>
<TextBox Text="{Binding Configuration.InitialDatabase}"/>
</StackPanel>
<!-- Spacer / Future buttons row could go here -->
</Grid>
</Window>