diff --git a/README.md b/README.md
index e2681e9..9c5d09f 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ SQL Pilot is a modern, from-scratch replacement. Same fast workflow, all the sam
## Features
- **Fuzzy search** across every database on every connected server. Indexes tables, views, stored procedures, scalar and table-valued functions, and synonyms.
+- **Search scope** — the filter button (funnel icon, `Alt+S`) opens a checkbox tree of your connected servers and their databases. Uncheck what you don't care about and it drops out of search *and* stops being indexed; exclusions persist across SSMS restarts. Everything is in scope until you say otherwise, and new databases on an in-scope server are picked up automatically.
- **Keyboard-driven** — type to filter, arrows to navigate, Enter to act, Right for the secondary action, Space for the context menu. No mouse needed.
- **Type-aware context actions** — Select Top N, Edit Top N (the real editable grid, not a SELECT script), Design Table, Script Create, Modify, Execute. Tables get *Edit Data*. Procedures and functions get *Execute*. Views get *Modify View*.
- **Favorites** — pin objects you use constantly, persisted across SSMS restarts.
@@ -70,6 +71,7 @@ SQL Pilot is a modern, from-scratch replacement. Same fast workflow, all the sam
| Key | Action |
|---|---|
| `Ctrl+D` | Open SQL Pilot from anywhere in SSMS |
+| `Alt+S` | Toggle the Scope panel — pick which servers and databases to search |
| Type letters | Fuzzy-match across all indexed objects |
| `↓` / `↑` | Navigate results |
| `Enter` | Default action — *Select Data* for tables/views, *Modify* for procs/functions |
@@ -173,7 +175,7 @@ Single AnyCPU build. Works on Windows x64 and Arm64. No native dependencies.
```
src/
-├── SqlPilot.Core/ # Search engine, models, favorites, recents (no SSMS deps)
+├── SqlPilot.Core/ # Search engine, models, favorites, recents, scope (no SSMS deps)
├── SqlPilot.Smo/ # SMO database object provider (SSMS 22 SMO 18.x)
├── SqlPilot.Smo.Legacy/ # Same source, references SSMS 18 SMO 16.x
├── SqlPilot.UI/ # WPF controls, view models, themes
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 96de992..88bc46d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -36,7 +36,7 @@ SMO is loaded from SSMS at runtime (not bundled). Set `false`
```
SqlPilot/
├── src/
-│ ├── SqlPilot.Core/ # Search engine, models, favorites, recents, settings
+│ ├── SqlPilot.Core/ # Search engine, models, favorites, recents, scope, settings
│ │ # Target: netstandard2.0 + net472 (no SSMS deps)
│ ├── SqlPilot.Smo/ # SMO database object provider (SSMS 22 SMO 18.x)
│ │ # Target: net472
@@ -77,6 +77,32 @@ Core projects shared by both extension builds: `SqlPilot.Core`, `SqlPilot.UI`. T
`release.yml` builds via `msbuild SqlPilot.sln`, which only compiles projects listed in the solution. `SqlPilot.Package.Legacy` and `SqlPilot.Smo.Legacy` must be explicitly added — they were initially missing and the first test release shipped an empty `SSMS18-20/` subfolder as a result. `build/Deploy-Dev.ps1` sidesteps this by invoking individual `.csproj` files by path, so local dev wouldn't notice the omission.
+## Persisted User State
+
+Everything the extension remembers lives in `%AppData%\SqlPilot\`, written through `SqlPilot.Core/Persistence/LineStore.cs` (a hand-rolled `|`-separated line format — see the `System.Text.Json` prohibition in CLAUDE.md):
+
+| File | Written by | Contents |
+|------|-----------|----------|
+| `favorites.json` | `FavoritesStore` | Pinned objects (line format despite the extension) |
+| `recents.json` | `RecentObjectsStore` | Recently accessed objects |
+| `settings.json` | `FileSettingsProvider` | `SqlPilotSettings` — user preferences, Tools > Options |
+| `scope.txt` | `SearchScopeStore` | Servers/databases excluded from search and indexing |
+
+### Search Scope
+
+Scope is **dynamic state, not a user preference** — it deliberately stays out of `SqlPilotSettings` and the Tools > Options page, because it's keyed to whatever servers happen to be connected.
+
+The model is **default-include**: `scope.txt` holds only exclusions, one per line, as composite keys — `S|` for an excluded server and `D||` for an excluded database. Nothing is ever persisted for an included item, so a missing or empty `scope.txt` behaves exactly like the pre-scope build, and a database that appears on an in-scope server later is indexed without asking.
+
+Both layers enforce it:
+
+- **Index time** — `SqlPilotToolWindowControl.RefreshIndexAsync()` filters each server's database list through `ISearchScopeStore.IsDatabaseIncluded` before building index tasks. Database *names* are still enumerated for out-of-scope servers (one cheap metadata query) so the scope tree and the "x of y database(s)" status stay accurate; the expensive per-database object crawl is what gets skipped.
+- **Search time** — `SearchEngine` takes an optional `ISearchScopeStore` as its third constructor parameter and skips whole index buckets whose `server/database` key is out of scope. Buckets are dropped from the index on exclusion (`ClearDatabase` / `ClearServer`) and spot-indexed on re-inclusion, so toggling a checkbox never triggers a full re-index.
+
+The UI side is `ScopeViewModel` + `ServerScopeNode` (tri-state `bool? IsChecked`) + `DatabaseScopeNode` in `SqlPilot.UI/ViewModels`, rendered by the shared `SqlPilot.UI/Controls/ScopeControl.xaml` — the same shared-`UserControl` pattern as `SearchControl`, so the tool window and the demo can't drift. The control is deliberately theme-neutral (colours inherit from the host). It is opened by the toolbar filter button or `Alt+S` — an in-window `KeyBinding` registered in each host's code-behind (`InputBindings` don't inherit `DataContext`, so a XAML-bound `Command` wouldn't resolve). Because the button is icon-only, it and every tree node carry explicit `AutomationProperties.Name` values — the labels are siblings of the checkboxes, so without them a screen reader announces an unnamed checkbox. The tree is merged — not rebuilt — on each refresh: existing check states survive, vanished databases are dropped, new ones arrive checked. `SqlPilot.UI.Demo` wires the same view models to `MockDatabaseObjectProvider` (three fake servers) so the tree can be exercised without SSMS.
+
+**LineStore gotcha**: composite keys are built with `LineStore.Join` / read back with `LineStore.Split`, so the escape scheme has exactly one owner. `LoadSettings` splits each line at the first *unescaped* separator — a plain `IndexOf('|')` would split a `D|server|database` key in the middle of its own escaped separator.
+
## SSMS Integration Points
All verified working via Phase 0 spike on SSMS 18, 20, and 22:
diff --git a/src/SqlPilot.Core/Persistence/LineStore.cs b/src/SqlPilot.Core/Persistence/LineStore.cs
index 7e4de80..e0e04af 100644
--- a/src/SqlPilot.Core/Persistence/LineStore.cs
+++ b/src/SqlPilot.Core/Persistence/LineStore.cs
@@ -29,17 +29,19 @@ public static List LoadObjects(string path)
foreach (var line in File.ReadAllLines(path))
{
- var parts = line.Split(Sep);
+ // Split, not line.Split(Sep): SaveObjects escapes separators inside a
+ // name, so a raw split would cut an object called "a|b" in half.
+ var parts = Split(line);
if (parts.Length < 5) continue;
if (int.TryParse(parts[4], out var typeInt))
{
results.Add(new DatabaseObject
{
- ServerName = Unesc(parts[0]),
- DatabaseName = Unesc(parts[1]),
- SchemaName = Unesc(parts[2]),
- ObjectName = Unesc(parts[3]),
+ ServerName = parts[0],
+ DatabaseName = parts[1],
+ SchemaName = parts[2],
+ ObjectName = parts[3],
ObjectType = (DatabaseObjectType)typeInt
});
}
@@ -62,7 +64,7 @@ public static Dictionary LoadSettings(string path)
foreach (var line in File.ReadAllLines(path))
{
- var idx = line.IndexOf(Sep);
+ var idx = IndexOfUnescaped(line);
if (idx > 0)
result[Unesc(line.Substring(0, idx))] = Unesc(line.Substring(idx + 1));
}
@@ -70,6 +72,48 @@ public static Dictionary LoadSettings(string path)
return result;
}
+ ///
+ /// Join values into a single separated string, escaping any separator they
+ /// contain. Callers that compose composite keys (see SearchScopeStore) share
+ /// this rather than rolling their own copy of the escape scheme.
+ ///
+ public static string Join(params string[] parts) => string.Join(Sep.ToString(), parts.Select(Esc));
+
+ /// Inverse of : split on unescaped separators and unescape each part.
+ public static string[] Split(string value)
+ {
+ var parts = new List();
+ int start = 0;
+
+ while (true)
+ {
+ int idx = IndexOfUnescaped(value, start);
+ if (idx < 0) break;
+
+ parts.Add(Unesc(value.Substring(start, idx - start)));
+ start = idx + 1;
+ }
+
+ parts.Add(Unesc(value.Substring(start)));
+ return parts.ToArray();
+ }
+
+ ///
+ /// Position of the first separator at or after that
+ /// isn't part of an escape sequence. Keys may legitimately contain an escaped
+ /// separator (scope.txt composes "D|server|database" keys), so a plain IndexOf
+ /// would split mid-key.
+ ///
+ private static int IndexOfUnescaped(string line, int startIndex = 0)
+ {
+ for (int i = startIndex; i < line.Length; i++)
+ {
+ if (line[i] == '\\') i++; // skip the escaped character
+ else if (line[i] == Sep) return i;
+ }
+ return -1;
+ }
+
private static string Esc(string s) => s?.Replace("\\", "\\\\").Replace("|", "\\|") ?? "";
private static string Unesc(string s) => s.Replace("\\|", "|").Replace("\\\\", "\\");
diff --git a/src/SqlPilot.Core/Scope/ISearchScopeStore.cs b/src/SqlPilot.Core/Scope/ISearchScopeStore.cs
new file mode 100644
index 0000000..2d6a4a5
--- /dev/null
+++ b/src/SqlPilot.Core/Scope/ISearchScopeStore.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+
+namespace SqlPilot.Core.Scope
+{
+ ///
+ /// Which servers and databases searching and indexing are allowed to touch.
+ ///
+ /// The model is default-include: only explicit exclusions are tracked, so a
+ /// server or database nobody has ever unchecked is in scope, and databases
+ /// that appear later are picked up automatically.
+ ///
+ public interface ISearchScopeStore
+ {
+ bool IsServerIncluded(string serverName);
+
+ ///
+ /// False when the database itself is excluded or its server is.
+ ///
+ bool IsDatabaseIncluded(string serverName, string databaseName);
+
+ void SetServerIncluded(string serverName, bool included);
+
+ void SetDatabaseIncluded(string serverName, string databaseName, bool included);
+
+ IReadOnlyCollection GetExcludedServers();
+
+ IReadOnlyCollection GetExcludedDatabases(string serverName);
+
+ void Save();
+
+ void Load();
+
+ event EventHandler ScopeChanged;
+ }
+}
diff --git a/src/SqlPilot.Core/Scope/SearchScopeStore.cs b/src/SqlPilot.Core/Scope/SearchScopeStore.cs
new file mode 100644
index 0000000..2a21492
--- /dev/null
+++ b/src/SqlPilot.Core/Scope/SearchScopeStore.cs
@@ -0,0 +1,140 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using SqlPilot.Core.Persistence;
+
+namespace SqlPilot.Core.Scope
+{
+ ///
+ /// Exclusion-list implementation of .
+ ///
+ /// Only exclusions are persisted — never inclusions — so a fresh install (or a
+ /// deleted scope file) behaves exactly like the pre-scope build: everything is
+ /// in scope.
+ ///
+ /// Server exclusion is stored separately from per-database exclusion, so
+ /// SetServerIncluded(true) leaves individual database exclusions standing. That
+ /// is the storage rule only — ScopeViewModel deliberately clears a server's
+ /// database exclusions when the user re-checks the server itself, because at the
+ /// UI level "check the server" means "give me the whole server back".
+ ///
+ public sealed class SearchScopeStore : ISearchScopeStore
+ {
+ private const string ServerPrefix = "S";
+ private const string DatabasePrefix = "D";
+
+ private readonly string _filePath;
+
+ private readonly ConcurrentDictionary _excludedServers = NewNameSet();
+
+ private readonly ConcurrentDictionary> _excludedDatabases =
+ new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase);
+
+ public event EventHandler ScopeChanged;
+
+ public SearchScopeStore(string filePath)
+ {
+ _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
+ }
+
+ public bool IsServerIncluded(string serverName)
+ {
+ if (string.IsNullOrEmpty(serverName)) return true;
+ return !_excludedServers.ContainsKey(serverName);
+ }
+
+ public bool IsDatabaseIncluded(string serverName, string databaseName)
+ {
+ if (!IsServerIncluded(serverName)) return false;
+ if (string.IsNullOrEmpty(serverName) || string.IsNullOrEmpty(databaseName)) return true;
+
+ return !(_excludedDatabases.TryGetValue(serverName, out var databases)
+ && databases.ContainsKey(databaseName));
+ }
+
+ public void SetServerIncluded(string serverName, bool included)
+ {
+ if (string.IsNullOrEmpty(serverName)) return;
+
+ bool changed = included
+ ? _excludedServers.TryRemove(serverName, out _)
+ : _excludedServers.TryAdd(serverName, 0);
+
+ if (changed) OnScopeChanged();
+ }
+
+ public void SetDatabaseIncluded(string serverName, string databaseName, bool included)
+ {
+ if (string.IsNullOrEmpty(serverName) || string.IsNullOrEmpty(databaseName)) return;
+
+ bool changed;
+ if (included)
+ {
+ changed = _excludedDatabases.TryGetValue(serverName, out var databases)
+ && databases.TryRemove(databaseName, out _);
+ }
+ else
+ {
+ changed = _excludedDatabases.GetOrAdd(serverName, _ => NewNameSet()).TryAdd(databaseName, 0);
+ }
+
+ if (changed) OnScopeChanged();
+ }
+
+ public IReadOnlyCollection GetExcludedServers() => _excludedServers.Keys.ToList();
+
+ public IReadOnlyCollection GetExcludedDatabases(string serverName)
+ {
+ if (!string.IsNullOrEmpty(serverName) && _excludedDatabases.TryGetValue(serverName, out var databases))
+ return databases.Keys.ToList();
+
+ return Array.Empty();
+ }
+
+ // Composite keys are "S|" and "D||", built with
+ // LineStore.Join so a '|' inside a name is escaped rather than mistaken for a
+ // part separator.
+ public void Save()
+ {
+ var settings = new Dictionary();
+
+ foreach (var server in _excludedServers.Keys)
+ settings[LineStore.Join(ServerPrefix, server)] = "1";
+
+ foreach (var kvp in _excludedDatabases)
+ {
+ foreach (var database in kvp.Value.Keys)
+ settings[LineStore.Join(DatabasePrefix, kvp.Key, database)] = "1";
+ }
+
+ LineStore.SaveSettings(_filePath, settings);
+ }
+
+ public void Load()
+ {
+ _excludedServers.Clear();
+ _excludedDatabases.Clear();
+
+ foreach (var key in LineStore.LoadSettings(_filePath).Keys)
+ {
+ var parts = LineStore.Split(key);
+ if (parts.Length == 2 && string.Equals(parts[0], ServerPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ _excludedServers.TryAdd(parts[1], 0);
+ }
+ else if (parts.Length == 3 && string.Equals(parts[0], DatabasePrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ _excludedDatabases.GetOrAdd(parts[1], _ => NewNameSet()).TryAdd(parts[2], 0);
+ }
+ }
+
+ OnScopeChanged();
+ }
+
+ private void OnScopeChanged() => ScopeChanged?.Invoke(this, EventArgs.Empty);
+
+ private static ConcurrentDictionary NewNameSet()
+ => new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/SqlPilot.Core/Search/ISearchEngine.cs b/src/SqlPilot.Core/Search/ISearchEngine.cs
index f38321c..3ce1abd 100644
--- a/src/SqlPilot.Core/Search/ISearchEngine.cs
+++ b/src/SqlPilot.Core/Search/ISearchEngine.cs
@@ -20,6 +20,8 @@ Task RefreshIndexAsync(
void ClearServer(string serverName);
+ void ClearDatabase(string serverName, string databaseName);
+
void ClearAll();
int GetIndexedObjectCount();
diff --git a/src/SqlPilot.Core/Search/SearchEngine.cs b/src/SqlPilot.Core/Search/SearchEngine.cs
index 0d3537c..5113b70 100644
--- a/src/SqlPilot.Core/Search/SearchEngine.cs
+++ b/src/SqlPilot.Core/Search/SearchEngine.cs
@@ -7,6 +7,7 @@
using SqlPilot.Core.Database;
using SqlPilot.Core.Favorites;
using SqlPilot.Core.Recents;
+using SqlPilot.Core.Scope;
namespace SqlPilot.Core.Search
{
@@ -16,11 +17,16 @@ public sealed class SearchEngine : ISearchEngine
new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase);
private readonly IFavoritesStore _favorites;
private readonly IRecentObjectsStore _recents;
+ private readonly ISearchScopeStore _scope;
- public SearchEngine(IFavoritesStore favorites = null, IRecentObjectsStore recents = null)
+ public SearchEngine(
+ IFavoritesStore favorites = null,
+ IRecentObjectsStore recents = null,
+ ISearchScopeStore scope = null)
{
_favorites = favorites;
_recents = recents;
+ _scope = scope;
}
public Task> SearchAsync(
@@ -41,16 +47,19 @@ public Task> SearchAsync(
{
cancellationToken.ThrowIfCancellationRequested();
- // Skip entire buckets that don't match the filter
- if (filter.ServerName != null || filter.DatabaseName != null)
+ // Skip entire buckets that are out of scope or don't match the filter.
+ // Parsing the key allocates, so only do it when something will read it.
+ if ((filter.ServerName != null || filter.DatabaseName != null || _scope != null)
+ && TryParseKey(kvp.Key, out var keyServer, out var keyDatabase))
{
- if (TryParseKey(kvp.Key, out var keyServer, out var keyDatabase))
- {
- if (filter.ServerName != null && !string.Equals(keyServer, filter.ServerName, StringComparison.OrdinalIgnoreCase))
- continue;
- if (filter.DatabaseName != null && !string.Equals(keyDatabase, filter.DatabaseName, StringComparison.OrdinalIgnoreCase))
- continue;
- }
+ if (filter.ServerName != null && !string.Equals(keyServer, filter.ServerName, StringComparison.OrdinalIgnoreCase))
+ continue;
+ if (filter.DatabaseName != null && !string.Equals(keyDatabase, filter.DatabaseName, StringComparison.OrdinalIgnoreCase))
+ continue;
+ // Hosts also drop excluded buckets from the index, so this is the
+ // backstop for a bucket indexed before the exclusion was made.
+ if (_scope != null && !_scope.IsDatabaseIncluded(keyServer, keyDatabase))
+ continue;
}
foreach (var obj in kvp.Value)
@@ -122,6 +131,10 @@ public async Task RefreshIndexAsync(
IDatabaseObjectProvider provider,
CancellationToken cancellationToken = default)
{
+ // The engine owns the scope store, so the guard lives here rather than in
+ // every caller's indexing loop.
+ if (_scope != null && !_scope.IsDatabaseIncluded(serverName, databaseName)) return;
+
var objects = await provider.GetObjectsAsync(serverName, databaseName, cancellationToken);
_index[MakeKey(serverName, databaseName)] = objects.ToList();
}
@@ -137,6 +150,11 @@ public void ClearServer(string serverName)
_index.TryRemove(key, out _);
}
+ public void ClearDatabase(string serverName, string databaseName)
+ {
+ _index.TryRemove(MakeKey(serverName, databaseName), out _);
+ }
+
public void ClearAll()
{
_index.Clear();
diff --git a/src/SqlPilot.Package/Commands/RelayInputCommand.cs b/src/SqlPilot.Package/Commands/RelayInputCommand.cs
deleted file mode 100644
index 668a30a..0000000
--- a/src/SqlPilot.Package/Commands/RelayInputCommand.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System;
-using System.Windows.Input;
-
-namespace SqlPilot.Package.Commands
-{
- internal class RelayInputCommand : ICommand
- {
- private readonly Action _execute;
-
- public RelayInputCommand(Action execute)
- {
- _execute = execute;
- }
-
- public event EventHandler CanExecuteChanged { add { } remove { } }
- public bool CanExecute(object parameter) => true;
- public void Execute(object parameter) => _execute();
- }
-}
diff --git a/src/SqlPilot.Package/SqlPilotPackage.cs b/src/SqlPilot.Package/SqlPilotPackage.cs
index f6d2c8f..32f04cf 100644
--- a/src/SqlPilot.Package/SqlPilotPackage.cs
+++ b/src/SqlPilot.Package/SqlPilotPackage.cs
@@ -7,6 +7,7 @@
using Microsoft.VisualStudio.Shell.Interop;
using SqlPilot.Core.Favorites;
using SqlPilot.Core.Recents;
+using SqlPilot.Core.Scope;
using SqlPilot.Core.Search;
using SqlPilot.Core.Settings;
using SqlPilot.Package.Commands;
@@ -30,6 +31,7 @@ public sealed class SqlPilotPackage : AsyncPackage
internal SearchEngine SearchEngine { get; private set; }
internal FavoritesStore FavoritesStore { get; private set; }
internal RecentObjectsStore RecentsStore { get; private set; }
+ internal SearchScopeStore ScopeStore { get; private set; }
internal FileSettingsProvider SettingsProvider { get; private set; }
internal ObjectExplorerBridge ObjectExplorerBridge { get; private set; }
internal ScriptingBridge ScriptingBridge { get; private set; }
@@ -53,7 +55,13 @@ protected override async Task InitializeAsync(
RecentsStore.Load();
SettingsProvider = new FileSettingsProvider(Path.Combine(DataDirectory, "settings.json"));
- SearchEngine = new SearchEngine(FavoritesStore, RecentsStore);
+
+ // Search scope is dynamic state, not a user preference — it lives in its
+ // own file rather than SqlPilotSettings / the Tools>Options page.
+ ScopeStore = new SearchScopeStore(Path.Combine(DataDirectory, "scope.txt"));
+ ScopeStore.Load();
+
+ SearchEngine = new SearchEngine(FavoritesStore, RecentsStore, ScopeStore);
await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
diff --git a/src/SqlPilot.Package/SqlPilotToolWindowControl.xaml b/src/SqlPilot.Package/SqlPilotToolWindowControl.xaml
index 48352c6..7510bf5 100644
--- a/src/SqlPilot.Package/SqlPilotToolWindowControl.xaml
+++ b/src/SqlPilot.Package/SqlPilotToolWindowControl.xaml
@@ -10,6 +10,7 @@
+
@@ -52,16 +53,35 @@
+
-
+
+
+
+
+
-
+
diff --git a/src/SqlPilot.Package/SqlPilotToolWindowControl.xaml.cs b/src/SqlPilot.Package/SqlPilotToolWindowControl.xaml.cs
index 5df04c1..b105c12 100644
--- a/src/SqlPilot.Package/SqlPilotToolWindowControl.xaml.cs
+++ b/src/SqlPilot.Package/SqlPilotToolWindowControl.xaml.cs
@@ -1,8 +1,11 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Input;
+using CommunityToolkit.Mvvm.Input;
using Microsoft.VisualStudio.Shell;
using SqlPilot.Core.Database;
using SqlPilot.Package.Services;
@@ -20,6 +23,8 @@ public partial class SqlPilotToolWindowControl : UserControl
public SearchViewModel ViewModel { get; }
+ public ScopeViewModel ScopeViewModel { get; }
+
public SqlPilotToolWindowControl(SqlPilotPackage package)
{
_package = package;
@@ -30,7 +35,26 @@ public SqlPilotToolWindowControl(SqlPilotPackage package)
ViewModel.DebounceMs = package.SettingsProvider.GetSettings().SearchDebounceMs;
SearchPanel.DataContext = ViewModel;
+ ScopeViewModel = new ScopeViewModel(package.ScopeStore);
+ ScopeViewModel.ScopeToggled += OnScopeToggled;
+ ScopePanel.DataContext = ScopeViewModel;
+
SearchPanel.ActionRequested += OnActionRequested;
+
+ // Alt+S toggles the scope panel. Added in code rather than XAML because
+ // InputBindings don't inherit DataContext, so a bound Command wouldn't resolve.
+ InputBindings.Add(new KeyBinding(new RelayCommand(ToggleScopePanel), Key.S, ModifierKeys.Alt));
+ }
+
+ /// Alt+S — drives the toggle button so button, panel and state stay in sync.
+ private void ToggleScopePanel()
+ {
+ ScopeButton.IsChecked = ScopeButton.IsChecked != true;
+
+ if (ScopeButton.IsChecked == true)
+ ScopePanelContent.FocusTree();
+ else
+ SearchPanel.FocusSearchBox();
}
public async Task RefreshIndexAsync()
@@ -38,79 +62,170 @@ public async Task RefreshIndexAsync()
try
{
IndexStatus.Text = "Indexing...";
- RefreshButton.IsEnabled = false;
+ SetBusy(true);
var servers = _package.ObjectExplorerBridge.GetConnectedServerNames();
if (servers.Count == 0)
{
IndexStatus.Text = "No servers connected. Select a server in Object Explorer and click Refresh.";
- RefreshButton.IsEnabled = true;
return;
}
_package.SearchEngine.ClearAll();
- int totalDatabases = 0;
+ ScopeViewModel.PruneServers(servers);
foreach (var serverName in servers)
- {
- IndexStatus.Text = $"Connecting to {serverName}...";
+ await IndexServerAsync(serverName);
- var connInfo = _package.ObjectExplorerBridge.GetConnectionInfo(serverName);
- var smoProvider = new SmoDatabaseObjectProvider(connInfo);
+ ScopeViewModel.UpdateSummary();
+ IndexStatus.Text = ScopeViewModel.DescribeIndexStatus(_package.SearchEngine.GetIndexedObjectCount());
- IndexStatus.Text = $"Loading databases from {serverName}...";
- var databases = await smoProvider.GetDatabaseNamesAsync(serverName);
+ // Re-run any pending search now that the index is populated
+ ViewModel.Rerun();
+ }
+ catch (Exception ex)
+ {
+ IndexStatus.Text = $"Indexing error: {ex.Message}";
+ Debug.WriteLine($"SqlPilot index error: {ex}");
+ }
+ finally
+ {
+ SetBusy(false);
+ }
+ }
- int completedDbs = 0;
- IndexStatus.Text = $"Indexing {serverName} (0/{databases.Count})...";
+ ///
+ /// Index a set of databases on one server. Parallelized — each SMO call creates
+ /// its own connection — with concurrency capped so we don't exhaust the
+ /// connection pool on servers with hundreds of databases.
+ ///
+ private async Task IndexDatabasesAsync(
+ string serverName, IDatabaseObjectProvider provider, IReadOnlyList databases)
+ {
+ int completedDbs = 0;
+ IndexStatus.Text = $"Indexing {serverName} (0/{databases.Count})...";
- // Parallelize database indexing — each SMO call creates its own connection.
- // Cap concurrency so we don't exhaust the connection pool on servers with
- // hundreds of databases.
- using (var throttle = new System.Threading.SemaphoreSlim(20))
+ using (var throttle = new System.Threading.SemaphoreSlim(20))
+ {
+ var dbTasks = databases.Select(async dbName =>
+ {
+ await throttle.WaitAsync();
+ try
{
- var dbTasks = databases.Select(async dbName =>
- {
- await throttle.WaitAsync();
- try
- {
- await Task.Run(() => _package.SearchEngine.RefreshIndexAsync(serverName, dbName, smoProvider));
- int done = System.Threading.Interlocked.Increment(ref completedDbs);
- // Fire-and-forget status update — no need to await UI thread hop
- _ = Dispatcher.BeginInvoke(new Action(() =>
- {
- IndexStatus.Text = $"Indexing {serverName} ({done}/{databases.Count}) — {dbName}";
- }));
- }
- finally
- {
- throttle.Release();
- }
- }).ToList();
-
- await Task.WhenAll(dbTasks);
+ await Task.Run(() => _package.SearchEngine.RefreshIndexAsync(serverName, dbName, provider));
+ int done = System.Threading.Interlocked.Increment(ref completedDbs);
+ // Assigned, not posted to the dispatcher: this method is entered
+ // from the UI thread and nothing here configures the awaits away
+ // from it, so we are already on it. Posting instead would queue an
+ // update that could run after the caller writes the final status
+ // and leave "Indexing ..." on screen for good.
+ IndexStatus.Text = $"Indexing {serverName} ({done}/{databases.Count}) — {dbName}";
}
- totalDatabases += databases.Count;
- }
+ finally
+ {
+ throttle.Release();
+ }
+ }).ToList();
- int objectCount = _package.SearchEngine.GetIndexedObjectCount();
- IndexStatus.Text = $"Indexed {objectCount:N0} objects in {totalDatabases} database(s) from {servers.Count} server(s).";
+ await Task.WhenAll(dbTasks);
+ }
+ }
- // Re-run any pending search now that the index is populated
+ private void OnScopeToggled(object sender, ScopeToggleEventArgs e)
+ {
+ _ = ApplyScopeToggleAsync(e);
+ }
+
+ ///
+ /// Bring the index in line with a scope toggle. Excluding drops the affected
+ /// buckets; including spot-indexes just what came back into scope, so no full
+ /// re-index is needed either way.
+ ///
+ private async Task ApplyScopeToggleAsync(ScopeToggleEventArgs e)
+ {
+ try
+ {
+ SetBusy(true);
+
+ if (e.DatabaseName == null)
+ {
+ if (e.Included)
+ await IndexServerAsync(e.ServerName, ScopeViewModel.GetDatabaseNames(e.ServerName));
+ else
+ _package.SearchEngine.ClearServer(e.ServerName);
+ }
+ else
+ {
+ if (e.Included)
+ await IndexDatabasesAsync(e.ServerName, CreateProvider(e.ServerName), new[] { e.DatabaseName });
+ else
+ _package.SearchEngine.ClearDatabase(e.ServerName, e.DatabaseName);
+ }
+
+ IndexStatus.Text = ScopeViewModel.DescribeIndexStatus(_package.SearchEngine.GetIndexedObjectCount());
ViewModel.Rerun();
}
catch (Exception ex)
{
- IndexStatus.Text = $"Indexing error: {ex.Message}";
- Debug.WriteLine($"SqlPilot index error: {ex}");
+ IndexStatus.Text = $"Scope update error: {ex.Message}";
+ Debug.WriteLine($"SqlPilot scope error: {ex}");
}
finally
{
- RefreshButton.IsEnabled = true;
+ SetBusy(false);
}
}
+ ///
+ /// Index every in-scope database on one server. lets a
+ /// caller that already knows the list (a scope toggle re-reads it off the tree) skip
+ /// the connect + metadata round trip.
+ ///
+ private async Task IndexServerAsync(string serverName, IReadOnlyList databases = null)
+ {
+ IndexStatus.Text = $"Connecting to {serverName}...";
+ var provider = CreateProvider(serverName);
+
+ if (databases == null)
+ {
+ // A server that's out of scope and already in the tree doesn't need
+ // re-enumerating — that's the connect the user excluded it to avoid.
+ var known = ScopeViewModel.GetDatabaseNames(serverName);
+ if (known.Count > 0 && !_package.ScopeStore.IsServerIncluded(serverName))
+ {
+ databases = known;
+ }
+ else
+ {
+ IndexStatus.Text = $"Loading databases from {serverName}...";
+ databases = await provider.GetDatabaseNamesAsync(serverName);
+
+ // Enumerated even for out-of-scope servers we haven't seen yet: one
+ // cheap metadata query is what keeps the scope tree and the
+ // "x of y database(s)" status honest. The expensive part — crawling
+ // each database's objects — is what scope skips.
+ ScopeViewModel.MergeServer(serverName, databases);
+ }
+ }
+
+ var inScope = databases
+ .Where(db => _package.ScopeStore.IsDatabaseIncluded(serverName, db))
+ .ToList();
+
+ if (inScope.Count > 0)
+ await IndexDatabasesAsync(serverName, provider, inScope);
+ }
+
+ private SmoDatabaseObjectProvider CreateProvider(string serverName)
+ => new SmoDatabaseObjectProvider(_package.ObjectExplorerBridge.GetConnectionInfo(serverName));
+
+ private void SetBusy(bool busy)
+ {
+ RefreshButton.IsEnabled = !busy;
+ ScopePanelContent.SetTreeEnabled(!busy);
+ }
+
private void OnActionRequested(DatabaseObject obj, string action)
{
if (obj == null) return;
diff --git a/src/SqlPilot.UI.Demo/MainWindow.xaml b/src/SqlPilot.UI.Demo/MainWindow.xaml
index 97f7eb1..1c8af62 100644
--- a/src/SqlPilot.UI.Demo/MainWindow.xaml
+++ b/src/SqlPilot.UI.Demo/MainWindow.xaml
@@ -7,6 +7,42 @@
WindowStartupLocation="CenterScreen"
Background="#1E1E1E">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SqlPilot.UI.Demo/MainWindow.xaml.cs b/src/SqlPilot.UI.Demo/MainWindow.xaml.cs
index cbfc53c..c0a0a41 100644
--- a/src/SqlPilot.UI.Demo/MainWindow.xaml.cs
+++ b/src/SqlPilot.UI.Demo/MainWindow.xaml.cs
@@ -1,4 +1,11 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
using System.Windows;
+using System.Windows.Input;
+using CommunityToolkit.Mvvm.Input;
+using SqlPilot.Core.Scope;
using SqlPilot.Core.Search;
using SqlPilot.UI.ViewModels;
@@ -6,19 +13,96 @@ namespace SqlPilot.UI.Demo
{
public partial class MainWindow : Window
{
+ private readonly SearchEngine _searchEngine;
+ private readonly SearchScopeStore _scopeStore;
+ private readonly MockDatabaseObjectProvider _provider = new MockDatabaseObjectProvider();
+
public MainWindow()
{
InitializeComponent();
- var searchEngine = new SearchEngine();
- var provider = new MockDatabaseObjectProvider();
+ // Scope survives demo restarts, same as it does in SSMS.
+ _scopeStore = new SearchScopeStore(Path.Combine(
+ Path.GetTempPath(), "SqlPilotDemo", "scope.txt"));
+ _scopeStore.Load();
+
+ _searchEngine = new SearchEngine(null, null, _scopeStore);
+
+ ScopeViewModel = new ScopeViewModel(_scopeStore);
+ ScopeViewModel.ScopeToggled += OnScopeToggled;
+ ScopePanel.DataContext = ScopeViewModel;
+
+ SearchViewModel = new SearchViewModel(_searchEngine);
+ SearchPanel.DataContext = SearchViewModel;
+
+ InputBindings.Add(new KeyBinding(new RelayCommand(ToggleScopePanel), Key.S, ModifierKeys.Alt));
+
+ Loaded += async (s, e) => await RefreshIndexAsync();
+ }
+
+ public ScopeViewModel ScopeViewModel { get; }
+
+ public SearchViewModel SearchViewModel { get; }
+
+ private async Task RefreshIndexAsync()
+ {
+ ScopeViewModel.PruneServers(MockDatabaseObjectProvider.Servers);
+
+ foreach (var serverName in MockDatabaseObjectProvider.Servers)
+ await IndexServerAsync(serverName);
+
+ ScopeViewModel.UpdateSummary();
+ IndexStatus.Text = ScopeViewModel.DescribeIndexStatus(_searchEngine.GetIndexedObjectCount());
+ SearchViewModel.Rerun();
+ }
+
+ /// Merge a server into the scope tree and index whatever is in scope.
+ private async Task IndexServerAsync(string serverName)
+ {
+ var databases = await _provider.GetDatabaseNamesAsync(serverName);
+ ScopeViewModel.MergeServer(serverName, databases);
+
+ // No scope filter here on purpose: the engine holds the same store and
+ // skips out-of-scope databases itself, and the demo exists to prove that.
+ foreach (var databaseName in databases)
+ await _searchEngine.RefreshIndexAsync(serverName, databaseName, _provider);
+ }
+
+ /// Alt+S — drives the toggle button so button, panel and state stay in sync.
+ private void ToggleScopePanel()
+ {
+ ScopeButton.IsChecked = ScopeButton.IsChecked != true;
- // Load mock data
- searchEngine.RefreshIndexAsync("localhost", "AdventureWorks", provider).Wait();
- searchEngine.RefreshIndexAsync("localhost", "Northwind", provider).Wait();
+ if (ScopeButton.IsChecked == true)
+ ScopePanelContent.FocusTree();
+ else
+ SearchPanel.FocusSearchBox();
+ }
+
+ private void OnScopeToggled(object sender, ScopeToggleEventArgs e)
+ {
+ _ = ApplyScopeToggleAsync(e);
+ }
+
+ private async Task ApplyScopeToggleAsync(ScopeToggleEventArgs e)
+ {
+ if (e.DatabaseName == null)
+ {
+ if (e.Included)
+ await IndexServerAsync(e.ServerName);
+ else
+ _searchEngine.ClearServer(e.ServerName);
+ }
+ else
+ {
+ if (e.Included)
+ await _searchEngine.RefreshIndexAsync(e.ServerName, e.DatabaseName, _provider);
+ else
+ _searchEngine.ClearDatabase(e.ServerName, e.DatabaseName);
+ }
- var vm = new SearchViewModel(searchEngine);
- SearchPanel.DataContext = vm;
+ IndexStatus.Text = ScopeViewModel.DescribeIndexStatus(_searchEngine.GetIndexedObjectCount());
+ SearchViewModel.Rerun();
}
}
}
diff --git a/src/SqlPilot.UI.Demo/MockDatabaseObjectProvider.cs b/src/SqlPilot.UI.Demo/MockDatabaseObjectProvider.cs
index 172fe3d..3054922 100644
--- a/src/SqlPilot.UI.Demo/MockDatabaseObjectProvider.cs
+++ b/src/SqlPilot.UI.Demo/MockDatabaseObjectProvider.cs
@@ -1,78 +1,130 @@
+using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SqlPilot.Core.Database;
namespace SqlPilot.UI.Demo
{
+ ///
+ /// Fake provider for the demo app. Multi-server on purpose: the scope tree only
+ /// gets interesting once there is more than one server and more than a couple of
+ /// databases to check and uncheck.
+ ///
public class MockDatabaseObjectProvider : IDatabaseObjectProvider
{
- private static readonly Dictionary> MockData = new Dictionary>
+ public static readonly IReadOnlyList Servers = new[]
{
- ["AdventureWorks"] = new List
+ "localhost\\SQL2019",
+ "prod-sql-01",
+ "contoso.database.windows.net"
+ };
+
+ private static readonly Dictionary> DatabasesByServer =
+ new Dictionary>(StringComparer.OrdinalIgnoreCase)
{
- MakeObj("AdventureWorks", "HumanResources", "Employee", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "HumanResources", "Department", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "HumanResources", "EmployeeDepartmentHistory", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Person", "Person", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Person", "Address", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Person", "EmailAddress", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Person", "PhoneNumber", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Sales", "Customer", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Sales", "SalesOrderHeader", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Sales", "SalesOrderDetail", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Sales", "SalesPerson", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Sales", "Store", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Production", "Product", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Production", "ProductCategory", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Production", "ProductSubcategory", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "Production", "WorkOrder", DatabaseObjectType.Table),
- MakeObj("AdventureWorks", "HumanResources", "vEmployee", DatabaseObjectType.View),
- MakeObj("AdventureWorks", "Sales", "vSalesPerson", DatabaseObjectType.View),
- MakeObj("AdventureWorks", "Production", "vProductAndDescription", DatabaseObjectType.View),
- MakeObj("AdventureWorks", "dbo", "uspGetEmployeeManagers", DatabaseObjectType.StoredProcedure),
- MakeObj("AdventureWorks", "dbo", "uspGetManagerEmployees", DatabaseObjectType.StoredProcedure),
- MakeObj("AdventureWorks", "dbo", "uspSearchCandidateResumes", DatabaseObjectType.StoredProcedure),
- MakeObj("AdventureWorks", "dbo", "ufnGetContactInformation", DatabaseObjectType.ScalarFunction),
- MakeObj("AdventureWorks", "dbo", "ufnGetProductDealerPrice", DatabaseObjectType.ScalarFunction),
- MakeObj("AdventureWorks", "dbo", "ufnGetProductListPrice", DatabaseObjectType.ScalarFunction),
- },
- ["Northwind"] = new List
+ ["localhost\\SQL2019"] = new List { "AdventureWorks", "Northwind", "ReportServer" },
+ ["prod-sql-01"] = new List { "Sales", "Inventory", "Logging", "Staging" },
+ ["contoso.database.windows.net"] = new List
+ {
+ "billing-prod", "billing-test", "identity", "telemetry", "archive-2024", "archive-2025"
+ }
+ };
+
+ private static readonly Dictionary> CuratedData =
+ new Dictionary>(StringComparer.OrdinalIgnoreCase)
{
- MakeObj("Northwind", "dbo", "Customers", DatabaseObjectType.Table),
- MakeObj("Northwind", "dbo", "Orders", DatabaseObjectType.Table),
- MakeObj("Northwind", "dbo", "OrderDetails", DatabaseObjectType.Table),
- MakeObj("Northwind", "dbo", "Products", DatabaseObjectType.Table),
- MakeObj("Northwind", "dbo", "Categories", DatabaseObjectType.Table),
- MakeObj("Northwind", "dbo", "Suppliers", DatabaseObjectType.Table),
- MakeObj("Northwind", "dbo", "Employees", DatabaseObjectType.Table),
- MakeObj("Northwind", "dbo", "Shippers", DatabaseObjectType.Table),
- MakeObj("Northwind", "dbo", "CustOrderHist", DatabaseObjectType.StoredProcedure),
- MakeObj("Northwind", "dbo", "CustOrdersDetail", DatabaseObjectType.StoredProcedure),
- MakeObj("Northwind", "dbo", "SalesByCategory", DatabaseObjectType.StoredProcedure),
- }
- };
+ ["AdventureWorks"] = new List
+ {
+ MakeObj("AdventureWorks", "HumanResources", "Employee", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "HumanResources", "Department", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "HumanResources", "EmployeeDepartmentHistory", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Person", "Person", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Person", "Address", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Person", "EmailAddress", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Person", "PhoneNumber", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Sales", "Customer", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Sales", "SalesOrderHeader", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Sales", "SalesOrderDetail", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Sales", "SalesPerson", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Sales", "Store", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Production", "Product", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Production", "ProductCategory", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Production", "ProductSubcategory", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "Production", "WorkOrder", DatabaseObjectType.Table),
+ MakeObj("AdventureWorks", "HumanResources", "vEmployee", DatabaseObjectType.View),
+ MakeObj("AdventureWorks", "Sales", "vSalesPerson", DatabaseObjectType.View),
+ MakeObj("AdventureWorks", "Production", "vProductAndDescription", DatabaseObjectType.View),
+ MakeObj("AdventureWorks", "dbo", "uspGetEmployeeManagers", DatabaseObjectType.StoredProcedure),
+ MakeObj("AdventureWorks", "dbo", "uspGetManagerEmployees", DatabaseObjectType.StoredProcedure),
+ MakeObj("AdventureWorks", "dbo", "uspSearchCandidateResumes", DatabaseObjectType.StoredProcedure),
+ MakeObj("AdventureWorks", "dbo", "ufnGetContactInformation", DatabaseObjectType.ScalarFunction),
+ MakeObj("AdventureWorks", "dbo", "ufnGetProductDealerPrice", DatabaseObjectType.ScalarFunction),
+ MakeObj("AdventureWorks", "dbo", "ufnGetProductListPrice", DatabaseObjectType.ScalarFunction),
+ },
+ ["Northwind"] = new List
+ {
+ MakeObj("Northwind", "dbo", "Customers", DatabaseObjectType.Table),
+ MakeObj("Northwind", "dbo", "Orders", DatabaseObjectType.Table),
+ MakeObj("Northwind", "dbo", "OrderDetails", DatabaseObjectType.Table),
+ MakeObj("Northwind", "dbo", "Products", DatabaseObjectType.Table),
+ MakeObj("Northwind", "dbo", "Categories", DatabaseObjectType.Table),
+ MakeObj("Northwind", "dbo", "Suppliers", DatabaseObjectType.Table),
+ MakeObj("Northwind", "dbo", "Employees", DatabaseObjectType.Table),
+ MakeObj("Northwind", "dbo", "Shippers", DatabaseObjectType.Table),
+ MakeObj("Northwind", "dbo", "CustOrderHist", DatabaseObjectType.StoredProcedure),
+ MakeObj("Northwind", "dbo", "CustOrdersDetail", DatabaseObjectType.StoredProcedure),
+ MakeObj("Northwind", "dbo", "SalesByCategory", DatabaseObjectType.StoredProcedure),
+ }
+ };
public Task> GetObjectsAsync(
string serverName, string databaseName, CancellationToken cancellationToken = default)
{
- if (MockData.TryGetValue(databaseName, out var objects))
- return Task.FromResult>(objects);
+ var objects = CuratedData.TryGetValue(databaseName, out var curated)
+ ? curated
+ : Generate(serverName, databaseName);
- return Task.FromResult>(new List());
+ return Task.FromResult>(objects);
}
public Task> GetDatabaseNamesAsync(
string serverName, CancellationToken cancellationToken = default)
{
- return Task.FromResult>(new List { "AdventureWorks", "Northwind" });
+ var databases = DatabasesByServer.TryGetValue(serverName, out var names)
+ ? names
+ : new List();
+
+ return Task.FromResult>(databases);
+ }
+
+ /// Filler objects so every database has something findable in it.
+ private static List Generate(string serverName, string databaseName)
+ {
+ var names = new[] { "Customer", "Order", "Invoice", "AuditLog", "Setting" };
+
+ return names
+ .Select(name => new DatabaseObject
+ {
+ ServerName = serverName,
+ DatabaseName = databaseName,
+ SchemaName = "dbo",
+ ObjectName = $"{databaseName.Replace("-", "")}{name}",
+ ObjectType = DatabaseObjectType.Table
+ })
+ .ToList();
}
+ // Both curated databases live on CuratedServer only, so their objects can
+ // carry the right server name from the start.
+ private const string CuratedServer = @"localhost\SQL2019";
+
private static DatabaseObject MakeObj(string db, string schema, string name, DatabaseObjectType type)
{
return new DatabaseObject
{
- ServerName = "localhost",
+ ServerName = CuratedServer,
DatabaseName = db,
SchemaName = schema,
ObjectName = name,
diff --git a/src/SqlPilot.UI/Controls/ScopeControl.xaml b/src/SqlPilot.UI/Controls/ScopeControl.xaml
new file mode 100644
index 0000000..a4cbf19
--- /dev/null
+++ b/src/SqlPilot.UI/Controls/ScopeControl.xaml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SqlPilot.UI/Controls/ScopeControl.xaml.cs b/src/SqlPilot.UI/Controls/ScopeControl.xaml.cs
new file mode 100644
index 0000000..d065d4a
--- /dev/null
+++ b/src/SqlPilot.UI/Controls/ScopeControl.xaml.cs
@@ -0,0 +1,38 @@
+using System.Windows.Controls;
+
+namespace SqlPilot.UI.Controls
+{
+ ///
+ /// The scope checkbox tree — servers with their databases. Shared by the SSMS
+ /// tool window and the demo app; its DataContext is a
+ /// .
+ ///
+ public partial class ScopeControl : UserControl
+ {
+ public ScopeControl()
+ {
+ InitializeComponent();
+ }
+
+ /// Disabled while an index pass is running so toggles can't overlap it.
+ public void SetTreeEnabled(bool enabled) => ScopeTree.IsEnabled = enabled;
+
+ ///
+ /// Put keyboard focus on the first server so arrow keys and Space work straight
+ /// away — the panel is useless to a keyboard user if opening it leaves focus behind.
+ ///
+ public void FocusTree()
+ {
+ // The panel goes Collapsed -> Visible in the same handler, so containers
+ // don't exist yet; force the layout the focus call depends on. An empty
+ // tree has nothing to lay out, and falls through to focusing itself.
+ if (ScopeTree.Items.Count > 0)
+ ScopeTree.UpdateLayout();
+
+ if (ScopeTree.ItemContainerGenerator.ContainerFromIndex(0) is TreeViewItem first)
+ first.Focus();
+ else
+ ScopeTree.Focus();
+ }
+ }
+}
diff --git a/src/SqlPilot.UI/Controls/SearchControl.xaml b/src/SqlPilot.UI/Controls/SearchControl.xaml
index da05d49..5f3f2f5 100644
--- a/src/SqlPilot.UI/Controls/SearchControl.xaml
+++ b/src/SqlPilot.UI/Controls/SearchControl.xaml
@@ -31,7 +31,7 @@
-
diff --git a/src/SqlPilot.UI/ViewModels/DatabaseScopeNode.cs b/src/SqlPilot.UI/ViewModels/DatabaseScopeNode.cs
new file mode 100644
index 0000000..8bc6b5a
--- /dev/null
+++ b/src/SqlPilot.UI/ViewModels/DatabaseScopeNode.cs
@@ -0,0 +1,52 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace SqlPilot.UI.ViewModels
+{
+ ///
+ /// One database checkbox under a .
+ ///
+ public class DatabaseScopeNode : ObservableObject
+ {
+ private readonly ServerScopeNode _server;
+ private bool _isIncluded = true;
+
+ internal DatabaseScopeNode(ServerScopeNode server, string name, bool isIncluded)
+ {
+ _server = server;
+ Name = name;
+ _isIncluded = isIncluded;
+ }
+
+ public string Name { get; }
+
+ public string ServerName => _server.Name;
+
+ ///
+ /// Written by the checkbox binding, so a change here means the user clicked.
+ /// Hand-rolled rather than [ObservableProperty] so that
+ /// can bypass that meaning.
+ ///
+ public bool IsIncluded
+ {
+ get => _isIncluded;
+ set
+ {
+ if (_isIncluded == value) return;
+ _isIncluded = value;
+ OnPropertyChanged();
+ _server.OnDatabaseToggled(this, value);
+ }
+ }
+
+ ///
+ /// Push a state that came from the store, without looping back into it as if
+ /// the user had clicked the checkbox.
+ ///
+ internal void SetIncludedSilently(bool included)
+ {
+ if (_isIncluded == included) return;
+ _isIncluded = included;
+ OnPropertyChanged(nameof(IsIncluded));
+ }
+ }
+}
diff --git a/src/SqlPilot.UI/ViewModels/ScopeViewModel.cs b/src/SqlPilot.UI/ViewModels/ScopeViewModel.cs
new file mode 100644
index 0000000..247f19e
--- /dev/null
+++ b/src/SqlPilot.UI/ViewModels/ScopeViewModel.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+using SqlPilot.Core.Scope;
+
+namespace SqlPilot.UI.ViewModels
+{
+ ///
+ /// Backs the Scope panel: a checkbox tree of connected servers and their
+ /// databases. The tree is a view over — every
+ /// user toggle writes through to the store immediately and is reported via
+ /// so the host can clear or spot-index the index.
+ ///
+ public partial class ScopeViewModel : ObservableObject
+ {
+ private readonly ISearchScopeStore _scope;
+
+ [ObservableProperty]
+ private string _summaryText = "No databases";
+
+ public ScopeViewModel(ISearchScopeStore scope)
+ {
+ _scope = scope ?? throw new ArgumentNullException(nameof(scope));
+ }
+
+ public ObservableCollection Servers { get; } = new ObservableCollection();
+
+ /// Raised after a user toggle has been written to the store.
+ public event EventHandler ScopeToggled;
+
+ ///
+ /// Merge a server's live database list into the tree. Existing check states
+ /// survive, databases that no longer exist are dropped, and new ones show up
+ /// checked unless the store says otherwise.
+ ///
+ public void MergeServer(string serverName, IReadOnlyList databaseNames)
+ {
+ var server = FindServer(serverName);
+ if (server == null)
+ {
+ server = new ServerScopeNode(this, serverName, _scope.IsServerIncluded(serverName));
+ Servers.Add(server);
+ }
+ else
+ {
+ server.IsServerIncluded = _scope.IsServerIncluded(serverName);
+ }
+
+ server.RemoveDatabasesExcept(databaseNames);
+
+ var known = server.Databases.ToDictionary(d => d.Name, StringComparer.OrdinalIgnoreCase);
+ foreach (var databaseName in databaseNames)
+ {
+ // The store already folds the server's own state in, so an excluded
+ // server shows all its databases unchecked while their individual
+ // exclusions stay put, ready for a Refresh after it is re-included.
+ bool included = _scope.IsDatabaseIncluded(serverName, databaseName);
+
+ if (known.TryGetValue(databaseName, out var existing))
+ existing.SetIncludedSilently(included);
+ else
+ server.AddDatabase(databaseName, included);
+ }
+
+ server.RaiseIsCheckedChanged();
+ }
+
+ ///
+ /// The databases already known for a server, or empty if it isn't in the tree
+ /// yet. Lets callers re-index without paying another metadata round trip.
+ ///
+ public IReadOnlyList GetDatabaseNames(string serverName)
+ => FindServer(serverName)?.Databases.Select(d => d.Name).ToList()
+ ?? (IReadOnlyList)Array.Empty();
+
+ private ServerScopeNode FindServer(string serverName)
+ => Servers.FirstOrDefault(s => string.Equals(s.Name, serverName, StringComparison.OrdinalIgnoreCase));
+
+ /// Drop servers that are no longer connected.
+ public void PruneServers(IEnumerable connectedServers)
+ {
+ var live = new HashSet(connectedServers, StringComparer.OrdinalIgnoreCase);
+ foreach (var gone in Servers.Where(s => !live.Contains(s.Name)).ToList())
+ Servers.Remove(gone);
+ }
+
+ /// Databases currently in scope across all connected servers.
+ public int IncludedDatabaseCount =>
+ Servers.Where(s => s.IsServerIncluded).Sum(s => s.Databases.Count(d => d.IsIncluded));
+
+ /// Databases visible on all connected servers, in scope or not.
+ public int TotalDatabaseCount => Servers.Sum(s => s.Databases.Count);
+
+ /// Servers contributing at least one in-scope database.
+ public int IncludedServerCount =>
+ Servers.Count(s => s.IsServerIncluded && s.Databases.Any(d => d.IsIncluded));
+
+ ///
+ /// The index status line, collapsing to the pre-scope wording
+ /// ("… in 70 database(s) from 3 server(s).") when nothing is excluded.
+ ///
+ public string DescribeIndexStatus(int objectCount)
+ {
+ string Part(int included, int total, string noun)
+ => included == total ? $"{total} {noun}(s)" : $"{included} of {total} {noun}(s)";
+
+ return $"Indexed {objectCount:N0} objects in " +
+ $"{Part(IncludedDatabaseCount, TotalDatabaseCount, "database")} from " +
+ $"{Part(IncludedServerCount, Servers.Count, "server")}.";
+ }
+
+ public void UpdateSummary()
+ {
+ int included = IncludedDatabaseCount;
+ int total = TotalDatabaseCount;
+
+ if (total == 0)
+ SummaryText = "No databases";
+ else if (included == total)
+ SummaryText = $"All {total} database(s) in scope";
+ else
+ SummaryText = $"{included} of {total} database(s) in scope";
+ }
+
+ internal void OnDatabaseToggled(ServerScopeNode server, DatabaseScopeNode database, bool included)
+ {
+ _scope.SetDatabaseIncluded(server.Name, database.Name, included);
+
+ // Checking a database on an excluded server implicitly re-includes the
+ // server — otherwise the toggle would look like it did nothing. The
+ // server's other databases stay unchecked.
+ if (included && !server.IsServerIncluded)
+ {
+ foreach (var other in server.Databases.Where(d => d != database))
+ _scope.SetDatabaseIncluded(server.Name, other.Name, false);
+
+ _scope.SetServerIncluded(server.Name, true);
+ server.IsServerIncluded = true;
+ }
+
+ _scope.Save();
+ server.RaiseIsCheckedChanged();
+ UpdateSummary();
+
+ ScopeToggled?.Invoke(this, new ScopeToggleEventArgs(server.Name, database.Name, included));
+ }
+
+ internal void OnServerToggled(ServerScopeNode server, bool included)
+ {
+ _scope.SetServerIncluded(server.Name, included);
+
+ // Checking a server clears its individual database exclusions — the user
+ // asked for the whole server back. Unchecking leaves the store's
+ // per-database exclusions alone and just greys the children out.
+ foreach (var database in server.Databases)
+ {
+ if (included)
+ _scope.SetDatabaseIncluded(server.Name, database.Name, true);
+
+ database.SetIncludedSilently(included);
+ }
+
+ _scope.Save();
+ server.IsServerIncluded = included;
+ UpdateSummary();
+
+ ScopeToggled?.Invoke(this, new ScopeToggleEventArgs(server.Name, null, included));
+ }
+
+ }
+
+ ///
+ /// A scope toggle the user just made. is null for a
+ /// server-level toggle.
+ ///
+ public sealed class ScopeToggleEventArgs : EventArgs
+ {
+ public ScopeToggleEventArgs(string serverName, string databaseName, bool included)
+ {
+ ServerName = serverName;
+ DatabaseName = databaseName;
+ Included = included;
+ }
+
+ public string ServerName { get; }
+ public string DatabaseName { get; }
+ public bool Included { get; }
+ }
+}
diff --git a/src/SqlPilot.UI/ViewModels/ServerScopeNode.cs b/src/SqlPilot.UI/ViewModels/ServerScopeNode.cs
new file mode 100644
index 0000000..0b1492b
--- /dev/null
+++ b/src/SqlPilot.UI/ViewModels/ServerScopeNode.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace SqlPilot.UI.ViewModels
+{
+ ///
+ /// A connected server in the scope tree. is tri-state:
+ /// true when everything under it is in scope, false when the server itself is
+ /// excluded (or every database is), null when only some databases are.
+ ///
+ public partial class ServerScopeNode : ObservableObject
+ {
+ private readonly ScopeViewModel _owner;
+ private bool _isServerIncluded = true;
+
+ internal ServerScopeNode(ScopeViewModel owner, string name, bool isServerIncluded)
+ {
+ _owner = owner;
+ Name = name;
+ _isServerIncluded = isServerIncluded;
+ }
+
+ public string Name { get; }
+
+ public ObservableCollection Databases { get; } = new ObservableCollection();
+
+ public bool? IsChecked
+ {
+ get
+ {
+ if (!_isServerIncluded) return false;
+ if (Databases.Count == 0) return true;
+ if (Databases.All(d => d.IsIncluded)) return true;
+ if (Databases.All(d => !d.IsIncluded)) return false;
+ return null;
+ }
+ set
+ {
+ // The checkbox is bound with IsThreeState="False", so a click only
+ // ever produces true or false; null arrives when the binding pushes
+ // the indeterminate state back and is ignored.
+ if (value == null) return;
+ if (value == IsChecked) return;
+ _owner.OnServerToggled(this, value.Value);
+ }
+ }
+
+ internal bool IsServerIncluded
+ {
+ get => _isServerIncluded;
+ set
+ {
+ _isServerIncluded = value;
+ RaiseIsCheckedChanged();
+ }
+ }
+
+ ///
+ /// Inserts at the sorted position. The collection is only ever added to here
+ /// and removed from below — both order-preserving — so it stays sorted without
+ /// a re-sort pass over a live-bound collection after every refresh.
+ ///
+ internal void AddDatabase(string name, bool isIncluded)
+ {
+ int i = 0;
+ while (i < Databases.Count
+ && StringComparer.OrdinalIgnoreCase.Compare(Databases[i].Name, name) < 0)
+ i++;
+
+ Databases.Insert(i, new DatabaseScopeNode(this, name, isIncluded));
+ }
+
+ internal void RemoveDatabasesExcept(IEnumerable keep)
+ {
+ var live = new HashSet(keep, StringComparer.OrdinalIgnoreCase);
+ foreach (var gone in Databases.Where(d => !live.Contains(d.Name)).ToList())
+ Databases.Remove(gone);
+ }
+
+ internal void RaiseIsCheckedChanged() => OnPropertyChanged(nameof(IsChecked));
+
+ internal void OnDatabaseToggled(DatabaseScopeNode database, bool included)
+ => _owner.OnDatabaseToggled(this, database, included);
+ }
+}
diff --git a/tests/SqlPilot.Core.Tests/LineStoreTests.cs b/tests/SqlPilot.Core.Tests/LineStoreTests.cs
new file mode 100644
index 0000000..5dcc686
--- /dev/null
+++ b/tests/SqlPilot.Core.Tests/LineStoreTests.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using FluentAssertions;
+using SqlPilot.Core.Database;
+using SqlPilot.Core.Persistence;
+using Xunit;
+
+namespace SqlPilot.Core.Tests
+{
+ public class LineStoreTests : IDisposable
+ {
+ private readonly string _tempFile;
+
+ public LineStoreTests()
+ {
+ _tempFile = Path.Combine(Path.GetTempPath(), $"sqlpilot_linestore_{Guid.NewGuid()}.txt");
+ }
+
+ [Fact]
+ public void SaveAndLoadSettings_RoundTripsPlainValues()
+ {
+ LineStore.SaveSettings(_tempFile, new Dictionary { ["Debounce"] = "150" });
+
+ LineStore.LoadSettings(_tempFile)["Debounce"].Should().Be("150");
+ }
+
+ [Fact]
+ public void SaveAndLoadSettings_RoundTripsKeysContainingTheSeparator()
+ {
+ LineStore.SaveSettings(_tempFile, new Dictionary { ["D|server|Db|Name"] = "1" });
+
+ LineStore.LoadSettings(_tempFile).Should().ContainKey("D|server|Db|Name");
+ }
+
+ [Fact]
+ public void SaveAndLoadSettings_RoundTripsKeysAndValuesContainingBackslashes()
+ {
+ LineStore.SaveSettings(_tempFile, new Dictionary { [@"SQL\INSTANCE"] = @"C:\path\to" });
+
+ LineStore.LoadSettings(_tempFile)[@"SQL\INSTANCE"].Should().Be(@"C:\path\to");
+ }
+
+ [Fact]
+ public void SaveAndLoadSettings_RoundTripsValuesContainingTheSeparator()
+ {
+ LineStore.SaveSettings(_tempFile, new Dictionary { ["Key"] = "a|b|c" });
+
+ LineStore.LoadSettings(_tempFile)["Key"].Should().Be("a|b|c");
+ }
+
+ [Fact]
+ public void SaveAndLoadObjects_RoundTripsNamesContainingTheSeparator()
+ {
+ var original = new DatabaseObject
+ {
+ ServerName = @"SQL\INSTANCE",
+ DatabaseName = "Sales",
+ SchemaName = "dbo",
+ ObjectName = "Weird|Name",
+ ObjectType = DatabaseObjectType.View
+ };
+
+ LineStore.SaveObjects(_tempFile, new[] { original });
+
+ var loaded = LineStore.LoadObjects(_tempFile).Should().ContainSingle().Subject;
+ loaded.ServerName.Should().Be(@"SQL\INSTANCE");
+ loaded.ObjectName.Should().Be("Weird|Name");
+ loaded.ObjectType.Should().Be(DatabaseObjectType.View);
+ }
+
+ public void Dispose()
+ {
+ if (File.Exists(_tempFile))
+ File.Delete(_tempFile);
+ }
+ }
+}
diff --git a/tests/SqlPilot.Core.Tests/SearchEngineScopeTests.cs b/tests/SqlPilot.Core.Tests/SearchEngineScopeTests.cs
new file mode 100644
index 0000000..eb895b1
--- /dev/null
+++ b/tests/SqlPilot.Core.Tests/SearchEngineScopeTests.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using NSubstitute;
+using SqlPilot.Core.Database;
+using SqlPilot.Core.Scope;
+using SqlPilot.Core.Search;
+using Xunit;
+
+namespace SqlPilot.Core.Tests
+{
+ public class SearchEngineScopeTests : IDisposable
+ {
+ private readonly string _tempFile;
+ private readonly SearchScopeStore _scope;
+ private readonly SearchEngine _engine;
+ private readonly IDatabaseObjectProvider _provider;
+
+ public SearchEngineScopeTests()
+ {
+ _tempFile = Path.Combine(Path.GetTempPath(), $"sqlpilot_enginescope_{Guid.NewGuid()}.txt");
+ _scope = new SearchScopeStore(_tempFile);
+ _engine = new SearchEngine(null, null, _scope);
+ _provider = Substitute.For();
+
+ Stub("server-a", "Sales");
+ Stub("server-a", "Warehouse");
+ Stub("server-b", "Sales");
+ }
+
+ [Fact]
+ public async Task SearchAsync_WithEmptyScope_ReturnsObjectsFromEveryDatabase()
+ {
+ await IndexAllAsync();
+
+ var results = await _engine.SearchAsync("Customer", new SearchFilter());
+
+ results.Should().HaveCount(3);
+ }
+
+ [Fact]
+ public async Task SearchAsync_SkipsExcludedDatabase()
+ {
+ await IndexAllAsync();
+
+ _scope.SetDatabaseIncluded("server-a", "Sales", false);
+
+ var results = await _engine.SearchAsync("Customer", new SearchFilter());
+ results.Should().NotContain(r => r.Object.ServerName == "server-a" && r.Object.DatabaseName == "Sales");
+ results.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public async Task SearchAsync_SkipsEveryDatabaseOfAnExcludedServer()
+ {
+ await IndexAllAsync();
+
+ _scope.SetServerIncluded("server-a", false);
+
+ var results = await _engine.SearchAsync("Customer", new SearchFilter());
+ results.Should().OnlyContain(r => r.Object.ServerName == "server-b");
+ }
+
+ [Fact]
+ public async Task SearchAsync_ReturnsDatabaseAgainAfterItIsReIncluded()
+ {
+ await IndexAllAsync();
+ _scope.SetDatabaseIncluded("server-a", "Sales", false);
+
+ _scope.SetDatabaseIncluded("server-a", "Sales", true);
+
+ var results = await _engine.SearchAsync("Customer", new SearchFilter());
+ results.Should().HaveCount(3);
+ }
+
+ [Fact]
+ public async Task SearchAsync_WithoutAScopeStore_ReturnsEverything()
+ {
+ var engine = new SearchEngine();
+ await engine.RefreshIndexAsync("server-a", "Sales", _provider);
+
+ var results = await engine.SearchAsync("Customer", new SearchFilter());
+
+ results.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public async Task ClearDatabase_RemovesOnlyThatDatabase()
+ {
+ await IndexAllAsync();
+
+ _engine.ClearDatabase("server-a", "Sales");
+
+ var results = await _engine.SearchAsync("Customer", new SearchFilter());
+ results.Should().HaveCount(2);
+ results.Should().NotContain(r => r.Object.ServerName == "server-a" && r.Object.DatabaseName == "Sales");
+ }
+
+ [Fact]
+ public async Task ClearDatabase_LeavesTheSameDatabaseNameOnOtherServersIndexed()
+ {
+ await IndexAllAsync();
+
+ _engine.ClearDatabase("server-b", "Sales");
+
+ var results = await _engine.SearchAsync("Customer", new SearchFilter());
+ results.Should().Contain(r => r.Object.ServerName == "server-a" && r.Object.DatabaseName == "Sales");
+ }
+
+ [Fact]
+ public async Task ClearDatabase_ForUnknownDatabase_DoesNothing()
+ {
+ await IndexAllAsync();
+
+ _engine.ClearDatabase("server-a", "NeverIndexed");
+
+ var results = await _engine.SearchAsync("Customer", new SearchFilter());
+ results.Should().HaveCount(3);
+ }
+
+ [Fact]
+ public async Task RefreshIndexAsync_SkipsAnExcludedDatabase()
+ {
+ _scope.SetDatabaseIncluded("server-a", "Sales", false);
+
+ await IndexAllAsync();
+
+ _scope.SetDatabaseIncluded("server-a", "Sales", true);
+ var results = await _engine.SearchAsync("Customer", new SearchFilter());
+ results.Should().NotContain(r => r.Object.ServerName == "server-a" && r.Object.DatabaseName == "Sales");
+ }
+
+ [Fact]
+ public async Task RefreshIndexAsync_SkipsEveryDatabaseOfAnExcludedServer()
+ {
+ _scope.SetServerIncluded("server-a", false);
+
+ await IndexAllAsync();
+
+ _scope.SetServerIncluded("server-a", true);
+ var results = await _engine.SearchAsync("Customer", new SearchFilter());
+ results.Should().OnlyContain(r => r.Object.ServerName == "server-b");
+ }
+
+ private async Task IndexAllAsync()
+ {
+ await _engine.RefreshIndexAsync("server-a", "Sales", _provider);
+ await _engine.RefreshIndexAsync("server-a", "Warehouse", _provider);
+ await _engine.RefreshIndexAsync("server-b", "Sales", _provider);
+ }
+
+ private void Stub(string server, string database)
+ {
+ _provider.GetObjectsAsync(server, database, Arg.Any())
+ .Returns(new List
+ {
+ new DatabaseObject
+ {
+ ServerName = server,
+ DatabaseName = database,
+ SchemaName = "dbo",
+ ObjectName = "Customer",
+ ObjectType = DatabaseObjectType.Table
+ }
+ });
+ }
+
+ public void Dispose()
+ {
+ if (File.Exists(_tempFile))
+ File.Delete(_tempFile);
+ }
+ }
+}
diff --git a/tests/SqlPilot.Core.Tests/SearchScopeStoreTests.cs b/tests/SqlPilot.Core.Tests/SearchScopeStoreTests.cs
new file mode 100644
index 0000000..a4b7a0c
--- /dev/null
+++ b/tests/SqlPilot.Core.Tests/SearchScopeStoreTests.cs
@@ -0,0 +1,261 @@
+using System;
+using System.IO;
+using FluentAssertions;
+using SqlPilot.Core.Scope;
+using Xunit;
+
+namespace SqlPilot.Core.Tests
+{
+ public class SearchScopeStoreTests : IDisposable
+ {
+ private readonly string _tempFile;
+ private readonly SearchScopeStore _store;
+
+ public SearchScopeStoreTests()
+ {
+ _tempFile = Path.Combine(Path.GetTempPath(), $"sqlpilot_scope_{Guid.NewGuid()}.txt");
+ _store = new SearchScopeStore(_tempFile);
+ }
+
+ [Fact]
+ public void UnknownServer_IsIncludedByDefault()
+ {
+ _store.IsServerIncluded("localhost").Should().BeTrue();
+ }
+
+ [Fact]
+ public void UnknownDatabase_IsIncludedByDefault()
+ {
+ _store.IsDatabaseIncluded("localhost", "TestDB").Should().BeTrue();
+ }
+
+ [Fact]
+ public void SetDatabaseIncluded_False_ExcludesThatDatabase()
+ {
+ _store.SetDatabaseIncluded("localhost", "TestDB", false);
+
+ _store.IsDatabaseIncluded("localhost", "TestDB").Should().BeFalse();
+ }
+
+ [Fact]
+ public void SetDatabaseIncluded_False_LeavesSiblingDatabasesIncluded()
+ {
+ _store.SetDatabaseIncluded("localhost", "TestDB", false);
+
+ _store.IsDatabaseIncluded("localhost", "OtherDB").Should().BeTrue();
+ }
+
+ [Fact]
+ public void SetDatabaseIncluded_False_LeavesSameNameOnOtherServerIncluded()
+ {
+ _store.SetDatabaseIncluded("localhost", "TestDB", false);
+
+ _store.IsDatabaseIncluded("other-server", "TestDB").Should().BeTrue();
+ }
+
+ [Fact]
+ public void SetDatabaseIncluded_BackToTrue_ReIncludesDatabase()
+ {
+ _store.SetDatabaseIncluded("localhost", "TestDB", false);
+
+ _store.SetDatabaseIncluded("localhost", "TestDB", true);
+
+ _store.IsDatabaseIncluded("localhost", "TestDB").Should().BeTrue();
+ _store.GetExcludedDatabases("localhost").Should().BeEmpty();
+ }
+
+ [Fact]
+ public void SetServerIncluded_False_ExcludesServer()
+ {
+ _store.SetServerIncluded("localhost", false);
+
+ _store.IsServerIncluded("localhost").Should().BeFalse();
+ }
+
+ [Fact]
+ public void ExcludedServer_ExcludesAllItsDatabases()
+ {
+ _store.SetServerIncluded("localhost", false);
+
+ _store.IsDatabaseIncluded("localhost", "AnyDatabase").Should().BeFalse();
+ }
+
+ [Fact]
+ public void SetServerIncluded_BackToTrue_KeepsIndividualDatabaseExclusions()
+ {
+ _store.SetDatabaseIncluded("localhost", "TestDB", false);
+ _store.SetServerIncluded("localhost", false);
+
+ _store.SetServerIncluded("localhost", true);
+
+ _store.IsServerIncluded("localhost").Should().BeTrue();
+ _store.IsDatabaseIncluded("localhost", "OtherDB").Should().BeTrue();
+ _store.IsDatabaseIncluded("localhost", "TestDB").Should().BeFalse();
+ }
+
+ [Fact]
+ public void ServerNames_AreCaseInsensitive()
+ {
+ _store.SetServerIncluded("LOCALHOST", false);
+
+ _store.IsServerIncluded("localhost").Should().BeFalse();
+ }
+
+ [Fact]
+ public void DatabaseNames_AreCaseInsensitive()
+ {
+ _store.SetDatabaseIncluded("LocalHost", "TESTDB", false);
+
+ _store.IsDatabaseIncluded("localhost", "testdb").Should().BeFalse();
+ }
+
+ [Fact]
+ public void GetExcludedServers_ReturnsOnlyExcludedServers()
+ {
+ _store.SetServerIncluded("server-a", false);
+ _store.SetServerIncluded("server-b", true);
+
+ _store.GetExcludedServers().Should().BeEquivalentTo(new[] { "server-a" });
+ }
+
+ [Fact]
+ public void GetExcludedDatabases_ReturnsOnlyThatServersExclusions()
+ {
+ _store.SetDatabaseIncluded("server-a", "DbOne", false);
+ _store.SetDatabaseIncluded("server-a", "DbTwo", false);
+ _store.SetDatabaseIncluded("server-b", "DbThree", false);
+
+ _store.GetExcludedDatabases("server-a").Should().BeEquivalentTo(new[] { "DbOne", "DbTwo" });
+ }
+
+ [Fact]
+ public void GetExcludedDatabases_ForUnknownServer_IsEmpty()
+ {
+ _store.GetExcludedDatabases("never-seen").Should().BeEmpty();
+ }
+
+ [Fact]
+ public void SaveAndLoad_PersistsServerAndDatabaseExclusions()
+ {
+ _store.SetServerIncluded("server-a", false);
+ _store.SetDatabaseIncluded("server-b", "TestDB", false);
+ _store.Save();
+
+ var reloaded = new SearchScopeStore(_tempFile);
+ reloaded.Load();
+
+ reloaded.IsServerIncluded("server-a").Should().BeFalse();
+ reloaded.IsDatabaseIncluded("server-b", "TestDB").Should().BeFalse();
+ reloaded.IsDatabaseIncluded("server-b", "OtherDB").Should().BeTrue();
+ }
+
+ [Fact]
+ public void SaveAndLoad_PersistsNamesContainingTheSeparator()
+ {
+ _store.SetDatabaseIncluded(@"sql\instance|odd", "Db|Name", false);
+ _store.Save();
+
+ var reloaded = new SearchScopeStore(_tempFile);
+ reloaded.Load();
+
+ reloaded.IsDatabaseIncluded(@"sql\instance|odd", "Db|Name").Should().BeFalse();
+ reloaded.IsDatabaseIncluded(@"sql\instance|odd", "Other").Should().BeTrue();
+ }
+
+ [Fact]
+ public void Save_WritesNothingForIncludedItems()
+ {
+ _store.SetServerIncluded("server-a", true);
+ _store.SetDatabaseIncluded("server-a", "TestDB", true);
+ _store.Save();
+
+ File.ReadAllText(_tempFile).Trim().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Load_WithNoFile_DoesNotThrowAndIncludesEverything()
+ {
+ var store = new SearchScopeStore(Path.Combine(Path.GetTempPath(), $"missing_{Guid.NewGuid()}.txt"));
+
+ var act = () => store.Load();
+
+ act.Should().NotThrow();
+ store.IsDatabaseIncluded("localhost", "TestDB").Should().BeTrue();
+ }
+
+ [Fact]
+ public void Load_ReplacesInMemoryState()
+ {
+ _store.SetServerIncluded("stale-server", false);
+ _store.Save();
+ _store.SetServerIncluded("stale-server", true);
+ _store.SetServerIncluded("unsaved-server", false);
+
+ _store.Load();
+
+ _store.IsServerIncluded("stale-server").Should().BeFalse();
+ _store.IsServerIncluded("unsaved-server").Should().BeTrue();
+ }
+
+ [Fact]
+ public void SetDatabaseIncluded_RaisesScopeChanged()
+ {
+ int raised = 0;
+ _store.ScopeChanged += (s, e) => raised++;
+
+ _store.SetDatabaseIncluded("localhost", "TestDB", false);
+
+ raised.Should().Be(1);
+ }
+
+ [Fact]
+ public void SetServerIncluded_RaisesScopeChanged()
+ {
+ int raised = 0;
+ _store.ScopeChanged += (s, e) => raised++;
+
+ _store.SetServerIncluded("localhost", false);
+
+ raised.Should().Be(1);
+ }
+
+ [Fact]
+ public void Set_WithNoActualChange_DoesNotRaiseScopeChanged()
+ {
+ int raised = 0;
+ _store.ScopeChanged += (s, e) => raised++;
+
+ _store.SetServerIncluded("localhost", true);
+ _store.SetDatabaseIncluded("localhost", "TestDB", true);
+
+ raised.Should().Be(0);
+ }
+
+ [Fact]
+ public void Load_RaisesScopeChanged()
+ {
+ _store.SetServerIncluded("server-a", false);
+ _store.Save();
+ int raised = 0;
+ _store.ScopeChanged += (s, e) => raised++;
+
+ _store.Load();
+
+ raised.Should().Be(1);
+ }
+
+ [Fact]
+ public void NullOrEmptyNames_AreTreatedAsIncluded()
+ {
+ _store.IsServerIncluded(null).Should().BeTrue();
+ _store.IsDatabaseIncluded("localhost", null).Should().BeTrue();
+ _store.IsDatabaseIncluded(null, "TestDB").Should().BeTrue();
+ }
+
+ public void Dispose()
+ {
+ if (File.Exists(_tempFile))
+ File.Delete(_tempFile);
+ }
+ }
+}