Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
13 changes: 7 additions & 6 deletions Docs/Features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand All @@ -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`,
Expand Down Expand Up @@ -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 |
Expand Down
16 changes: 12 additions & 4 deletions src/Services/OpenerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@ namespace Fido.Services;
/// </summary>
public sealed class OpenerService
{
/// <summary>Solution file extensions recognised, in preference order.</summary>
private static readonly string[] SolutionExtensions = [".sln", ".slnx"];
/// <summary>
/// Solution file extensions recognised, in preference order: a full solution (<c>.sln</c>/<c>.slnx</c>)
/// or a Visual Studio solution filter (<c>.slnf</c>) — 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.
/// </summary>
private static readonly string[] SolutionExtensions = [".sln", ".slnx", ".slnf"];

/// <summary>Glob patterns for the recognised <see cref="SolutionExtensions"/>, kept in sync with it.</summary>
private static readonly string[] SolutionGlobs = [.. SolutionExtensions.Select(ext => "*" + ext)];

/// <summary>Project file extensions recognised when locating a repo, in preference order.</summary>
private static readonly string[] ProjectExtensions = [".csproj", ".fsproj", ".vbproj"];
Expand Down Expand Up @@ -64,9 +72,9 @@ public async Task<IReadOnlyList<BranchFolder>> FindBranchFoldersAsync(AppConfig
return matches;
}

/// <summary>All solution files (.sln/.slnx) under a folder, depth-limited.</summary>
/// <summary>All solution files (.sln/.slnx/.slnf) under a folder, depth-limited.</summary>
public IReadOnlyList<string> FindSolutionsInFolder(string folder, AppConfig config)
=> _finder.Find([folder], ["*.sln", "*.slnx"], config.SearchDepth);
=> _finder.Find([folder], SolutionGlobs, config.SearchDepth);

/// <summary>
/// Finds repositories whose tree contains a solution or project matching <paramref name="solutionName"/>,
Expand Down
14 changes: 14 additions & 0 deletions tests/Fido.Tests/Infrastructure/TestRepoWorld.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ public string AddWorktree(string clonePath, string branch)
return path;
}

/// <summary>
/// Writes a solution-style file (e.g. a <c>.slnx</c> or a <c>.slnf</c> filter) at
/// <paramref name="relativePath"/> under <paramref name="dir"/>, 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.
/// </summary>
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;
}

/// <summary>Leaves an uncommitted file so <c>git status</c> reports the tree dirty.</summary>
public void MakeDirty(string repoPath) =>
File.WriteAllText(Path.Combine(repoPath, "uncommitted.txt"), "work in progress");
Expand Down
47 changes: 47 additions & 0 deletions tests/Fido.Tests/Services/OpenerServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading