diff --git a/README.md b/README.md index aff80fe..83c6f7a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,20 @@ # Performance Studio +## Fork Notice + +This repository is a personal fork of Erik Darling's PerformanceStudio project, maintained to support additional workflow and UX changes for day-to-day tuning work. + +Current fork-specific changes include: +- query file save and save-as actions in the desktop app +- top-level tab drag/drop behavior and related UI improvements +- iterative app-level settings and session handling updates tied to those features + +Upstream source of truth: [erikdarlingdata/PerformanceStudio](https://github.com/erikdarlingdata/PerformanceStudio) + +If you do not specifically need the fork changes above, prefer the upstream repository for official releases and updates. + +--- + A cross-platform SQL Server execution plan analyzer with built-in MCP server for AI-assisted analysis. Parses `.sqlplan` XML, identifies performance problems, suggests missing indexes, and provides actionable warnings — from the command line or a desktop GUI. Built for developers and DBAs who want fast, automated plan analysis without clicking through SSMS. diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index fadeed6..586dd8d 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -44,6 +44,14 @@ public partial class QuerySessionControl : UserControl private CancellationTokenSource? _statusClearCts; private CompletionWindow? _completionWindow; + public string QueryText + { + get => QueryEditor.Text ?? ""; + set => QueryEditor.Text = value ?? ""; + } + + public string? SourceFilePath { get; set; } + public QuerySessionControl(ICredentialService credentialService, ConnectionStore connectionStore) { _credentialService = credentialService; @@ -51,7 +59,7 @@ public QuerySessionControl(ICredentialService credentialService, ConnectionStore InitializeComponent(); // Initialize editor with empty text so the document is ready - QueryEditor.Text = ""; + QueryText = ""; ZoomBox.SelectedIndex = 2; // 100% SetupSyntaxHighlighting(); diff --git a/src/PlanViewer.App/MainWindow.axaml b/src/PlanViewer.App/MainWindow.axaml index 40e7c87..294e788 100644 --- a/src/PlanViewer.App/MainWindow.axaml +++ b/src/PlanViewer.App/MainWindow.axaml @@ -16,8 +16,12 @@ - + + diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index dc61e5f..9950c49 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.IO.Pipes; @@ -7,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Input.Platform; @@ -15,6 +17,7 @@ using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.Threading; +using Avalonia.VisualTree; using PlanViewer.App.Controls; using PlanViewer.App.Services; using PlanViewer.Core.Interfaces; @@ -28,14 +31,21 @@ namespace PlanViewer.App; public partial class MainWindow : Window { private const string PipeName = "SQLPerformanceStudio_OpenFile"; + private static readonly DataFormat TopLevelTabDragDataFormat = + DataFormat.CreateStringApplicationFormat("PerformanceStudio.TopLevelTab"); + private static readonly IBrush TabDropCueBrush = new SolidColorBrush(Color.Parse("#4DA3FF")); private readonly ICredentialService _credentialService; private readonly ConnectionStore _connectionStore; private readonly CancellationTokenSource _pipeCts = new(); + private readonly Dictionary _topLevelTabDragLookup = new(); + private readonly Dictionary _topLevelTabDropMarkers = new(); private McpHostService? _mcpHost; private CancellationTokenSource? _mcpCts; private int _queryCounter; private AppSettings _appSettings; + private Point? _topLevelTabDragStart; + private TabItem? _topLevelTabDragSource; public MainWindow() { @@ -55,13 +65,17 @@ public MainWindow() AddHandler(DragDrop.DropEvent, OnDrop); AddHandler(DragDrop.DragOverEvent, OnDragOver); - // Track tab changes to update empty overlay - MainTabControl.SelectionChanged += (_, _) => UpdateEmptyOverlay(); + // Track tab changes to update empty overlay and file actions + MainTabControl.SelectionChanged += (_, _) => + { + UpdateEmptyOverlay(); + UpdateFileMenuState(); + }; // Global hotkeys via tunnel routing so they fire before AvaloniaEdit consumes them AddHandler(KeyDownEvent, (_, e) => { - if (e.KeyModifiers == KeyModifiers.Control) + if ((e.KeyModifiers & KeyModifiers.Control) != 0) { switch (e.Key) { @@ -73,11 +87,21 @@ public MainWindow() OpenFile_Click(this, new RoutedEventArgs()); e.Handled = true; break; + case Key.S: + if (GetSelectedQuerySession() != null) + { + var forceSaveAs = (e.KeyModifiers & KeyModifiers.Shift) != 0; + _ = SaveSelectedQueryAsync(forceSaveAs); + e.Handled = true; + } + break; case Key.W: if (MainTabControl.SelectedItem is TabItem selected) { MainTabControl.Items.Remove(selected); + ForgetTopLevelTabDragId(selected); UpdateEmptyOverlay(); + UpdateFileMenuState(); e.Handled = true; } break; @@ -107,6 +131,7 @@ public MainWindow() // Start MCP server if enabled in settings StartMcpServer(); + UpdateFileMenuState(); } private void StartPipeServer() @@ -198,6 +223,7 @@ private void NewQuery_Click(object? sender, RoutedEventArgs e) MainTabControl.Items.Add(tab); MainTabControl.SelectedItem = tab; UpdateEmptyOverlay(); + UpdateFileMenuState(); } private async void OpenFile_Click(object? sender, RoutedEventArgs e) @@ -209,17 +235,29 @@ private async void OpenFile_Click(object? sender, RoutedEventArgs e) AllowMultiple = true, FileTypeFilter = new[] { + new FilePickerFileType("Supported Files (.sqlplan, .sql, .xml)") + { + Patterns = new[] { "*.sqlplan", "*.sql", "*.xml" }, + MimeTypes = new[] { "application/sql", "text/x-sql", "text/plain", "application/xml", "text/xml" }, + AppleUniformTypeIdentifiers = new[] { "public.sql", "public.plain-text", "public.xml", "public.text" } + }, new FilePickerFileType("SQL Server Execution Plans") { - Patterns = new[] { "*.sqlplan" } + Patterns = new[] { "*.sqlplan" }, + MimeTypes = new[] { "application/xml", "text/xml" }, + AppleUniformTypeIdentifiers = new[] { "public.xml", "public.text" } }, new FilePickerFileType("SQL Scripts") { - Patterns = new[] { "*.sql" } + Patterns = new[] { "*.sql" }, + MimeTypes = new[] { "application/sql", "text/x-sql", "text/plain" }, + AppleUniformTypeIdentifiers = new[] { "public.sql", "public.plain-text", "public.text" } }, new FilePickerFileType("XML Files") { - Patterns = new[] { "*.xml" } + Patterns = new[] { "*.xml" }, + MimeTypes = new[] { "application/xml", "text/xml" }, + AppleUniformTypeIdentifiers = new[] { "public.xml", "public.text" } }, FilePickerFileTypes.All } @@ -233,6 +271,16 @@ private async void OpenFile_Click(object? sender, RoutedEventArgs e) } } + private async void SaveQuery_Click(object? sender, RoutedEventArgs e) + { + await SaveSelectedQueryAsync(); + } + + private async void SaveAsQuery_Click(object? sender, RoutedEventArgs e) + { + await SaveSelectedQueryAsync(forceSaveAs: true); + } + private async void PasteXml_Click(object? sender, RoutedEventArgs e) { await PasteXmlAsync(); @@ -260,6 +308,10 @@ private static bool IsSupportedFile(string? path) private void OnDragOver(object? sender, DragEventArgs e) { + // Let tab-header drag/drop decide drag effects for internal top-level tab reordering. + if (e.DataTransfer.TryGetValue(TopLevelTabDragDataFormat) != null) + return; + e.DragEffects = DragDropEffects.None; if (e.Data.Contains(DataFormats.Files)) @@ -272,6 +324,10 @@ private void OnDragOver(object? sender, DragEventArgs e) private void OnDrop(object? sender, DragEventArgs e) { + // Internal top-level tab drop is handled by tab header drop handlers. + if (e.DataTransfer.TryGetValue(TopLevelTabDragDataFormat) != null) + return; + if (!e.Data.Contains(DataFormats.Files)) return; var files = e.Data.GetFiles(); @@ -303,12 +359,14 @@ private void LoadSqlFile(string filePath) _queryCounter++; var session = new QuerySessionControl(_credentialService, _connectionStore); - session.QueryEditor.Text = text; + session.QueryText = text; + session.SourceFilePath = filePath; var tab = CreateTab(fileName, session); MainTabControl.Items.Add(tab); MainTabControl.SelectedItem = tab; UpdateEmptyOverlay(); + UpdateFileMenuState(); } catch (Exception ex) { @@ -690,7 +748,7 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal private static string GetTabLabel(TabItem tab) { - if (tab.Header is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb) + if (FindHeaderTextBlock(tab.Header) is TextBlock tb) return tb.Text ?? "Tab"; if (tab.Header is string s) return s; @@ -871,10 +929,41 @@ private TabItem CreateTab(string label, Control content) Children = { headerText, closeBtn } }; - var tab = new TabItem { Header = header, Content = content }; + var insertMarker = new TextBlock + { + IsVisible = false, + IsHitTestVisible = false, + FontSize = 11, + FontWeight = FontWeight.Bold, + Foreground = TabDropCueBrush, + VerticalAlignment = VerticalAlignment.Center + }; + + var headerLayer = new Grid + { + Children = { header, insertMarker } + }; + + var headerRoot = new Border + { + Background = Brushes.Transparent, + Child = headerLayer + }; + + var tab = new TabItem { Header = headerRoot, Content = content }; + headerRoot.Tag = tab; + _topLevelTabDropMarkers[headerRoot] = insertMarker; closeBtn.Tag = tab; closeBtn.Click += CloseTab_Click; + DragDrop.SetAllowDrop(headerRoot, true); + headerRoot.PointerPressed += TopLevelTabHeader_PointerPressed; + headerRoot.PointerMoved += TopLevelTabHeader_PointerMoved; + headerRoot.PointerReleased += TopLevelTabHeader_PointerReleased; + headerRoot.AddHandler(DragDrop.DragLeaveEvent, TopLevelTabHeader_DragLeave); + headerRoot.AddHandler(DragDrop.DragOverEvent, TopLevelTabHeader_DragOver); + headerRoot.AddHandler(DragDrop.DropEvent, TopLevelTabHeader_Drop); + // Right-click context menu var copyPathItem = new MenuItem { Header = "Copy Path", Tag = tab }; // Only visible when tab content has a file path @@ -897,7 +986,7 @@ private TabItem CreateTab(string label, Control content) foreach (var item in contextMenu.Items.OfType()) item.Click += TabContextMenu_Click; - header.ContextMenu = contextMenu; + headerRoot.ContextMenu = contextMenu; return tab; } @@ -907,7 +996,10 @@ private void CloseTab_Click(object? sender, RoutedEventArgs e) if (sender is Button btn && btn.Tag is TabItem tab) { MainTabControl.Items.Remove(tab); + ForgetTopLevelTabDragId(tab); + ForgetTopLevelTabMarker(tab); UpdateEmptyOverlay(); + UpdateFileMenuState(); } } @@ -937,7 +1029,10 @@ private void TabContextMenu_Click(object? sender, RoutedEventArgs e) if (item.Tag is TabItem tab) { MainTabControl.Items.Remove(tab); + ForgetTopLevelTabDragId(tab); + ForgetTopLevelTabMarker(tab); UpdateEmptyOverlay(); + UpdateFileMenuState(); } break; @@ -946,20 +1041,39 @@ private void TabContextMenu_Click(object? sender, RoutedEventArgs e) { var others = MainTabControl.Items.Cast().Where(t => t != keepTab).ToList(); foreach (var t in others) + { MainTabControl.Items.Remove(t); + ForgetTopLevelTabDragId(t); + ForgetTopLevelTabMarker(t); + } MainTabControl.SelectedItem = keepTab; UpdateEmptyOverlay(); + UpdateFileMenuState(); } break; case "Close All Tabs": + foreach (var tabItem in MainTabControl.Items.Cast().ToList()) + { + ForgetTopLevelTabDragId(tabItem); + ForgetTopLevelTabMarker(tabItem); + } MainTabControl.Items.Clear(); UpdateEmptyOverlay(); + UpdateFileMenuState(); break; } } private static string? GetTabFilePath(TabItem tab) + { + if (tab.Content is QuerySessionControl session) + return session.SourceFilePath; + + return GetPlanTabFilePath(tab); + } + + private static string? GetPlanTabFilePath(TabItem tab) { // Plans opened from file are wrapped in a DockPanel with the viewer as the last child if (tab.Content is DockPanel dp) @@ -1014,6 +1128,293 @@ void CommitRename() textBox.LostFocus += (_, _) => CommitRename(); } + private QuerySessionControl? GetSelectedQuerySession() + { + return MainTabControl.SelectedItem is TabItem { Content: QuerySessionControl session } + ? session + : null; + } + + private void UpdateFileMenuState() + { + var hasSelectedQuery = GetSelectedQuerySession() != null; + SaveQueryMenuItem.IsEnabled = hasSelectedQuery; + SaveAsQueryMenuItem.IsEnabled = hasSelectedQuery; + } + + private async Task SaveSelectedQueryAsync(bool forceSaveAs = false) + { + var session = GetSelectedQuerySession(); + if (session == null || MainTabControl.SelectedItem is not TabItem selectedTab) + return; + + var filePath = session.SourceFilePath; + var savedToNewFile = forceSaveAs || string.IsNullOrWhiteSpace(filePath); + + if (savedToNewFile) + { + var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = "Save Query", + DefaultExtension = "sql", + SuggestedFileName = BuildSuggestedQueryFileName(GetTabLabel(selectedTab)), + FileTypeChoices = new[] + { + new FilePickerFileType("SQL Scripts") + { + Patterns = new[] { "*.sql" }, + MimeTypes = new[] { "application/sql", "text/x-sql", "text/plain" }, + AppleUniformTypeIdentifiers = new[] { "public.sql", "public.plain-text" } + }, + FilePickerFileTypes.All + } + }); + + filePath = file?.TryGetLocalPath(); + if (string.IsNullOrWhiteSpace(filePath)) + return; + + session.SourceFilePath = filePath; + } + + try + { + var resolvedFilePath = filePath!; + await File.WriteAllTextAsync(resolvedFilePath, session.QueryText); + + if (savedToNewFile) + SetTabLabel(selectedTab, Path.GetFileName(resolvedFilePath)); + } + catch (Exception ex) + { + ShowError($"Failed to save query:\n\n{ex.Message}"); + } + } + + private static string BuildSuggestedQueryFileName(string label) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(label.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()).Trim(); + if (string.IsNullOrWhiteSpace(sanitized)) + sanitized = "query"; + + return sanitized.EndsWith(".sql", StringComparison.OrdinalIgnoreCase) + ? sanitized + : $"{sanitized}.sql"; + } + + private static void SetTabLabel(TabItem tab, string label) + { + var headerText = FindHeaderTextBlock(tab.Header); + if (headerText != null) + headerText.Text = label; + } + + private static TextBlock? FindHeaderTextBlock(object? header) + { + if (header is TextBlock textBlock) + return textBlock; + + if (header is Border border) + return FindHeaderTextBlock(border.Child); + + if (header is Panel panel) + { + foreach (var child in panel.Children) + { + if (child is TextBlock tb) + return tb; + + var nested = FindHeaderTextBlock(child); + if (nested != null) + return nested; + } + } + + return null; + } + + private static bool IsHeaderButtonSource(object? source) + { + var visual = source as Visual; + while (visual != null) + { + if (visual is Button) + return true; + + visual = visual.GetVisualParent(); + } + + return false; + } + + private void TopLevelTabHeader_PointerPressed(object? sender, PointerPressedEventArgs e) + { + if (sender is not Border { Tag: TabItem tab }) + return; + + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || IsHeaderButtonSource(e.Source)) + return; + + _topLevelTabDragSource = tab; + _topLevelTabDragStart = e.GetPosition(this); + } + + private async void TopLevelTabHeader_PointerMoved(object? sender, PointerEventArgs e) + { + if (sender is not Border { Tag: TabItem tab }) + return; + + if (_topLevelTabDragSource != tab || _topLevelTabDragStart is not Point dragStart) + return; + + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + _topLevelTabDragSource = null; + _topLevelTabDragStart = null; + return; + } + + var position = e.GetPosition(this); + var delta = position - dragStart; + if (Math.Abs(delta.X) < 8 && Math.Abs(delta.Y) < 8) + return; + + _topLevelTabDragSource = null; + _topLevelTabDragStart = null; + + var data = new DataTransfer(); + data.Add(DataTransferItem.Create(TopLevelTabDragDataFormat, EnsureTopLevelTabDragId(tab))); + await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Move); + } + + private void TopLevelTabHeader_PointerReleased(object? sender, PointerReleasedEventArgs e) + { + _topLevelTabDragSource = null; + _topLevelTabDragStart = null; + } + + private void TopLevelTabHeader_DragOver(object? sender, DragEventArgs e) + { + if (sender is not Border { Tag: TabItem targetTab } headerRoot) + return; + + var dragId = e.DataTransfer.TryGetValue(TopLevelTabDragDataFormat); + if (dragId == null || !_topLevelTabDragLookup.TryGetValue(dragId, out var sourceTab) || sourceTab == targetTab) + { + e.DragEffects = DragDropEffects.None; + ClearTabInsertionCue(headerRoot); + return; + } + + var insertAfter = e.GetPosition(headerRoot).X >= headerRoot.Bounds.Width / 2; + ApplyTabInsertionCue(headerRoot, insertAfter); + e.DragEffects = DragDropEffects.Move; + e.Handled = true; + } + + private void TopLevelTabHeader_DragLeave(object? sender, DragEventArgs e) + { + if (sender is Border headerRoot) + ClearTabInsertionCue(headerRoot); + } + + private void TopLevelTabHeader_Drop(object? sender, DragEventArgs e) + { + if (sender is not Border { Tag: TabItem targetTab } headerRoot) + return; + + var dragId = e.DataTransfer.TryGetValue(TopLevelTabDragDataFormat); + if (dragId == null || !_topLevelTabDragLookup.TryGetValue(dragId, out var sourceTab) || sourceTab == targetTab) + return; + + var insertAfter = e.GetPosition(headerRoot).X >= headerRoot.Bounds.Width / 2; + ClearTabInsertionCue(headerRoot); + ReorderTopLevelTab(sourceTab, targetTab, insertAfter); + e.Handled = true; + } + + private void ApplyTabInsertionCue(Border headerRoot, bool insertAfter) + { + headerRoot.BorderBrush = TabDropCueBrush; + headerRoot.BorderThickness = insertAfter + ? new Thickness(0, 0, 3, 0) + : new Thickness(3, 0, 0, 0); + + if (_topLevelTabDropMarkers.TryGetValue(headerRoot, out var marker)) + { + marker.Text = insertAfter ? "▶" : "◀"; + marker.HorizontalAlignment = insertAfter ? HorizontalAlignment.Right : HorizontalAlignment.Left; + marker.Margin = insertAfter ? new Thickness(0, 0, 2, 0) : new Thickness(2, 0, 0, 0); + marker.IsVisible = true; + } + } + + private void ClearTabInsertionCue(Border headerRoot) + { + headerRoot.BorderThickness = default; + headerRoot.BorderBrush = null; + + if (_topLevelTabDropMarkers.TryGetValue(headerRoot, out var marker)) + marker.IsVisible = false; + } + + private string EnsureTopLevelTabDragId(TabItem tab) + { + foreach (var entry in _topLevelTabDragLookup) + { + if (ReferenceEquals(entry.Value, tab)) + return entry.Key; + } + + var dragId = Guid.NewGuid().ToString("N"); + _topLevelTabDragLookup[dragId] = tab; + return dragId; + } + + private void ForgetTopLevelTabDragId(TabItem tab) + { + var dragId = _topLevelTabDragLookup + .FirstOrDefault(entry => ReferenceEquals(entry.Value, tab)) + .Key; + + if (!string.IsNullOrEmpty(dragId)) + _topLevelTabDragLookup.Remove(dragId); + } + + private void ForgetTopLevelTabMarker(TabItem tab) + { + if (tab.Header is Border headerRoot) + _topLevelTabDropMarkers.Remove(headerRoot); + } + + private void ReorderTopLevelTab(TabItem sourceTab, TabItem targetTab, bool insertAfter) + { + if (MainTabControl.Items is not IList items) + return; + + var sourceIndex = items.IndexOf(sourceTab); + if (sourceIndex < 0) + return; + + items.RemoveAt(sourceIndex); + + var targetIndex = items.IndexOf(targetTab); + if (targetIndex < 0) + { + items.Add(sourceTab); + } + else + { + if (insertAfter) + targetIndex++; + + items.Insert(targetIndex, sourceTab); + } + + MainTabControl.SelectedItem = sourceTab; + } + /// /// Gets query text from a PlanViewerControl — uses QueryText if set, /// otherwise concatenates StatementText from all parsed statements. @@ -1276,14 +1677,32 @@ private void ClearRecentPlans_Click(object? sender, RoutedEventArgs e) private void SaveOpenPlans() { _appSettings.OpenPlans.Clear(); + _appSettings.OpenTabs.Clear(); foreach (var item in MainTabControl.Items) { if (item is not TabItem tab) continue; - var path = GetTabFilePath(tab); + if (tab.Content is QuerySessionControl session && !string.IsNullOrEmpty(session.SourceFilePath)) + { + _appSettings.OpenTabs.Add(new OpenTabState + { + Type = "query", + Path = session.SourceFilePath + }); + continue; + } + + var path = GetPlanTabFilePath(tab); if (!string.IsNullOrEmpty(path)) + { _appSettings.OpenPlans.Add(path); + _appSettings.OpenTabs.Add(new OpenTabState + { + Type = "plan", + Path = path + }); + } } AppSettingsService.Save(_appSettings); @@ -1297,16 +1716,36 @@ private void RestoreOpenPlans() { var restored = false; - foreach (var path in _appSettings.OpenPlans) + if (_appSettings.OpenTabs.Count > 0) { - if (File.Exists(path)) + foreach (var tabState in _appSettings.OpenTabs) { - LoadPlanFile(path); + if (string.IsNullOrWhiteSpace(tabState.Path) || !File.Exists(tabState.Path)) + continue; + + if (string.Equals(tabState.Type, "query", StringComparison.OrdinalIgnoreCase)) + LoadSqlFile(tabState.Path); + else + LoadPlanFile(tabState.Path); + restored = true; } } + else + { + // Backward-compatible restore for settings written by older versions + foreach (var path in _appSettings.OpenPlans) + { + if (File.Exists(path)) + { + LoadPlanFile(path); + restored = true; + } + } + } // Clear the open plans list now that we've restored + _appSettings.OpenTabs.Clear(); _appSettings.OpenPlans.Clear(); AppSettingsService.Save(_appSettings); diff --git a/src/PlanViewer.App/Services/AppSettingsService.cs b/src/PlanViewer.App/Services/AppSettingsService.cs index e8faf34..f0dbb8c 100644 --- a/src/PlanViewer.App/Services/AppSettingsService.cs +++ b/src/PlanViewer.App/Services/AppSettingsService.cs @@ -113,4 +113,20 @@ internal sealed class AppSettings /// [JsonPropertyName("open_plans")] public List OpenPlans { get; set; } = new(); + + /// + /// Ordered top-level tabs that can be restored on startup. + /// Includes file-backed plan/query tabs only. + /// + [JsonPropertyName("open_tabs")] + public List OpenTabs { get; set; } = new(); +} + +internal sealed class OpenTabState +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "plan"; + + [JsonPropertyName("path")] + public string Path { get; set; } = ""; }