Skip to content
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ SMO is loaded from SSMS at runtime (not bundled). Set `<Private>false</Private>`
```
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
Expand Down Expand Up @@ -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|<server>` for an excluded server and `D|<server>|<database>` 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:
Expand Down
56 changes: 50 additions & 6 deletions src/SqlPilot.Core/Persistence/LineStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,19 @@ public static List<DatabaseObject> 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
});
}
Expand All @@ -62,14 +64,56 @@ public static Dictionary<string, string> 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));
}

return result;
}

/// <summary>
/// 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.
/// </summary>
public static string Join(params string[] parts) => string.Join(Sep.ToString(), parts.Select(Esc));

/// <summary>Inverse of <see cref="Join"/>: split on unescaped separators and unescape each part.</summary>
public static string[] Split(string value)
{
var parts = new List<string>();
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();
}

/// <summary>
/// Position of the first separator at or after <paramref name="startIndex"/> 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.
/// </summary>
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("\\\\", "\\");

Expand Down
36 changes: 36 additions & 0 deletions src/SqlPilot.Core/Scope/ISearchScopeStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;

namespace SqlPilot.Core.Scope
{
/// <summary>
/// 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.
/// </summary>
public interface ISearchScopeStore
{
bool IsServerIncluded(string serverName);

/// <summary>
/// False when the database itself is excluded or its server is.
/// </summary>
bool IsDatabaseIncluded(string serverName, string databaseName);

void SetServerIncluded(string serverName, bool included);

void SetDatabaseIncluded(string serverName, string databaseName, bool included);

IReadOnlyCollection<string> GetExcludedServers();

IReadOnlyCollection<string> GetExcludedDatabases(string serverName);

void Save();

void Load();

event EventHandler ScopeChanged;
}
}
140 changes: 140 additions & 0 deletions src/SqlPilot.Core/Scope/SearchScopeStore.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Exclusion-list implementation of <see cref="ISearchScopeStore"/>.
///
/// 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".
/// </summary>
public sealed class SearchScopeStore : ISearchScopeStore
{
private const string ServerPrefix = "S";
private const string DatabasePrefix = "D";

private readonly string _filePath;

private readonly ConcurrentDictionary<string, byte> _excludedServers = NewNameSet();

private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _excludedDatabases =
new ConcurrentDictionary<string, ConcurrentDictionary<string, byte>>(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<string> GetExcludedServers() => _excludedServers.Keys.ToList();

public IReadOnlyCollection<string> GetExcludedDatabases(string serverName)
{
if (!string.IsNullOrEmpty(serverName) && _excludedDatabases.TryGetValue(serverName, out var databases))
return databases.Keys.ToList();

return Array.Empty<string>();
}

// Composite keys are "S|<server>" and "D|<server>|<database>", 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<string, string>();

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<string, byte> NewNameSet()
=> new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
}
}
2 changes: 2 additions & 0 deletions src/SqlPilot.Core/Search/ISearchEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Task RefreshIndexAsync(

void ClearServer(string serverName);

void ClearDatabase(string serverName, string databaseName);

void ClearAll();

int GetIndexedObjectCount();
Expand Down
Loading
Loading