diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9845f9c..fda5930 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Solution filters (`.slnf`).** Fido now detects Visual Studio **solution filter** files alongside
+ `.sln`/`.slnx`, so a filtered subset of a solution shows up in the "which solution?" chooser and can
+ be handed straight to the editor (Rider, Visual Studio, etc. open `.slnf` directly). When a filter
+ sits beside a same-named full solution, the full `.sln`/`.slnx` still wins as the repository's primary
+ target — the filter is offered as an additional choice, never a replacement.
+
- **Open in WebStorm.** [JetBrains **WebStorm**](https://www.jetbrains.com/webstorm/) is now a built-in
editor kind (slug `ws`), auto-detected on `PATH`, in `%LOCALAPPDATA%\Programs\WebStorm`, the JetBrains
**Toolbox** apps/shim, and `Program Files\JetBrains\WebStorm *` (macOS `/Applications`, `~/Applications`,
diff --git a/Docs/Features.md b/Docs/Features.md
index d10efe7..6358482 100644
--- a/Docs/Features.md
+++ b/Docs/Features.md
@@ -21,8 +21,8 @@ Given a branch and a solution, Fido:
1. Finds the git repository (or repositories) on your machine that contain the solution.
2. Works out where the branch should be opened — reusing an existing checkout when one
exists, or letting you switch the main tree / create a worktree when it doesn't.
-3. Opens the resolved `.sln`/`.slnx` (or the repo folder) in your chosen editor — Rider by default,
- or WebStorm / VS Code / Visual Studio / Zed / a custom editor.
+3. Opens the resolved `.sln`/`.slnx`/`.slnf` (or the repo folder) in your chosen editor — Rider by
+ default, or WebStorm / VS Code / Visual Studio / Zed / a custom editor.
Everything is keyboard-friendly, and a live log narrates each step.
@@ -55,7 +55,8 @@ the branch exists in none of your configured repos, Fido says so and does nothin
### Finding the repository
-- Matches both **`.sln`** and **`.slnx`** solution files.
+- Matches **`.sln`**, **`.slnx`**, and **`.slnf`** (Visual Studio solution filter) files. A full
+ solution wins over a same-named filter, so a repo's primary target stays the full solution.
- Walks each configured **search root** to a limited depth, skipping noise directories
(`.git`, `node_modules`, `bin`, `obj`, `.vs`, `.idea`, `packages`, `.svn`, `.hg`, and
hidden folders).
@@ -108,7 +109,7 @@ When a choice is needed, Fido shows a keyboard-navigable list with rich, two-lin
### What gets opened: solution or folder
-- **Solution mode:** a radio toggle chooses **Solution** (the `.sln`/`.slnx`) or **Folder**
+- **Solution mode:** a radio toggle chooses **Solution** (the `.sln`/`.slnx`/`.slnf`) or **Folder**
(the repo root). If solution mode can't find the file, it falls back to opening the folder.
- **Branch-only mode:** the chooser lists each solution found in the folder plus an
"open the folder" option.
@@ -127,7 +128,7 @@ and one editor is the **default**:
Built-in editor kinds — **Rider**, **WebStorm**, **VS Code**, **Visual Studio**, **Zed** — auto-detect
when their path is left blank; a **Custom** editor opens whatever executable/app-bundle path you give it.
-**WebStorm** is **folder-only**: it's always handed the repo folder rather than a `.sln`/`.slnx`.
+**WebStorm** is **folder-only**: it's always handed the repo folder rather than a `.sln`/`.slnx`/`.slnf`.
Optional extra command-line arguments can be supplied per editor (passed before the target path).
Each editor also carries a **slug** — a short command-line token (built-in defaults: `rider`, `ws`,
@@ -264,7 +265,7 @@ the next save writes to the new location.
| Branch-only mode | Open from an existing checkout, or place the branch into a configured repo that already has it |
| Cross-clone reuse | Never creates a second worktree for a branch already checked out |
| Placement | Switch main tree **or** create a linked worktree |
-| Open target | `.sln` / `.slnx` solution, or the repo folder |
+| Open target | `.sln` / `.slnx` / `.slnf` solution, or the repo folder |
| Editors | Rider / WebStorm / VS Code / Visual Studio / Zed / Custom — default + Ctrl+1…9, or by CLI slug |
| Editor discovery | Explicit path → PATH → standard installs (per kind) |
| Commit links | Short HEAD hash, clickable to the GitHub commit |
diff --git a/src/Services/OpenerService.cs b/src/Services/OpenerService.cs
index b84de60..0a6a0f0 100644
--- a/src/Services/OpenerService.cs
+++ b/src/Services/OpenerService.cs
@@ -11,8 +11,16 @@ namespace Fido.Services;
///
public sealed class OpenerService
{
- /// Solution file extensions recognised, in preference order.
- private static readonly string[] SolutionExtensions = [".sln", ".slnx"];
+ ///
+ /// Solution file extensions recognised, in preference order: a full solution (.sln/.slnx)
+ /// or a Visual Studio solution filter (.slnf) — a subset view that editors such as Rider and
+ /// Visual Studio open directly. Full solutions are listed first so they win de-duplication over a
+ /// filter that shares the same base name.
+ ///
+ private static readonly string[] SolutionExtensions = [".sln", ".slnx", ".slnf"];
+
+ /// Glob patterns for the recognised , kept in sync with it.
+ private static readonly string[] SolutionGlobs = [.. SolutionExtensions.Select(ext => "*" + ext)];
/// Project file extensions recognised when locating a repo, in preference order.
private static readonly string[] ProjectExtensions = [".csproj", ".fsproj", ".vbproj"];
@@ -64,9 +72,9 @@ public async Task> FindBranchFoldersAsync(AppConfig
return matches;
}
- /// All solution files (.sln/.slnx) under a folder, depth-limited.
+ /// All solution files (.sln/.slnx/.slnf) under a folder, depth-limited.
public IReadOnlyList FindSolutionsInFolder(string folder, AppConfig config)
- => _finder.Find([folder], ["*.sln", "*.slnx"], config.SearchDepth);
+ => _finder.Find([folder], SolutionGlobs, config.SearchDepth);
///
/// Finds repositories whose tree contains a solution or project matching ,
diff --git a/tests/Fido.Tests/Infrastructure/TestRepoWorld.cs b/tests/Fido.Tests/Infrastructure/TestRepoWorld.cs
index d9ef649..3b45a8a 100644
--- a/tests/Fido.Tests/Infrastructure/TestRepoWorld.cs
+++ b/tests/Fido.Tests/Infrastructure/TestRepoWorld.cs
@@ -100,6 +100,20 @@ public string AddWorktree(string clonePath, string branch)
return path;
}
+ ///
+ /// Writes a solution-style file (e.g. a .slnx or a .slnf filter) at
+ /// under , creating any parent
+ /// folders, and returns its full path. Contents are irrelevant to discovery — Fido
+ /// matches on the file name — so a placeholder is fine.
+ ///
+ public static string WriteSolutionFile(string dir, string relativePath, string contents = "")
+ {
+ var path = Path.Combine(dir, relativePath);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, contents);
+ return path;
+ }
+
/// Leaves an uncommitted file so git status reports the tree dirty.
public void MakeDirty(string repoPath) =>
File.WriteAllText(Path.Combine(repoPath, "uncommitted.txt"), "work in progress");
diff --git a/tests/Fido.Tests/Services/OpenerServiceTests.cs b/tests/Fido.Tests/Services/OpenerServiceTests.cs
index c834f5a..c3f6f96 100644
--- a/tests/Fido.Tests/Services/OpenerServiceTests.cs
+++ b/tests/Fido.Tests/Services/OpenerServiceTests.cs
@@ -25,6 +25,53 @@ public async Task Two_clones_of_one_origin_are_two_repositories()
await Assert.That(repos.Count).IsEqualTo(2);
}
+ [Test]
+ public async Task FindSolutionsInFolder_offers_sln_slnx_and_slnf_files()
+ {
+ using var world = new TestRepoWorld();
+ var folder = world.SearchRoot("repo");
+ TestRepoWorld.WriteSolutionFile(folder, "App.sln");
+ TestRepoWorld.WriteSolutionFile(folder, "App.slnx");
+ TestRepoWorld.WriteSolutionFile(folder, "App.Backend.slnf"); // a solution filter
+
+ var found = NewOpener().FindSolutionsInFolder(folder, world.Config(folder));
+
+ await Assert.That(found.Any(p => Path.GetFileName(p) == "App.sln")).IsTrue();
+ await Assert.That(found.Any(p => Path.GetFileName(p) == "App.slnx")).IsTrue();
+ await Assert.That(found.Any(p => Path.GetFileName(p) == "App.Backend.slnf")).IsTrue();
+ }
+
+ [Test]
+ public async Task A_repo_with_only_a_solution_filter_is_discovered_by_name()
+ {
+ using var world = new TestRepoWorld();
+ var origin = world.CreateOrigin("Foo", "Foo");
+ var root = world.SearchRoot("root");
+ var clone = world.Clone(origin, root, "Foo");
+ File.Delete(Path.Combine(clone, "Foo.sln")); // leave only a filter behind
+ TestRepoWorld.WriteSolutionFile(clone, "Foo.slnf");
+
+ var repos = await NewOpener().FindRepositoriesAsync("Foo", world.Config(root));
+
+ await Assert.That(repos.Count).IsEqualTo(1);
+ await Assert.That(repos[0].SolutionFileName).IsEqualTo("Foo.slnf");
+ }
+
+ [Test]
+ public async Task A_full_solution_beats_a_same_named_filter_as_the_repo_target()
+ {
+ using var world = new TestRepoWorld();
+ var origin = world.CreateOrigin("Foo", "Foo");
+ var root = world.SearchRoot("root");
+ var clone = world.Clone(origin, root, "Foo");
+ TestRepoWorld.WriteSolutionFile(clone, "Foo.slnf"); // filter sits beside Foo.sln
+
+ var repos = await NewOpener().FindRepositoriesAsync("Foo", world.Config(root));
+
+ await Assert.That(repos.Count).IsEqualTo(1);
+ await Assert.That(repos[0].SolutionFileName).IsEqualTo("Foo.sln"); // full solution wins de-dup
+ }
+
[Test]
public async Task Worktrees_of_one_clone_collapse_to_a_single_repository()
{