From ca8d3c9f141ebe841ca689d97c899ea8358cd480 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Thu, 28 May 2026 00:09:37 +0200
Subject: [PATCH 1/4] add settings working wave 1
---
.../Controls/QuerySessionControl.Format.cs | 18 +-
.../Controls/QuerySessionControl.axaml | 4 -
.../Controls/QueryStoreGridControl.axaml.cs | 29 +-
.../QueryStoreHistoryControl.axaml.cs | 9 +-
.../QueryStoreOverviewControl.axaml.cs | 31 +-
.../Controls/TimeRangeSlicerControl.axaml.cs | 9 +-
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 11 +-
.../Dialogs/SettingsWindow.axaml | 69 ++
.../Dialogs/SettingsWindow.axaml.cs | 682 ++++++++++++++++++
src/PlanViewer.App/MainWindow.axaml | 1 +
src/PlanViewer.App/MainWindow.axaml.cs | 23 +
.../Services/AppSettingsService.cs | 137 +++-
.../Services/SqlFormatSettingsService.cs | 33 +-
13 files changed, 976 insertions(+), 80 deletions(-)
create mode 100644 src/PlanViewer.App/Dialogs/SettingsWindow.axaml
create mode 100644 src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs
index 9ead789..5ab4fa8 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs
@@ -86,9 +86,7 @@ private async void Format_Click(object? sender, RoutedEventArgs e)
try
{
- var settings = SqlFormatSettingsService.Load(out var loadError);
- if (loadError != null)
- SetStatus("Warning: using default format settings (load failed)");
+ var settings = AppSettingsService.Load().FormatOptions ?? new SqlFormatSettings();
var (formatted, errors) = await Task.Run(() => SqlFormattingService.Format(sql, settings));
@@ -147,13 +145,7 @@ private async void Format_Click(object? sender, RoutedEventArgs e)
}
finally
{
- FormatButton.IsEnabled = true;
- }
- }
-
- private void FormatOptions_Click(object? sender, RoutedEventArgs e)
- {
- var dialog = new Dialogs.FormatOptionsWindow();
- dialog.ShowDialog(GetParentWindow());
- }
-}
+ FormatButton.IsEnabled = true;
+ }
+ }
+ }
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml b/src/PlanViewer.App/Controls/QuerySessionControl.axaml
index a9aefad..f651f35 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml
@@ -92,10 +92,6 @@
Height="28" Padding="10,0" FontSize="12"
Theme="{StaticResource AppButton}"
ToolTip.Tip="Format the SQL query"/>
-
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index 0a7b1dc..dbe228a 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -61,8 +61,23 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi
_database = initialDatabase;
_connectionString = serverConnection.GetConnectionString(credentialService, initialDatabase);
_waitStatsSupported = supportsWaitStats;
- _slicerDaysBack = AppSettingsService.Load().QueryStoreSlicerDays;
+
+ var userSettings = AppSettingsService.Load();
+ _slicerDaysBack = userSettings.QueryStoreSlicerDays;
+
InitializeComponent();
+
+ // Apply user defaults to UI controls
+ TopNBox.Value = userSettings.QueryStoreTopLimit;
+ SelectComboByTag(OrderByBox, userSettings.QueryStoreDefaultMetric);
+ SelectComboByTag(GroupByBox, userSettings.QueryStoreDefaultGroupBy switch
+ {
+ "QueryHash" => "query-hash",
+ "Module" => "module",
+ "None" => "none",
+ _ => "query-hash"
+ });
+
ResultsGrid.ItemsSource = _filteredRows;
Helpers.DataGridBehaviors.Attach(ResultsGrid);
EnsureFilterPopup();
@@ -227,6 +242,18 @@ private static readonly (string ColumnId, Func Accessor)[
["AvgMemSort"] = "AvgMemory",
};
+ private static void SelectComboByTag(ComboBox box, string tag)
+ {
+ for (int i = 0; i < box.Items.Count; i++)
+ {
+ if (box.Items[i] is ComboBoxItem item && item.Tag?.ToString() == tag)
+ {
+ box.SelectedIndex = i;
+ return;
+ }
+ }
+ }
+
}
diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs
index b674825..293a1d8 100644
--- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs
@@ -296,7 +296,14 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
private void BuildColorMap()
{
_planHashColorMap.Clear();
- var hashes = _historyData.Select(r => r.QueryPlanHash).Distinct().OrderBy(h => h).ToList();
+ var maxPlans = Services.AppSettingsService.Load().QueryHistoryMaxPlans;
+ var hashes = _historyData
+ .GroupBy(r => r.QueryPlanHash)
+ .OrderByDescending(g => g.Sum(r => r.CountExecutions))
+ .Take(maxPlans)
+ .Select(g => g.Key)
+ .OrderBy(h => h)
+ .ToList();
for (int i = 0; i < hashes.Count; i++)
_planHashColorMap[hashes[i]] = PlanColors[i % PlanColors.Length];
}
diff --git a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs
index 040b702..9fa2d43 100644
--- a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs
@@ -12,6 +12,7 @@
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
+using PlanViewer.App.Services;
using PlanViewer.Core.Interfaces;
using PlanViewer.Core.Models;
using PlanViewer.Core.Services;
@@ -38,18 +39,8 @@ public partial class QueryStoreOverviewControl : UserControl
private DateTime _slicerEndUtc;
private int _daysBack = 30;
- // Color palette for databases (minimizes color dispersion)
- private static readonly Color[] Palette = new[]
- {
- Color.Parse("#2EAEF1"), // blue
- Color.Parse("#F2994A"), // orange
- Color.Parse("#27AE60"), // green
- Color.Parse("#9B51E0"), // purple
- Color.Parse("#EB5757"), // red
- Color.Parse("#F2C94C"), // yellow
- Color.Parse("#56CCF2"), // light blue
- Color.Parse("#BB6BD9"), // violet
- };
+ // Color palette for databases — loaded from user settings
+ private readonly Color[] _palette;
private static readonly Color OthersColor = Color.Parse("#555555");
@@ -69,13 +60,21 @@ public class DrillDownEventArgs(string database, DateTime startUtc, DateTime end
public event EventHandler? DrillDownRequested;
public QueryStoreOverviewControl(ServerConnection serverConnection,
- ICredentialService credentialService, int maxDop = 8, int topN = 4, bool supportsWaitStats = true)
+ ICredentialService credentialService, int maxDop = 8, int? topN = null, bool supportsWaitStats = true)
{
_serverConnection = serverConnection;
_credentialService = credentialService;
_masterConnectionString = serverConnection.GetConnectionString(credentialService, "master");
_maxDop = maxDop;
- _topN = topN;
+
+ var userSettings = AppSettingsService.Load();
+ _topN = topN ?? userSettings.MultiQsTopDbCount;
+ _palette = userSettings.MultiQsTopDbColors
+ .Select(hex => { try { return Color.Parse(hex); } catch { return Color.Parse("#555555"); } })
+ .ToArray();
+ if (_palette.Length == 0)
+ _palette = AppSettingsService.DefaultTopDbColors.Select(hex => Color.Parse(hex)).ToArray();
+
_supportsWaitStats = supportsWaitStats;
_slicerEndUtc = DateTime.UtcNow;
_slicerStartUtc = _slicerEndUtc.AddHours(-24);
@@ -668,8 +667,8 @@ private void DrawBarCards()
.Select(m => m.DatabaseName)
.ToList();
var topDbs = ranked.Take(_topN).ToList();
- for (int i = 0; i < topDbs.Count && i < Palette.Length; i++)
- _dbColorMap[topDbs[i]] = Palette[i];
+ for (int i = 0; i < topDbs.Count && i < _palette.Length; i++)
+ _dbColorMap[topDbs[i]] = _palette[i];
DrawMetricRow(TotalMetricsGrid, isTotal: true, topDbs);
DrawMetricRow(AvgMetricsGrid, isTotal: false, topDbs);
diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs
index 7edfdf0..36310ae 100644
--- a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs
@@ -8,6 +8,7 @@
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
+using PlanViewer.App.Services;
using PlanViewer.Core.Models;
using PlanViewer.Core.Services;
@@ -52,7 +53,7 @@ private enum DragMode { None, MoveRange, DragStart, DragEnd, SelectRect }
private double _selectRectOriginX; // canvas-x where drag-select started
private double _selectRectCurrentX; // canvas-x of current pointer during drag-select
- private string _activeFilterTag = "24"; // tag of the currently active quick-filter button
+ private string _activeFilterTag = AppSettingsService.Load().QueryStoreDefaultTimeRange; // tag from user settings
private DispatcherTimer? _rangeChangedDebounce;
public event EventHandler? RangeChanged;
@@ -80,13 +81,13 @@ public void LoadData(List data, string metric,
}
else
{
- // Default selection: last 24 hours
+ // Default selection from user settings
_rangeEnd = 1.0;
- _activeFilterTag = "24";
+ var defaultHours = int.TryParse(_activeFilterTag, out var h) ? h : 24;
if (_data.Count >= 2)
{
var last = _data[^1].IntervalStartUtc.AddHours(1);
- var start24h = last.AddHours(-24);
+ var start24h = last.AddHours(-defaultHours);
_rangeStart = GetNormFromDateTime(start24h);
}
else
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index ee13bf8..840d844 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -1,6 +1,7 @@
using System;
using Avalonia.Controls;
using PlanViewer.App.Controls;
+using PlanViewer.App.Services;
namespace PlanViewer.App.Dialogs;
@@ -18,15 +19,19 @@ public QueryStoreHistoryWindow()
public QueryStoreHistoryWindow(string connectionString, string queryHash,
string queryText, string database,
- string initialMetricTag = "AvgCpuMs",
+ string? initialMetricTag = null,
DateTime? slicerStartUtc = null, DateTime? slicerEndUtc = null,
- int slicerDaysBack = 30)
+ int? slicerDaysBack = null)
{
InitializeComponent();
+ var settings = AppSettingsService.Load();
+ var metricTag = initialMetricTag ?? settings.QueryHistoryDefaultMetric;
+ var daysBack = slicerDaysBack ?? settings.QueryStoreSlicerDays;
+
var control = new QueryStoreHistoryControl(
connectionString, queryHash, queryText, database,
- initialMetricTag, slicerStartUtc, slicerEndUtc, slicerDaysBack);
+ metricTag, slicerStartUtc, slicerEndUtc, daysBack);
control.ShowCloseButton(true);
Content = control;
HistoryControlInstance = control;
diff --git a/src/PlanViewer.App/Dialogs/SettingsWindow.axaml b/src/PlanViewer.App/Dialogs/SettingsWindow.axaml
new file mode 100644
index 0000000..1e6fdb0
--- /dev/null
+++ b/src/PlanViewer.App/Dialogs/SettingsWindow.axaml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs b/src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs
new file mode 100644
index 0000000..a02994f
--- /dev/null
+++ b/src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs
@@ -0,0 +1,682 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App.Dialogs;
+
+internal partial class SettingsWindow : Window
+{
+ private AppSettings _settings;
+ private bool _isDirty;
+
+ // QueryStore controls
+ private NumericUpDown? _slicerDaysBox;
+ private ComboBox? _defaultMetricBox;
+ private NumericUpDown? _topLimitBox;
+ private ComboBox? _defaultTimeRangeBox;
+ private ComboBox? _defaultTimeDisplayBox;
+ private ComboBox? _defaultGroupByBox;
+
+ // Multi QS Overview controls
+ private NumericUpDown? _topDbCountBox;
+ private readonly List _colorTextBoxes = new();
+ private readonly List _colorPreviews = new();
+ private StackPanel? _colorListPanel;
+
+ // Query History controls
+ private ComboBox? _historyMetricBox;
+ private NumericUpDown? _historyMaxPlansBox;
+
+ // Script Options (Format) controls
+ private readonly ObservableCollection _formatRows = new();
+ private DataGrid? _formatGrid;
+
+ internal event Action? SettingsSaved;
+
+ public SettingsWindow()
+ {
+ _settings = AppSettingsService.Load();
+ InitializeComponent();
+ ShowSection(0);
+ }
+
+ internal SettingsWindow(AppSettings settings)
+ {
+ _settings = settings;
+ InitializeComponent();
+ ShowSection(0);
+ }
+
+ private void SectionList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (SectionList.SelectedIndex >= 0)
+ ShowSection(SectionList.SelectedIndex);
+ }
+
+ private void ShowSection(int index)
+ {
+ DetailPanel.Content = index switch
+ {
+ 0 => BuildQueryStoreSection(),
+ 1 => BuildQueryHistorySection(),
+ 2 => BuildScriptOptionsSection(),
+ _ => null
+ };
+ }
+
+ // ── Query Store Section ──────────────────────────────────────────
+
+ private static readonly (string Content, string Tag)[] MetricOptions =
+ {
+ ("Total CPU", "cpu"), ("Avg CPU", "avg-cpu"),
+ ("Total Duration", "duration"), ("Avg Duration", "avg-duration"),
+ ("Total Reads", "reads"), ("Avg Reads", "avg-reads"),
+ ("Total Writes", "writes"), ("Avg Writes", "avg-writes"),
+ ("Total Physical Reads", "physical-reads"), ("Avg Physical Reads", "avg-physical-reads"),
+ ("Total Memory", "memory"), ("Avg Memory", "avg-memory"),
+ ("Executions", "executions"),
+ };
+
+ private static readonly (string Content, string Tag)[] TimeRangeOptions =
+ {
+ ("3 hours", "3"), ("24 hours", "24"), ("48 hours", "48"),
+ ("7 days", "168"), ("30 days", "720"),
+ };
+
+ private static readonly (string Content, string Tag)[] TimeDisplayOptions =
+ {
+ ("Local", "Local"), ("UTC", "Utc"), ("Server", "Server"),
+ };
+
+ private static readonly (string Content, string Tag)[] GroupByOptions =
+ {
+ ("None", "None"), ("Query Hash", "QueryHash"), ("Module", "Module"),
+ };
+
+ private Control BuildQueryStoreSection()
+ {
+ var panel = new StackPanel { Spacing = 16 };
+
+ // Chapter 1: Query Store
+ panel.Children.Add(CreateChapterHeader("Query Store"));
+
+ _slicerDaysBox = CreateNumericUpDown(_settings.QueryStoreSlicerDays, 1, 365);
+ _slicerDaysBox.ValueChanged += (_, _) => _isDirty = true;
+ panel.Children.Add(CreateRow("Default history length (days)", _slicerDaysBox));
+
+ _defaultMetricBox = CreateTagComboBox(MetricOptions, _settings.QueryStoreDefaultMetric);
+ _defaultMetricBox.SelectionChanged += (_, _) => _isDirty = true;
+ panel.Children.Add(CreateRow("Default metric for top", _defaultMetricBox));
+
+ _topLimitBox = CreateNumericUpDown(_settings.QueryStoreTopLimit, 1, 200);
+ _topLimitBox.ValueChanged += (_, _) => _isDirty = true;
+ panel.Children.Add(CreateRow("Top elements limit", _topLimitBox));
+
+ _defaultTimeRangeBox = CreateTagComboBox(TimeRangeOptions, _settings.QueryStoreDefaultTimeRange);
+ _defaultTimeRangeBox.SelectionChanged += (_, _) => _isDirty = true;
+ panel.Children.Add(CreateRow("Default time range", _defaultTimeRangeBox));
+
+ _defaultTimeDisplayBox = CreateTagComboBox(TimeDisplayOptions, _settings.QueryStoreDefaultTimeDisplay);
+ _defaultTimeDisplayBox.SelectionChanged += (_, _) => _isDirty = true;
+ panel.Children.Add(CreateRow("Default time display", _defaultTimeDisplayBox));
+
+ _defaultGroupByBox = CreateTagComboBox(GroupByOptions, _settings.QueryStoreDefaultGroupBy);
+ _defaultGroupByBox.SelectionChanged += (_, _) => _isDirty = true;
+ panel.Children.Add(CreateRow("Default group by", _defaultGroupByBox));
+
+ // Chapter 2: Multi QS Overview
+ panel.Children.Add(CreateChapterHeader("Multi QS Overview"));
+
+ _topDbCountBox = CreateNumericUpDown(_settings.MultiQsTopDbCount, 2, 20);
+ _topDbCountBox.ValueChanged += (_, e) =>
+ {
+ _isDirty = true;
+ RebuildColorList();
+ };
+ panel.Children.Add(CreateRow("Number of top databases", _topDbCountBox));
+
+ _colorListPanel = new StackPanel { Spacing = 4 };
+ RebuildColorList();
+ panel.Children.Add(CreateRow("Top database colors", _colorListPanel));
+
+ return panel;
+ }
+
+ private void RebuildColorList()
+ {
+ if (_colorListPanel == null) return;
+ _colorListPanel.Children.Clear();
+ _colorTextBoxes.Clear();
+ _colorPreviews.Clear();
+
+ var count = (int)(_topDbCountBox?.Value ?? _settings.MultiQsTopDbCount);
+ var colors = _settings.MultiQsTopDbColors;
+
+ for (int i = 0; i < count; i++)
+ {
+ var hex = i < colors.Count ? colors[i] : AppSettingsService.DefaultTopDbColors[i % AppSettingsService.DefaultTopDbColors.Count];
+ var preview = new Rectangle
+ {
+ Width = 24, Height = 24,
+ Fill = TryParseBrush(hex),
+ RadiusX = 3, RadiusY = 3,
+ Margin = new Thickness(0, 0, 6, 0)
+ };
+ var textBox = new TextBox
+ {
+ Text = hex, Width = 100, Height = 28, FontSize = 12,
+ Foreground = (IBrush?)this.FindResource("ForegroundBrush") ?? Brushes.White
+ };
+ var index = i;
+ textBox.TextChanged += (_, _) =>
+ {
+ _isDirty = true;
+ if (index < _colorPreviews.Count)
+ _colorPreviews[index].Fill = TryParseBrush(textBox.Text ?? "");
+ };
+
+ _colorTextBoxes.Add(textBox);
+ _colorPreviews.Add(preview);
+
+ var row = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 4,
+ Children =
+ {
+ new TextBlock
+ {
+ Text = $"#{i + 1}",
+ Width = 28,
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = (IBrush?)this.FindResource("ForegroundBrush") ?? Brushes.White,
+ FontSize = 12
+ },
+ preview,
+ textBox
+ }
+ };
+ _colorListPanel.Children.Add(row);
+ }
+ }
+
+ private static SolidColorBrush TryParseBrush(string hex)
+ {
+ try
+ {
+ return new SolidColorBrush(Color.Parse(hex));
+ }
+ catch
+ {
+ return new SolidColorBrush(Colors.Gray);
+ }
+ }
+
+ // ── Query History Section ────────────────────────────────────────
+
+ private static readonly (string Content, string Tag)[] HistoryMetricOptions =
+ {
+ ("Avg CPU (ms)", "AvgCpuMs"), ("Avg Duration (ms)", "AvgDurationMs"),
+ ("Avg Logical Reads", "AvgLogicalReads"), ("Avg Logical Writes", "AvgLogicalWrites"),
+ ("Avg Physical Reads", "AvgPhysicalReads"), ("Avg Memory (MB)", "AvgMemoryMb"),
+ ("Avg Rows", "AvgRowcount"),
+ ("Total CPU (ms)", "TotalCpuMs"), ("Total Duration (ms)", "TotalDurationMs"),
+ ("Total Reads", "TotalLogicalReads"), ("Total Writes", "TotalLogicalWrites"),
+ ("Total Physical Reads", "TotalPhysicalReads"), ("Total Memory (MB)", "TotalMemoryMb"),
+ ("Executions", "CountExecutions"),
+ };
+
+ private Control BuildQueryHistorySection()
+ {
+ var panel = new StackPanel { Spacing = 16 };
+ panel.Children.Add(CreateChapterHeader("Query History"));
+
+ _historyMetricBox = CreateTagComboBox(HistoryMetricOptions, _settings.QueryHistoryDefaultMetric);
+ _historyMetricBox.SelectionChanged += (_, _) => _isDirty = true;
+ panel.Children.Add(CreateRow("Default chart metric", _historyMetricBox));
+
+ _historyMaxPlansBox = CreateNumericUpDown(_settings.QueryHistoryMaxPlans, 1, 100);
+ _historyMaxPlansBox.ValueChanged += (_, _) => _isDirty = true;
+ panel.Children.Add(CreateRow("Max plans fetched per query", _historyMaxPlansBox));
+
+ return panel;
+ }
+
+ // ── Script Options Section ───────────────────────────────────────
+
+ private static readonly string[] FormatPropertyOrder =
+ [
+ "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 FormatChoiceOptionsMap = new()
+ {
+ ["KeywordCasing"] = ["Uppercase", "Lowercase", "PascalCase"],
+ ["SqlVersion"] = ["80", "90", "100", "110", "120", "130", "140", "150", "160", "170"],
+ };
+
+ private Control BuildScriptOptionsSection()
+ {
+ var panel = new StackPanel { Spacing = 16 };
+ panel.Children.Add(CreateChapterHeader("Format Options"));
+
+ var current = _settings.FormatOptions ?? new SqlFormatSettings();
+ var defaults = new SqlFormatSettings();
+ _formatRows.Clear();
+
+ var props = typeof(SqlFormatSettings).GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .ToDictionary(p => p.Name);
+
+ foreach (var name in FormatPropertyOrder)
+ {
+ if (!props.TryGetValue(name, out var prop)) continue;
+
+ var currentVal = prop.GetValue(current);
+ var defaultVal = prop.GetValue(defaults);
+ var isBool = prop.PropertyType == typeof(bool);
+ FormatChoiceOptionsMap.TryGetValue(prop.Name, out var choiceOptions);
+
+ var row = new FormatOptionRow
+ {
+ Name = SplitPascalCase(prop.Name),
+ CurrentValue = currentVal?.ToString() ?? "",
+ DefaultValue = defaultVal?.ToString() ?? "",
+ IsBool = isBool,
+ BoolValue = isBool && currentVal is true,
+ DefaultBoolValue = isBool && defaultVal is true,
+ ChoiceOptions = choiceOptions,
+ PropertyInfo = prop
+ };
+ row.PropertyChanged += (_, _) => _isDirty = true;
+ _formatRows.Add(row);
+ }
+
+ _formatGrid = new DataGrid
+ {
+ ItemsSource = _formatRows,
+ AutoGenerateColumns = false,
+ CanUserReorderColumns = false,
+ CanUserResizeColumns = true,
+ CanUserSortColumns = false,
+ HeadersVisibility = DataGridHeadersVisibility.Column,
+ GridLinesVisibility = DataGridGridLinesVisibility.Horizontal,
+ IsReadOnly = false,
+ MinHeight = 400,
+ FontSize = 13,
+ };
+
+ _formatGrid.Columns.Add(new DataGridTextColumn
+ {
+ Header = "Setting",
+ Binding = new Binding("Name"),
+ Width = new DataGridLength(2, DataGridLengthUnitType.Star),
+ IsReadOnly = true
+ });
+
+ // Value column: ToggleSwitch for bools, ComboBox for choices, TextBox for text
+ var valueColumn = new DataGridTemplateColumn
+ {
+ Header = "Value",
+ Width = new DataGridLength(1, DataGridLengthUnitType.Star),
+ };
+ valueColumn.CellTemplate = new FuncDataTemplate((row, _) =>
+ {
+ if (row == null) return new Panel();
+ var container = new Panel();
+
+ if (row.IsBool)
+ {
+ var toggle = new ToggleSwitch
+ {
+ [!ToggleSwitch.IsCheckedProperty] = new Binding("BoolValue"),
+ Margin = new Thickness(4, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ container.Children.Add(toggle);
+ }
+ else if (row.IsChoice)
+ {
+ var combo = new ComboBox
+ {
+ ItemsSource = row.ChoiceOptions,
+ [!ComboBox.SelectedItemProperty] = new Binding("CurrentValue"),
+ VerticalAlignment = VerticalAlignment.Center,
+ MinHeight = 0, Height = 26, FontSize = 12,
+ Margin = new Thickness(4, 0),
+ };
+ container.Children.Add(combo);
+ }
+ else
+ {
+ var tb = new TextBox
+ {
+ [!TextBox.TextProperty] = new Binding("CurrentValue"),
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(4, 0),
+ };
+ container.Children.Add(tb);
+ }
+
+ return container;
+ }, supportsRecycling: false);
+ _formatGrid.Columns.Add(valueColumn);
+
+ // Default column: disabled ToggleSwitch for bools, TextBlock for others
+ var defaultColumn = new DataGridTemplateColumn
+ {
+ Header = "Default",
+ Width = new DataGridLength(1, DataGridLengthUnitType.Star),
+ IsReadOnly = true,
+ };
+ defaultColumn.CellTemplate = new FuncDataTemplate((row, _) =>
+ {
+ if (row == null) return new Panel();
+ var container = new Panel();
+
+ if (row.IsBool)
+ {
+ var toggle = new ToggleSwitch
+ {
+ IsChecked = row.DefaultBoolValue,
+ IsEnabled = false,
+ Margin = new Thickness(4, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ container.Children.Add(toggle);
+ }
+ else
+ {
+ container.Children.Add(new TextBlock
+ {
+ Text = row.DefaultValue,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(8, 0),
+ });
+ }
+
+ return container;
+ }, supportsRecycling: false);
+ _formatGrid.Columns.Add(defaultColumn);
+
+ panel.Children.Add(_formatGrid);
+
+ var revertBtn = new Button
+ {
+ Content = "Revert Format to Defaults",
+ Height = 28, Padding = new Thickness(12, 0),
+ FontSize = 12,
+ Theme = (Avalonia.Styling.ControlTheme?)this.FindResource("AppButton")
+ };
+ revertBtn.Click += (_, _) =>
+ {
+ foreach (var row in _formatRows)
+ {
+ row.CurrentValue = row.DefaultValue;
+ if (row.IsBool)
+ row.BoolValue = row.DefaultBoolValue;
+ }
+ };
+ panel.Children.Add(revertBtn);
+
+ return panel;
+ }
+
+ private static string SplitPascalCase(string name)
+ {
+ var sb = new StringBuilder(name.Length + 8);
+ for (int i = 0; i < name.Length; i++)
+ {
+ var c = name[i];
+ if (i > 0 && char.IsUpper(c) && !char.IsUpper(name[i - 1]))
+ sb.Append(' ');
+ sb.Append(c);
+ }
+ return sb.ToString();
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────
+
+ private static readonly SolidColorBrush ChapterHeaderBg = new(Color.Parse("#2A2D35"));
+ private static readonly SolidColorBrush ChapterHeaderFg = new(Color.Parse("#4FC3F7"));
+
+ private static Border CreateChapterHeader(string text) => new()
+ {
+ Background = ChapterHeaderBg,
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(12, 6),
+ Margin = new Thickness(0, 8, 0, 2),
+ Child = new TextBlock
+ {
+ Text = text,
+ FontSize = 15,
+ FontWeight = FontWeight.SemiBold,
+ Foreground = ChapterHeaderFg,
+ }
+ };
+
+ private static StackPanel CreateRow(string label, Control control)
+ {
+ return new StackPanel
+ {
+ Spacing = 4,
+ Children =
+ {
+ new TextBlock { Text = label, FontSize = 13 },
+ control
+ }
+ };
+ }
+
+ private static ComboBox CreateTagComboBox((string Content, string Tag)[] options, string selectedTag)
+ {
+ var box = new ComboBox { Width = 200, Height = 32, FontSize = 13 };
+ int selectedIndex = 0;
+ for (int i = 0; i < options.Length; i++)
+ {
+ box.Items.Add(new ComboBoxItem { Content = options[i].Content, Tag = options[i].Tag });
+ if (options[i].Tag == selectedTag)
+ selectedIndex = i;
+ }
+ box.SelectedIndex = selectedIndex;
+ return box;
+ }
+
+ private static NumericUpDown CreateNumericUpDown(int value, int min, int max) => new()
+ {
+ Value = value,
+ Minimum = min, Maximum = max, FormatString = "0",
+ Width = 120, Height = 32, FontSize = 13,
+ HorizontalContentAlignment = HorizontalAlignment.Left,
+ };
+
+ private static string GetComboTag(ComboBox? box) =>
+ (box?.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "";
+
+ // ── Button handlers ──────────────────────────────────────────────
+
+ private void Save_Click(object? sender, RoutedEventArgs e)
+ {
+ // Read QueryStore settings
+ _settings.QueryStoreSlicerDays = (int)(_slicerDaysBox?.Value ?? 30);
+ _settings.QueryStoreDefaultMetric = GetComboTag(_defaultMetricBox);
+ _settings.QueryStoreTopLimit = (int)(_topLimitBox?.Value ?? 25);
+ _settings.QueryStoreDefaultTimeRange = GetComboTag(_defaultTimeRangeBox);
+ _settings.QueryStoreDefaultTimeDisplay = GetComboTag(_defaultTimeDisplayBox);
+ _settings.QueryStoreDefaultGroupBy = GetComboTag(_defaultGroupByBox);
+
+ // Read Multi QS Overview settings
+ _settings.MultiQsTopDbCount = (int)(_topDbCountBox?.Value ?? 5);
+ _settings.MultiQsTopDbColors = _colorTextBoxes.Select(tb => tb.Text ?? "#555555").ToList();
+
+ // Read Query History settings
+ _settings.QueryHistoryDefaultMetric = GetComboTag(_historyMetricBox);
+ _settings.QueryHistoryMaxPlans = (int)(_historyMaxPlansBox?.Value ?? 10);
+
+ // Read Format Options
+ if (_formatRows.Count > 0)
+ {
+ var fmt = new SqlFormatSettings();
+ foreach (var row in _formatRows)
+ {
+ 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)) continue;
+ value = intVal;
+ }
+ else
+ value = row.CurrentValue;
+ prop.SetValue(fmt, value);
+ }
+ catch { /* skip bad values */ }
+ }
+ _settings.FormatOptions = fmt;
+ }
+
+ // Apply live settings
+ if (Enum.TryParse(_settings.QueryStoreDefaultTimeDisplay, true, out var tdm))
+ TimeDisplayHelper.Current = tdm;
+
+ AppSettingsService.Save(_settings);
+ _isDirty = false;
+ SettingsSaved?.Invoke(_settings);
+ Close();
+ }
+
+ private void Cancel_Click(object? sender, RoutedEventArgs e)
+ {
+ TryClose();
+ }
+
+ private void Reset_Click(object? sender, RoutedEventArgs e)
+ {
+ var fresh = new AppSettings
+ {
+ // Preserve app state that isn't user preferences
+ RecentPlans = _settings.RecentPlans,
+ OpenPlans = _settings.OpenPlans,
+ AccuracyRatioDivergenceLimit = _settings.AccuracyRatioDivergenceLimit
+ };
+ _settings = fresh;
+ _isDirty = true;
+ ShowSection(SectionList.SelectedIndex);
+ }
+
+ protected override void OnClosing(WindowClosingEventArgs e)
+ {
+ if (_isDirty)
+ {
+ e.Cancel = true;
+ TryClose();
+ return;
+ }
+ base.OnClosing(e);
+ }
+
+ private async void TryClose()
+ {
+ if (!_isDirty)
+ {
+ _isDirty = false;
+ 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 Thickness(20),
+ Children =
+ {
+ new TextBlock
+ {
+ Text = "You have unsaved changes. Discard them?",
+ FontSize = 13, TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(0, 0, 0, 16)
+ }
+ }
+ }
+ };
+
+ var discardBtn = new Button
+ {
+ Content = "Discard", Height = 32, Width = 90,
+ Padding = new Thickness(16, 0), FontSize = 12,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme?)this.FindResource("AppButton")
+ };
+ var cancelBtn = new Button
+ {
+ Content = "Cancel", Height = 32, Width = 90,
+ Padding = new Thickness(16, 0), FontSize = 12,
+ Margin = new Thickness(8, 0, 0, 0),
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme?)this.FindResource("AppButton")
+ };
+
+ var buttonPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = 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;
+ }
+}
diff --git a/src/PlanViewer.App/MainWindow.axaml b/src/PlanViewer.App/MainWindow.axaml
index 166a9a5..19e1603 100644
--- a/src/PlanViewer.App/MainWindow.axaml
+++ b/src/PlanViewer.App/MainWindow.axaml
@@ -26,6 +26,7 @@
+