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 @@ + -