Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/PlanViewer.App/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
<MenuItem Header="_Paste Plan XML" Click="PasteXml_Click"
InputGesture="Ctrl+V"/>
<Separator/>
<MenuItem x:Name="RecentPlansMenu" Header="_Recent Plans"/>
<Separator/>
<MenuItem Header="E_xit" Click="Exit_Click"
InputGesture="Alt+F4"/>
</MenuItem>
Expand Down
148 changes: 145 additions & 3 deletions src/PlanViewer.App/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -88,16 +93,16 @@ 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]))
{
LoadPlanFile(args[1]);
}
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -1175,6 +1186,137 @@ private async Task GetActualPlanFromFile(PlanViewerControl viewer)
}
}

// ── Recent Plans & Session Restore ────────────────────────────────────

/// <summary>
/// Adds a file path to the recent plans list, saves settings, and rebuilds the menu.
/// </summary>
private void TrackRecentPlan(string filePath)
{
AppSettingsService.AddRecentPlan(_appSettings, filePath);
AppSettingsService.Save(_appSettings);
RebuildRecentPlansMenu();
}

/// <summary>
/// Rebuilds the Recent Plans submenu from the current settings.
/// Shows a disabled "(empty)" item when the list is empty, plus a Clear Recent separator.
/// </summary>
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();
}

/// <summary>
/// Saves the file paths of all currently open file-based plan tabs.
/// </summary>
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);
}

/// <summary>
/// Restores plan tabs from the previous session. Skips files that no longer exist.
/// Falls back to a new query tab if nothing was restored.
/// </summary>
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
Expand Down
116 changes: 116 additions & 0 deletions src/PlanViewer.App/Services/AppSettingsService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Persists recent plans and open session state to a JSON file in the app's local data directory.
/// </summary>
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
};

/// <summary>
/// Loads settings from disk. Returns default settings if the file is missing or corrupt.
/// </summary>
public static AppSettings Load()
{
try
{
if (!File.Exists(SettingsPath))
return new AppSettings();

var json = File.ReadAllText(SettingsPath);
var settings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions);
return settings ?? new AppSettings();
}
catch
{
return new AppSettings();
}
}

/// <summary>
/// Saves settings to disk. Silently ignores write failures.
/// </summary>
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
}
}

/// <summary>
/// Adds a file path to the recent plans list (most recent first).
/// Deduplicates by full path (case-insensitive on Windows).
/// </summary>
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);
}

/// <summary>
/// Removes a specific path from the recent plans list.
/// </summary>
public static void RemoveRecentPlan(AppSettings settings, string filePath)
{
settings.RecentPlans.RemoveAll(p =>
string.Equals(p, filePath, StringComparison.OrdinalIgnoreCase));
}
}

/// <summary>
/// Serializable settings model for the application.
/// </summary>
internal sealed class AppSettings
{
/// <summary>
/// Most recently opened plan file paths, newest first. Max 10.
/// </summary>
[JsonPropertyName("recent_plans")]
public List<string> RecentPlans { get; set; } = new();

/// <summary>
/// File paths that were open when the app last closed — restored on next launch.
/// </summary>
[JsonPropertyName("open_plans")]
public List<string> OpenPlans { get; set; } = new();
}
Loading