From 3da8a90d983941c45ef7504949c4f82f65424448 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:03:13 -0400 Subject: [PATCH] Add recent plans menu and restore open plans on startup (#67) - New AppSettingsService persists recent plans (last 10) and open session state to JSON in %LOCALAPPDATA%/PerformanceStudio/appsettings.json - File > Recent Plans submenu with clear option; gracefully handles moved/deleted files by removing them and notifying the user - On close, saves all open file-based plan tab paths; on next launch, restores them (falls back to a fresh query tab if none restored) - Only file-based plans are tracked (not clipboard paste or Query Store) Co-Authored-By: Claude Opus 4.6 --- src/PlanViewer.App/MainWindow.axaml | 2 + src/PlanViewer.App/MainWindow.axaml.cs | 148 +++++++++++++++++- .../Services/AppSettingsService.cs | 116 ++++++++++++++ 3 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 src/PlanViewer.App/Services/AppSettingsService.cs diff --git a/src/PlanViewer.App/MainWindow.axaml b/src/PlanViewer.App/MainWindow.axaml index 68372c0..40e7c87 100644 --- a/src/PlanViewer.App/MainWindow.axaml +++ b/src/PlanViewer.App/MainWindow.axaml @@ -21,6 +21,8 @@ + + diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index d01fd56..dc61e5f 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -35,17 +35,22 @@ public partial class MainWindow : Window private McpHostService? _mcpHost; private CancellationTokenSource? _mcpCts; private int _queryCounter; + private AppSettings _appSettings; public MainWindow() { _credentialService = CredentialServiceFactory.Create(); _connectionStore = new ConnectionStore(); + _appSettings = AppSettingsService.Load(); // Listen for file paths from other instances (e.g. SSMS extension) StartPipeServer(); InitializeComponent(); + // Build the Recent Plans submenu from saved state + RebuildRecentPlansMenu(); + // Wire up drag-and-drop AddHandler(DragDrop.DropEvent, OnDrop); AddHandler(DragDrop.DragOverEvent, OnDragOver); @@ -88,7 +93,7 @@ public MainWindow() } }, RoutingStrategies.Tunnel); - // Accept command-line argument or open a default query editor + // Accept command-line argument or restore previously open plans var args = Environment.GetCommandLineArgs(); if (args.Length > 1 && File.Exists(args[1])) { @@ -96,8 +101,8 @@ public MainWindow() } else { - // Open with a query editor so toolbar buttons are visible on startup - NewQuery_Click(this, new RoutedEventArgs()); + // Restore plans that were open in the previous session + RestoreOpenPlans(); } // Start MCP server if enabled in settings @@ -162,6 +167,9 @@ private void StartMcpServer() protected override async void OnClosed(EventArgs e) { + // Save the list of currently open file-based plans for session restore + SaveOpenPlans(); + _pipeCts.Cancel(); if (_mcpHost != null && _mcpCts != null) @@ -360,6 +368,9 @@ private void LoadPlanFile(string filePath) MainTabControl.Items.Add(tab); MainTabControl.SelectedItem = tab; UpdateEmptyOverlay(); + + // Track in recent plans list and persist + TrackRecentPlan(filePath); } catch (Exception ex) { @@ -1175,6 +1186,137 @@ private async Task GetActualPlanFromFile(PlanViewerControl viewer) } } + // ── Recent Plans & Session Restore ──────────────────────────────────── + + /// + /// Adds a file path to the recent plans list, saves settings, and rebuilds the menu. + /// + private void TrackRecentPlan(string filePath) + { + AppSettingsService.AddRecentPlan(_appSettings, filePath); + AppSettingsService.Save(_appSettings); + RebuildRecentPlansMenu(); + } + + /// + /// Rebuilds the Recent Plans submenu from the current settings. + /// Shows a disabled "(empty)" item when the list is empty, plus a Clear Recent separator. + /// + private void RebuildRecentPlansMenu() + { + RecentPlansMenu.Items.Clear(); + + if (_appSettings.RecentPlans.Count == 0) + { + var emptyItem = new MenuItem + { + Header = "(empty)", + IsEnabled = false + }; + RecentPlansMenu.Items.Add(emptyItem); + return; + } + + foreach (var path in _appSettings.RecentPlans) + { + var fileName = Path.GetFileName(path); + var directory = Path.GetDirectoryName(path) ?? ""; + + // Show "filename — directory" so the user can distinguish same-named files + var displayText = string.IsNullOrEmpty(directory) + ? fileName + : $"{fileName} — {directory}"; + + var item = new MenuItem + { + Header = displayText, + Tag = path + }; + + item.Click += RecentPlanItem_Click; + RecentPlansMenu.Items.Add(item); + } + + RecentPlansMenu.Items.Add(new Separator()); + + var clearItem = new MenuItem { Header = "Clear Recent Plans" }; + clearItem.Click += ClearRecentPlans_Click; + RecentPlansMenu.Items.Add(clearItem); + } + + private void RecentPlanItem_Click(object? sender, RoutedEventArgs e) + { + if (sender is not MenuItem item || item.Tag is not string path) + return; + + if (!File.Exists(path)) + { + // File was moved or deleted — remove from the list and notify the user + AppSettingsService.RemoveRecentPlan(_appSettings, path); + AppSettingsService.Save(_appSettings); + RebuildRecentPlansMenu(); + + ShowError($"The file no longer exists and has been removed from recent plans:\n\n{path}"); + return; + } + + LoadPlanFile(path); + } + + private void ClearRecentPlans_Click(object? sender, RoutedEventArgs e) + { + _appSettings.RecentPlans.Clear(); + AppSettingsService.Save(_appSettings); + RebuildRecentPlansMenu(); + } + + /// + /// Saves the file paths of all currently open file-based plan tabs. + /// + private void SaveOpenPlans() + { + _appSettings.OpenPlans.Clear(); + + foreach (var item in MainTabControl.Items) + { + if (item is not TabItem tab) continue; + + var path = GetTabFilePath(tab); + if (!string.IsNullOrEmpty(path)) + _appSettings.OpenPlans.Add(path); + } + + AppSettingsService.Save(_appSettings); + } + + /// + /// Restores plan tabs from the previous session. Skips files that no longer exist. + /// Falls back to a new query tab if nothing was restored. + /// + private void RestoreOpenPlans() + { + var restored = false; + + 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.OpenPlans.Clear(); + AppSettingsService.Save(_appSettings); + + if (!restored) + { + // Nothing to restore — open a fresh query editor like before + NewQuery_Click(this, new RoutedEventArgs()); + } + } + private void ShowError(string message) { var dialog = new Window diff --git a/src/PlanViewer.App/Services/AppSettingsService.cs b/src/PlanViewer.App/Services/AppSettingsService.cs new file mode 100644 index 0000000..e8faf34 --- /dev/null +++ b/src/PlanViewer.App/Services/AppSettingsService.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PlanViewer.App.Services; + +/// +/// Persists recent plans and open session state to a JSON file in the app's local data directory. +/// +internal sealed class AppSettingsService +{ + private const int MaxRecentPlans = 10; + private static readonly string SettingsDir; + private static readonly string SettingsPath; + + static AppSettingsService() + { + SettingsDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PerformanceStudio"); + SettingsPath = Path.Combine(SettingsDir, "appsettings.json"); + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + /// Loads settings from disk. Returns default settings if the file is missing or corrupt. + /// + public static AppSettings Load() + { + try + { + if (!File.Exists(SettingsPath)) + return new AppSettings(); + + var json = File.ReadAllText(SettingsPath); + var settings = JsonSerializer.Deserialize(json, JsonOptions); + return settings ?? new AppSettings(); + } + catch + { + return new AppSettings(); + } + } + + /// + /// Saves settings to disk. Silently ignores write failures. + /// + public static void Save(AppSettings settings) + { + try + { + Directory.CreateDirectory(SettingsDir); + var json = JsonSerializer.Serialize(settings, JsonOptions); + File.WriteAllText(SettingsPath, json); + } + catch + { + // Best-effort persistence — don't crash the app + } + } + + /// + /// Adds a file path to the recent plans list (most recent first). + /// Deduplicates by full path (case-insensitive on Windows). + /// + public static void AddRecentPlan(AppSettings settings, string filePath) + { + var fullPath = Path.GetFullPath(filePath); + + // Remove any existing entry for this path + settings.RecentPlans.RemoveAll(p => + string.Equals(p, fullPath, StringComparison.OrdinalIgnoreCase)); + + // Insert at the front + settings.RecentPlans.Insert(0, fullPath); + + // Trim to max size + if (settings.RecentPlans.Count > MaxRecentPlans) + settings.RecentPlans.RemoveRange(MaxRecentPlans, settings.RecentPlans.Count - MaxRecentPlans); + } + + /// + /// Removes a specific path from the recent plans list. + /// + public static void RemoveRecentPlan(AppSettings settings, string filePath) + { + settings.RecentPlans.RemoveAll(p => + string.Equals(p, filePath, StringComparison.OrdinalIgnoreCase)); + } +} + +/// +/// Serializable settings model for the application. +/// +internal sealed class AppSettings +{ + /// + /// Most recently opened plan file paths, newest first. Max 10. + /// + [JsonPropertyName("recent_plans")] + public List RecentPlans { get; set; } = new(); + + /// + /// File paths that were open when the app last closed — restored on next launch. + /// + [JsonPropertyName("open_plans")] + public List OpenPlans { get; set; } = new(); +}