From 738065096b2c5b6ea5bdc2da5800072fda987e8c Mon Sep 17 00:00:00 2001 From: Christian Mourier Date: Wed, 19 Aug 2026 00:27:31 +0200 Subject: [PATCH 1/8] Add per-server and per-database search scope (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Scope button on the tool window toolbar opens a checkbox tree of the connected servers and their databases. Unchecking a database drops it from search results immediately and stops it being indexed; re-checking spot-indexes just that database rather than triggering a full refresh. Scope is an exclusion list (default-include), persisted to %AppData%\SqlPilot\scope.txt, so an empty store behaves exactly as before and new databases on an in-scope server are still picked up automatically. - SqlPilot.Core/Scope: ISearchScopeStore + SearchScopeStore, mirroring FavoritesStore. Composite keys "S|" and "D||". - SearchEngine takes an optional ISearchScopeStore and skips out-of-scope index buckets; adds ClearDatabase and GetIndexedDatabaseCount. - LineStore.LoadSettings now splits at the first *unescaped* separator — a plain IndexOf split the new composite keys mid-key. - RefreshIndexAsync filters each server's database list through the store before building index tasks. Database names are still enumerated for out-of-scope servers (one cheap query) to keep the tree and status line accurate. - Status line reports "x of y database(s) from n of m server(s)" when scope excludes anything, and the old wording when it doesn't. - UI.Demo goes multi-server so the tree is exercisable without SSMS. --- README.md | 3 +- docs/ARCHITECTURE.md | 28 +- src/SqlPilot.Core/Persistence/LineStore.cs | 17 +- src/SqlPilot.Core/Scope/ISearchScopeStore.cs | 36 +++ src/SqlPilot.Core/Scope/SearchScopeStore.cs | 175 ++++++++++++ src/SqlPilot.Core/Search/ISearchEngine.cs | 4 + src/SqlPilot.Core/Search/SearchEngine.cs | 23 +- src/SqlPilot.Package/SqlPilotPackage.cs | 10 +- .../SqlPilotToolWindowControl.xaml | 57 +++- .../SqlPilotToolWindowControl.xaml.cs | 179 +++++++++--- src/SqlPilot.UI.Demo/MainWindow.xaml | 68 ++++- src/SqlPilot.UI.Demo/MainWindow.xaml.cs | 88 +++++- .../MockDatabaseObjectProvider.cs | 149 +++++++--- .../ViewModels/DatabaseScopeNode.cs | 45 +++ src/SqlPilot.UI/ViewModels/ScopeViewModel.cs | 200 ++++++++++++++ src/SqlPilot.UI/ViewModels/ServerScopeNode.cs | 80 ++++++ tests/SqlPilot.Core.Tests/LineStoreTests.cs | 57 ++++ .../SearchEngineScopeTests.cs | 161 +++++++++++ .../SearchScopeStoreTests.cs | 261 ++++++++++++++++++ 19 files changed, 1543 insertions(+), 98 deletions(-) create mode 100644 src/SqlPilot.Core/Scope/ISearchScopeStore.cs create mode 100644 src/SqlPilot.Core/Scope/SearchScopeStore.cs create mode 100644 src/SqlPilot.UI/ViewModels/DatabaseScopeNode.cs create mode 100644 src/SqlPilot.UI/ViewModels/ScopeViewModel.cs create mode 100644 src/SqlPilot.UI/ViewModels/ServerScopeNode.cs create mode 100644 tests/SqlPilot.Core.Tests/LineStoreTests.cs create mode 100644 tests/SqlPilot.Core.Tests/SearchEngineScopeTests.cs create mode 100644 tests/SqlPilot.Core.Tests/SearchScopeStoreTests.cs diff --git a/README.md b/README.md index e2681e9..e971830 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 *Scope* button 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. @@ -173,7 +174,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..13f39c4 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`. 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**: `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 — that's why `IndexOfUnescaped` exists. + ## 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..30aaac2 100644 --- a/src/SqlPilot.Core/Persistence/LineStore.cs +++ b/src/SqlPilot.Core/Persistence/LineStore.cs @@ -62,7 +62,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 +70,21 @@ public static Dictionary LoadSettings(string path) return result; } + /// + /// Position of the first separator 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) + { + for (int i = 0; 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..279ee4c --- /dev/null +++ b/src/SqlPilot.Core/Scope/SearchScopeStore.cs @@ -0,0 +1,175 @@ +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 kept separate from per-database exclusion so + /// re-including a server doesn't silently resurrect databases the user had + /// unchecked individually. + /// + public sealed class SearchScopeStore : ISearchScopeStore + { + private const string ServerPrefix = "S"; + private const string DatabasePrefix = "D"; + private const char PartSeparator = '|'; + + private readonly string _filePath; + + private readonly ConcurrentDictionary _excludedServers = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + 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 + { + var databases = _excludedDatabases.GetOrAdd( + serverName, + _ => new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase)); + changed = databases.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(); + } + + public void Save() + { + var settings = new Dictionary(); + + foreach (var server in _excludedServers.Keys) + settings[MakeKey(ServerPrefix, server)] = "1"; + + foreach (var kvp in _excludedDatabases) + { + foreach (var database in kvp.Value.Keys) + settings[MakeKey(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 = SplitKey(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)) + { + var databases = _excludedDatabases.GetOrAdd( + parts[1], + _ => new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase)); + databases.TryAdd(parts[2], 0); + } + } + + OnScopeChanged(); + } + + private void OnScopeChanged() => ScopeChanged?.Invoke(this, EventArgs.Empty); + + // Composite keys are "S|" and "D||". Server and + // database names are escaped before being joined so a '|' inside a name can't + // be mistaken for a part separator — LineStore escapes the finished key again + // on the way to disk, which is harmless because the two passes are symmetric. + private static string MakeKey(params string[] parts) + => string.Join(PartSeparator.ToString(), parts.Select(EscapePart)); + + private static string[] SplitKey(string key) + { + var parts = new List(); + var current = new System.Text.StringBuilder(); + + for (int i = 0; i < key.Length; i++) + { + char c = key[i]; + if (c == '\\' && i + 1 < key.Length) + { + current.Append(key[++i]); + } + else if (c == PartSeparator) + { + parts.Add(current.ToString()); + current.Clear(); + } + else + { + current.Append(c); + } + } + + parts.Add(current.ToString()); + return parts.ToArray(); + } + + private static string EscapePart(string value) + => (value ?? "").Replace("\\", "\\\\").Replace("|", "\\|"); + } +} diff --git a/src/SqlPilot.Core/Search/ISearchEngine.cs b/src/SqlPilot.Core/Search/ISearchEngine.cs index f38321c..2f9d074 100644 --- a/src/SqlPilot.Core/Search/ISearchEngine.cs +++ b/src/SqlPilot.Core/Search/ISearchEngine.cs @@ -20,10 +20,14 @@ Task RefreshIndexAsync( void ClearServer(string serverName); + void ClearDatabase(string serverName, string databaseName); + void ClearAll(); int GetIndexedObjectCount(); + int GetIndexedDatabaseCount(); + int GetIndexedServerCount(); } } diff --git a/src/SqlPilot.Core/Search/SearchEngine.cs b/src/SqlPilot.Core/Search/SearchEngine.cs index 0d3537c..41f7c46 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,8 +47,8 @@ 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 + if (_scope != null || filter.ServerName != null || filter.DatabaseName != null) { if (TryParseKey(kvp.Key, out var keyServer, out var keyDatabase)) { @@ -50,6 +56,10 @@ public Task> SearchAsync( continue; if (filter.DatabaseName != null && !string.Equals(keyDatabase, filter.DatabaseName, StringComparison.OrdinalIgnoreCase)) continue; + // Scope excludes the database (or its whole server). The bucket + // stays indexed so re-including it doesn't need a re-crawl. + if (_scope != null && !_scope.IsDatabaseIncluded(keyServer, keyDatabase)) + continue; } } @@ -137,6 +147,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(); @@ -150,6 +165,8 @@ public int GetIndexedObjectCount() return count; } + public int GetIndexedDatabaseCount() => _index.Count; + public int GetIndexedServerCount() { var servers = new HashSet(StringComparer.OrdinalIgnoreCase); 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..f46827e 100644 --- a/src/SqlPilot.Package/SqlPilotToolWindowControl.xaml +++ b/src/SqlPilot.Package/SqlPilotToolWindowControl.xaml @@ -2,6 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:SqlPilot.UI.Controls;assembly=SqlPilot.UI" + xmlns:vm="clr-namespace:SqlPilot.UI.ViewModels;assembly=SqlPilot.UI" Focusable="True"> @@ -10,6 +11,7 @@ + @@ -52,16 +54,67 @@ + -