From 3277c86c85250a921eb84970b0a368a7fb9064fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 09:52:52 +0000 Subject: [PATCH] Detect and offer to open .slnf solution filter files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fido now recognises Visual Studio solution filter (.slnf) files alongside .sln/.slnx wherever it looks for solutions: the branch-only "which solution?" chooser, solution mode, and discovery-by-name. Editors such as Rider and Visual Studio open .slnf directly, so a filtered subset of a solution is now a first-class open target. Full solutions stay preferred: .slnf is listed after .sln/.slnx in the extension preference order, so when a filter sits beside a same-named full solution the .sln/.slnx still wins repository de-duplication and remains the primary target — the filter is offered as an additional choice, not a replacement. FindSolutionsInFolder now derives its globs from the shared extension list so the two can't drift. Adds OpenerService tests covering filter discovery in a folder, discovery of a filter-only repo by name, and the full-solution-wins preference, plus a TestRepoWorld helper for writing solution-style files. Updates CHANGELOG and Features docs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ro23XuZJc8Gep8rNJcGxpN --- CHANGELOG.md | 6 +++ Docs/Features.md | 13 ++--- src/Services/OpenerService.cs | 16 +++++-- .../Infrastructure/TestRepoWorld.cs | 14 ++++++ .../Fido.Tests/Services/OpenerServiceTests.cs | 47 +++++++++++++++++++ 5 files changed, 86 insertions(+), 10 deletions(-) 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() {