diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
new file mode 100644
index 0000000..e50879f
--- /dev/null
+++ b/THIRD-PARTY-NOTICES.md
@@ -0,0 +1,35 @@
+# Third-Party Notices
+
+This project incorporates material from the projects listed below.
+
+---
+
+## SqlFormatter
+
+- **Source:** https://github.com/madskristensen/SqlFormatter
+- **License:** MIT (Apache-2.0 per repository; individual files carry MIT terms)
+- **Copyright:** Copyright (c) Mads Kristensen
+
+The `SqlFormattingService` in this project was inspired by and partially derived
+from the SqlFormatter extension for Visual Studio by Mads Kristensen. It uses
+`Microsoft.SqlServer.TransactSql.ScriptDom` for T-SQL parsing and formatting.
+
+### MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml b/src/PlanViewer.App/Controls/QuerySessionControl.axaml
index 9d44cc6..46fa6bf 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml
@@ -69,6 +69,16 @@
Height="28" Padding="10,0" FontSize="12" IsEnabled="False"
Theme="{StaticResource AppButton}"
ToolTip.Tip="Execute the repro script and capture actual plan with runtime stats"/>
+
+
+
SqlFormattingService.Format(sql, settings));
+
+ if (errors != null && errors.Count > 0)
+ {
+ var errorMessages = string.Join("\n", errors.Select(err => $"Line {err.Line}: {err.Message}"));
+ var dialog = new Window
+ {
+ Title = "SQL Format Error",
+ Width = 500,
+ Height = 250,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Icon = GetParentWindow().Icon,
+ Background = (IBrush)this.FindResource("BackgroundBrush")!,
+ Foreground = (IBrush)this.FindResource("ForegroundBrush")!,
+ Content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Children =
+ {
+ new TextBlock
+ {
+ Text = $"Could not format: {errors.Count} parse error(s)",
+ FontWeight = Avalonia.Media.FontWeight.Bold,
+ FontSize = 14,
+ Margin = new Avalonia.Thickness(0, 0, 0, 10)
+ },
+ new TextBlock
+ {
+ Text = errorMessages,
+ TextWrapping = TextWrapping.Wrap,
+ FontSize = 12
+ }
+ }
+ }
+ };
+ dialog.ShowDialog(GetParentWindow());
+ SetStatus($"Format failed: {errors.Count} error(s)");
+ return;
+ }
+
+ var caretOffset = QueryEditor.CaretOffset;
+
+ QueryEditor.Document.BeginUpdate();
+ try
+ {
+ QueryEditor.Document.Replace(0, QueryEditor.Document.TextLength, formatted);
+ }
+ finally
+ {
+ QueryEditor.Document.EndUpdate();
+ }
+
+ QueryEditor.CaretOffset = Math.Min(caretOffset, QueryEditor.Document.TextLength);
+ SetStatus("Formatted");
+ }
+ finally
+ {
+ FormatButton.IsEnabled = true;
+ }
+ }
+
+ private void FormatOptions_Click(object? sender, RoutedEventArgs e)
+ {
+ var dialog = new Dialogs.FormatOptionsWindow();
+ dialog.ShowDialog(GetParentWindow());
+ }
}
diff --git a/src/PlanViewer.App/Dialogs/FormatOptionsWindow.axaml b/src/PlanViewer.App/Dialogs/FormatOptionsWindow.axaml
new file mode 100644
index 0000000..78594a4
--- /dev/null
+++ b/src/PlanViewer.App/Dialogs/FormatOptionsWindow.axaml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Dialogs/FormatOptionsWindow.axaml.cs b/src/PlanViewer.App/Dialogs/FormatOptionsWindow.axaml.cs
new file mode 100644
index 0000000..0e4e24a
--- /dev/null
+++ b/src/PlanViewer.App/Dialogs/FormatOptionsWindow.axaml.cs
@@ -0,0 +1,326 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using PlanViewer.App.Services;
+
+namespace PlanViewer.App.Dialogs;
+
+public partial class FormatOptionsWindow : Window
+{
+ private readonly ObservableCollection _rows = new();
+ private readonly SqlFormatSettings _defaults = new();
+
+ private bool _isDirty;
+
+ public FormatOptionsWindow()
+ {
+ InitializeComponent();
+ LoadSettings();
+ }
+
+ // Explicit ordering — reflection doesn't guarantee declaration order
+ private static readonly string[] PropertyOrder =
+ [
+ "KeywordCasing", "SqlVersion", "IndentationSize",
+ "AlignClauseBodies", "AlignColumnDefinitionFields", "AlignSetClauseItem",
+ "AsKeywordOnOwnLine", "IncludeSemicolons",
+ "IndentSetClause", "IndentViewBody",
+ "MultilineInsertSourcesList", "MultilineInsertTargetsList",
+ "MultilineSelectElementsList", "MultilineSetClauseItems",
+ "MultilineViewColumnsList", "MultilineWherePredicatesList",
+ "NewLineBeforeCloseParenthesisInMultilineList",
+ "NewLineBeforeFromClause", "NewLineBeforeGroupByClause",
+ "NewLineBeforeHavingClause", "NewLineBeforeJoinClause",
+ "NewLineBeforeOffsetClause", "NewLineBeforeOpenParenthesisInMultilineList",
+ "NewLineBeforeOrderByClause", "NewLineBeforeOutputClause",
+ "NewLineBeforeWhereClause", "NewLineBeforeWindowClause",
+ ];
+
+ private static readonly Dictionary ChoiceOptionsMap = new()
+ {
+ ["KeywordCasing"] = ["Uppercase", "Lowercase", "PascalCase"],
+ ["SqlVersion"] = ["80", "90", "100", "110", "120", "130", "140", "150", "160", "170"],
+ };
+
+ private void LoadSettings()
+ {
+ var current = SqlFormatSettingsService.Load(out var loadError);
+ if (loadError != null)
+ ShowErrorPopup("Load Error", loadError);
+ _rows.Clear();
+
+ var props = typeof(SqlFormatSettings).GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .ToDictionary(p => p.Name);
+
+ foreach (var name in PropertyOrder)
+ {
+ if (!props.TryGetValue(name, out var prop))
+ continue;
+
+ var currentVal = prop.GetValue(current);
+ var defaultVal = prop.GetValue(_defaults);
+ var isBool = prop.PropertyType == typeof(bool);
+
+ ChoiceOptionsMap.TryGetValue(prop.Name, out var choiceOptions);
+
+ _rows.Add(new FormatOptionRow
+ {
+ Name = prop.Name,
+ CurrentValue = currentVal?.ToString() ?? "",
+ DefaultValue = defaultVal?.ToString() ?? "",
+ IsBool = isBool,
+ BoolValue = isBool && currentVal is true,
+ DefaultBoolValue = isBool && defaultVal is true,
+ ChoiceOptions = choiceOptions,
+ PropertyInfo = prop
+ });
+ }
+
+ OptionsGrid.ItemsSource = _rows;
+
+ // Track changes for dirty-state prompt
+ foreach (var row in _rows)
+ row.PropertyChanged += (_, _) => _isDirty = true;
+ }
+
+ private void Save_Click(object? sender, RoutedEventArgs e)
+ {
+ var settings = new SqlFormatSettings();
+
+ foreach (var row in _rows)
+ {
+ try
+ {
+ var prop = row.PropertyInfo;
+ object? value;
+
+ if (prop.PropertyType == typeof(bool))
+ value = row.BoolValue;
+ else if (prop.PropertyType == typeof(int))
+ {
+ if (!int.TryParse(row.CurrentValue, out var intVal))
+ {
+ Debug.WriteLine($"FormatOptions: invalid int value '{row.CurrentValue}' for {row.Name}, using default");
+ continue;
+ }
+ value = intVal;
+ }
+ else
+ value = row.CurrentValue;
+
+ prop.SetValue(settings, value);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"FormatOptions: failed to set {row.Name}: {ex.Message}");
+ }
+ }
+
+ if (!SqlFormatSettingsService.Save(settings, out var saveError))
+ {
+ ShowErrorPopup("Save Error", saveError!);
+ return;
+ }
+ _isDirty = false;
+ Close();
+ }
+
+ private void Revert_Click(object? sender, RoutedEventArgs e)
+ {
+ foreach (var row in _rows)
+ {
+ row.CurrentValue = row.DefaultValue;
+ if (row.IsBool)
+ row.BoolValue = row.DefaultBoolValue;
+ }
+ }
+
+ private void ShowErrorPopup(string title, string message)
+ {
+ var dialog = new Window
+ {
+ Title = title,
+ Width = 480,
+ Height = 220,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Background = (IBrush)this.FindResource("BackgroundBrush")!,
+ Foreground = (IBrush)this.FindResource("ForegroundBrush")!,
+ Content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Children =
+ {
+ new TextBlock
+ {
+ Text = message,
+ TextWrapping = TextWrapping.Wrap,
+ FontSize = 13
+ }
+ }
+ }
+ };
+ dialog.ShowDialog(this);
+ }
+
+ private void Close_Click(object? sender, RoutedEventArgs e)
+ {
+ TryClose();
+ }
+
+ protected override void OnClosing(WindowClosingEventArgs e)
+ {
+ if (_isDirty)
+ {
+ e.Cancel = true;
+ TryClose();
+ return;
+ }
+ base.OnClosing(e);
+ }
+
+ private async void TryClose()
+ {
+ if (!_isDirty)
+ {
+ _isDirty = false; // prevent re-entry
+ Close();
+ return;
+ }
+
+ var result = await ShowDiscardDialog();
+ if (result)
+ {
+ _isDirty = false;
+ Close();
+ }
+ }
+
+ private async Task ShowDiscardDialog()
+ {
+ var tcs = new TaskCompletionSource();
+
+ var dialog = new Window
+ {
+ Title = "Unsaved Changes",
+ Width = 360,
+ Height = 160,
+ MinWidth = 360,
+ MinHeight = 160,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Background = (IBrush)this.FindResource("BackgroundBrush")!,
+ Foreground = (IBrush)this.FindResource("ForegroundBrush")!,
+ Content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Children =
+ {
+ new TextBlock
+ {
+ Text = "You have unsaved changes. Discard them?",
+ FontSize = 13,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Avalonia.Thickness(0, 0, 0, 16)
+ }
+ }
+ }
+ };
+
+ var discardBtn = new Button
+ {
+ Content = "Discard",
+ Height = 32, Width = 90,
+ Padding = new Avalonia.Thickness(16, 0),
+ FontSize = 12,
+ HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+
+ var cancelBtn = new Button
+ {
+ Content = "Cancel",
+ Height = 32, Width = 90,
+ Padding = new Avalonia.Thickness(16, 0),
+ FontSize = 12,
+ Margin = new Avalonia.Thickness(8, 0, 0, 0),
+ HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+
+ var buttonPanel = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right
+ };
+ buttonPanel.Children.Add(discardBtn);
+ buttonPanel.Children.Add(cancelBtn);
+
+ ((StackPanel)dialog.Content!).Children.Add(buttonPanel);
+
+ discardBtn.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
+ cancelBtn.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
+ dialog.Closing += (_, _) => tcs.TrySetResult(false);
+
+ await dialog.ShowDialog(this);
+ return await tcs.Task;
+ }
+}
+
+public class FormatOptionRow : INotifyPropertyChanged
+{
+ private string _currentValue = "";
+ private bool _boolValue;
+
+ public string Name { get; set; } = "";
+
+ public bool IsBool { get; set; }
+
+ public bool BoolValue
+ {
+ get => _boolValue;
+ set
+ {
+ _boolValue = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(BoolValue)));
+ // Keep CurrentValue in sync for serialization
+ if (IsBool)
+ {
+ _currentValue = value.ToString();
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentValue)));
+ }
+ }
+ }
+
+ public bool DefaultBoolValue { get; set; }
+
+ public string[]? ChoiceOptions { get; set; }
+
+ public bool IsChoice => ChoiceOptions != null;
+
+ public bool IsText => !IsBool && !IsChoice;
+
+ public string CurrentValue
+ {
+ get => _currentValue;
+ set
+ {
+ _currentValue = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentValue)));
+ }
+ }
+
+ public string DefaultValue { get; set; } = "";
+
+ internal PropertyInfo PropertyInfo { get; set; } = null!;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+}
diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj
index 51b3a7a..da69d71 100644
--- a/src/PlanViewer.App/PlanViewer.App.csproj
+++ b/src/PlanViewer.App/PlanViewer.App.csproj
@@ -32,6 +32,7 @@
+