From 727648d07441eba98284c606002c7ff29febd08c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 14:33:23 +0000 Subject: [PATCH 1/2] Add Console and File Explorer open targets (Windows/macOS/Linux) Fido can now open the resolved folder in a terminal or the OS file manager, alongside the editors. Both are modelled as new EditorKinds (Console, FileExplorer) so they reuse the whole open pipeline: a secondary button, a Ctrl+1..9 shortcut, and a command-line slug (`term` for the terminal, `files` for the file explorer). Both always hand over the folder, never a .sln. Cross-platform launch/locate: - Console: Windows Terminal (-d) / PowerShell / cmd; macOS Terminal via `open -a`; Linux terminal emulators, opening in the working directory. - File Explorer: explorer.exe; Finder via `open`; xdg-open / file managers. The terminal and file-manager programs are configurable per row in Settings (path blank = OS default). Launch command construction is extracted into a pure, unit-tested BuildLaunchSpec. Existing configs are migrated forward once on load (config version 2): Console and File Explorer are appended to the editor list, preserving order and the default pointer. Docs (README, Features, CHANGELOG) updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018AFP3DvYu9aiaNvtbtnBjy --- CHANGELOG.md | 13 ++ Docs/Features.md | 64 +++++-- README.md | 13 +- src/Models/AppConfig.cs | 2 +- src/Models/Editor.cs | 12 +- src/Models/EditorKind.cs | 15 ++ src/Services/ConfigService.cs | 9 + src/Services/EditorLauncher.cs | 169 ++++++++++++++++-- src/ViewModels/EditorChoice.cs | 11 +- tests/Fido.Tests/E2E/EditorSelectionTests.cs | 28 ++- .../Fido.Tests/Locator/EditorLauncherTests.cs | 45 +++++ tests/Fido.Tests/Locator/LaunchSpecTests.cs | 89 +++++++++ tests/Fido.Tests/Models/AppConfigTests.cs | 4 + .../Fido.Tests/Services/ConfigServiceTests.cs | 21 ++- 14 files changed, 442 insertions(+), 53 deletions(-) create mode 100644 tests/Fido.Tests/Locator/LaunchSpecTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index fda5930..d9fda3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Open the folder in a console or the file explorer.** Two new built-in open targets sit alongside the + editors: **Console** (slug `term`) opens a terminal at the resolved folder, and **File Explorer** (slug + `files`) reveals it in the OS file manager. Both work on **Windows, macOS, and Linux** — Console finds + Windows Terminal / PowerShell / `cmd`, macOS **Terminal**, or a Linux terminal emulator + (`x-terminal-emulator`, `gnome-terminal`, `konsole`, `xterm`, …); File Explorer uses Explorer, Finder + (`open`), or `xdg-open`. Like every other target they get a **secondary button**, a **Ctrl+1 … Ctrl+9** + shortcut, and a **command-line slug** — so `fido feature/new-ui term` drops you into a terminal on that + branch and `fido feature/new-ui files` opens its folder. Both always hand over the **folder** (never a + `.sln`), and the **terminal program is configurable**: pick the **Console** / **File Explorer** kind for an + editor row in Settings and set its path (blank = the OS default) to use a specific terminal or file manager. + Existing configs are migrated forward once on load — Console and File Explorer are **appended** to the + editor list, preserving your existing order and default. + - **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 diff --git a/Docs/Features.md b/Docs/Features.md index 6358482..1994b1f 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -113,28 +113,28 @@ When a choice is needed, Fido shows a keyboard-navigable list with rich, two-lin (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. -- **Folder-only editors:** some editors (e.g. **WebStorm**) only understand a project folder. When - one of those is the chosen editor, Fido always hands over the folder — it ignores the solution - toggle and skips the "which solution?" chooser. +- **Folder-only targets:** some targets only understand a project folder — **WebStorm**, and the + **Console** / **File Explorer** targets that open the folder itself. When one of those is chosen, Fido + always hands over the folder — it ignores the solution toggle and skips the "which solution?" chooser. ### Editors / IDEs -Fido can open the resolved target into any of several editors. The list is configured in Settings, -and one editor is the **default**: +Fido can open the resolved target into any of several editors — plus the **Console** and **File Explorer** +targets below. The list is configured in Settings, and one entry is the **default**: -- The **default** editor is launched by the **Open** button and **Enter**. -- Every other editor gets a **secondary button** on the main window and a numbered keyboard - shortcut, **Ctrl+1 … Ctrl+9** (Ctrl+N opens with the Nth editor in the list). +- The **default** target is launched by the **Open** button and **Enter**. +- Every other one gets a **secondary button** on the main window and a numbered keyboard + shortcut, **Ctrl+1 … Ctrl+9** (Ctrl+N opens with the Nth entry in the list). 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`/`.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`, -`vsc`, `vs`, `zed`) — so a specific editor can be picked when launching Fido from the command line (see -**Command-line launch**). The slug is editable per editor in Settings; leave it blank to make that -editor un-selectable from the CLI. +Each entry also carries a **slug** — a short command-line token (built-in defaults: `rider`, `ws`, +`vsc`, `vs`, `zed`, `term`, `files`) — so a specific one can be picked when launching Fido from the command +line (see **Command-line launch**). The slug is editable per entry in Settings; leave it blank to make that +entry un-selectable from the CLI. **Auto-detection** for each known kind looks, in order, at an explicit path, then your **`PATH`**, then common install locations: @@ -151,6 +151,28 @@ then common install locations: The editor is launched **detached** (Fido doesn't wait on it). If the chosen editor can't be found, Fido says so and points you to its path setting. +### Console & file explorer + +Beyond editors, Fido can open the resolved **folder** directly — handy when you just want a shell on the +branch or to browse its files. Two built-in targets, present out of the box and working on **Windows, +macOS, and Linux**: + +- **Console** *(folder-only, slug `term`)* — opens a terminal **at the folder**. Auto-detection picks the + OS default: **Windows** — Windows Terminal (`wt`), else PowerShell (`pwsh`/`powershell`), else `cmd`; + **macOS** — the **Terminal** app (via `open -a`); **Linux** — the first of `x-terminal-emulator`, + `gnome-terminal`, `konsole`, `xfce4-terminal`, `kitty`, `alacritty`, `tilix`, `xterm` on `PATH`. + **The terminal is configurable:** set the Console row's **path** to a specific terminal program (and add + arguments if needed). Most terminals open in the folder because Fido sets it as their working directory; + Windows Terminal is pointed at it explicitly with `-d`. +- **File Explorer** *(folder-only, slug `files`)* — reveals the folder in the OS file manager: **Windows** + `explorer.exe`, **macOS** Finder (via `open`), **Linux** `xdg-open` (honouring your default file manager), + else `nautilus` / `dolphin` / `thunar` / `nemo` / `pcmanfm`. The file manager is configurable via the + row's **path** too. + +Both behave like any other target — a secondary button, a **Ctrl+N** shortcut, and a CLI slug — so +`fido feature/new-ui term` opens a terminal on that branch and `fido feature/new-ui files` opens its folder. +They always hand over the **folder**, ignoring the Solution/Folder toggle and the "which solution?" chooser. + ### Mission-control console The in-app log narrates each launch like a flight-control "go around the horn" poll: @@ -204,7 +226,7 @@ chooser/decision dialogs still appear when a choice is genuinely needed: | Argument | Effect | | --- | --- | | `` (bare, first) or `--branch` / `-b` `` | Set the branch — **and auto-run the open** | -| `` (bare, second) or `--editor` / `-e` `` | Open with the editor whose **slug** matches (e.g. `rider`, `vsc`, `vs`, `zed`) instead of the default | +| `` (bare, second) or `--editor` / `-e` `` | Open with the target whose **slug** matches (e.g. `rider`, `vsc`, `vs`, `zed`, or `term` / `files` for a terminal / file manager) instead of the default | | `--solution` / `-s` `` | Set the solution name | | `--folder` | Start in Folder open-mode | @@ -212,10 +234,11 @@ For example, `fido feature/new-ui -s MyApp` opens that branch's `MyApp` solution by default, closes Fido a few seconds after Rider is launched (see **Close after opening** and **Close delay** below). -To pick a non-default editor, give its slug as the **second bare argument** — `fido feature/new-ui zed` -opens in Zed — or pass it explicitly with `--editor` / `-e`: `fido -b feature/new-ui -s MyApp -e vs`. +To pick a non-default target, give its slug as the **second bare argument** — `fido feature/new-ui zed` +opens in Zed, `fido feature/new-ui term` opens a terminal on the branch, `fido feature/new-ui files` opens +its folder — or pass it explicitly with `--editor` / `-e`: `fido -b feature/new-ui -s MyApp -e vs`. An unrecognised slug stops with a **No-go** that names it (and lists the known slugs) rather than -silently falling back to the default editor. +silently falling back to the default. --- @@ -224,11 +247,13 @@ silently falling back to the default editor. ### Settings (in the app's **Settings** panel) - **Search roots** — directories to scan for solutions / working trees (one per line). -- **Editors** — the editors/IDEs Fido can open into. Each row has a name, an optional **slug** (the +- **Editors** — the targets Fido can open into. Each row has a name, an optional **slug** (the command-line token that selects it, e.g. `rider`), a **kind** (Rider, WebStorm, VS Code, Visual Studio, - Zed, or Custom), and an optional path (blank = auto-detect for known kinds; required for Custom). Tick the + Zed, **Console**, **File Explorer**, or Custom), and an optional path (blank = auto-detect for known kinds; + required for Custom). For **Console** the path is the **terminal program** and for **File Explorer** the + **file manager** (blank = the OS default), so you can point Fido at the terminal you prefer. Tick the **●** radio to set the default (the Open button / Enter); the rest are reached by **Ctrl+1 … Ctrl+9** - or by their slug on the command line. **Add** appends a new editor; **✕** removes one. + or by their slug on the command line. **Add** appends a new row; **✕** removes one. - **Worktree root** — leave blank for the sibling `.worktrees` convention. - **New-branch repos** — the repositories Fido may place a branch into in **branch-only mode** when the branch isn't checked out anywhere. Click **Detect** to scan your search roots for git @@ -267,6 +292,7 @@ the next save writes to the new location. | Placement | Switch main tree **or** create a linked worktree | | 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 | +| Folder targets | **Console** (`term`) opens a terminal, **File Explorer** (`files`) the OS file manager — Windows / macOS / Linux | | Editor discovery | Explicit path → PATH → standard installs (per kind) | | Commit links | Short HEAD hash, clickable to the GitHub commit | | Config | `%APPDATA%\Fido\config.json` (migrates the legacy folder) | diff --git a/README.md b/README.md index 94844b5..4c385ab 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Give it a branch name; it locates the matching Git worktree on disk — switching or creating one when needed — and opens the solution or repo folder in your editor. Pick a default -(opened with **Enter**); other editors are a **Ctrl+1 … Ctrl+9** away. +(opened with **Enter**); other editors are a **Ctrl+1 … Ctrl+9** away. It can also drop you into a +**terminal** or open the folder in your **file explorer** — on Windows, macOS, and Linux.

Fido — GO! WebStorm launched; “The Eagle has landed” @@ -27,13 +28,15 @@ when a choice is genuinely needed): fido feature/new-ui # resolve the branch, open in the default editor fido feature/new-ui -s MyApp # open MyApp's solution on that branch fido feature/new-ui zed # open in a specific editor by its slug +fido feature/new-ui term # open a terminal on that branch (files = file explorer) fido -b feature/new-ui -s MyApp -e vs # the same, with explicit options ``` -Each editor has a short **slug** (built-in: `rider`, `vsc`, `vs`, `zed`) that you can pass as the -**second argument** — or explicitly with `-e` / `--editor` — to open in that editor instead of the -default. Slugs are editable per editor in **Settings**; an unknown slug stops with a clear **No-go** -rather than silently using the default. See **[Features](Docs/Features.md)** for the full reference. +Each target has a short **slug** (built-in: `rider`, `vsc`, `vs`, `zed`, plus `term` for a terminal and +`files` for the file explorer) that you can pass as the **second argument** — or explicitly with `-e` / +`--editor` — to open in that target instead of the default. Slugs are editable in **Settings**; an unknown +slug stops with a clear **No-go** rather than silently using the default. See +**[Features](Docs/Features.md)** for the full reference. --- diff --git a/src/Models/AppConfig.cs b/src/Models/AppConfig.cs index f3dd566..0af9b15 100644 --- a/src/Models/AppConfig.cs +++ b/src/Models/AppConfig.cs @@ -13,7 +13,7 @@ public sealed class AppConfig /// Latest config schema version. Bumped when a one-time forward-migration is needed (e.g. seeding a /// newly-introduced built-in editor into existing lists); see ConfigService.Normalize. /// - public const int CurrentConfigVersion = 1; + public const int CurrentConfigVersion = 2; ///

/// Schema version of the loaded config; 0 for files written before versioning. Drives the diff --git a/src/Models/Editor.cs b/src/Models/Editor.cs index c48c2df..8a8e6d3 100644 --- a/src/Models/Editor.cs +++ b/src/Models/Editor.cs @@ -22,10 +22,11 @@ public sealed class Editor public EditorKind Kind { get; set; } = EditorKind.Custom; /// - /// True when this editor understands only a project folder, never a .sln/.slnx (e.g. WebStorm). - /// Fido forces folder mode for these and skips the "which solution?" chooser, always handing over the folder. + /// True when this target understands only a project folder, never a .sln/.slnx — WebStorm, + /// or the non-editor / targets, which + /// always open the folder itself. Fido forces folder mode for these and skips the "which solution?" chooser. /// - public bool OpensFolderOnly => Kind == EditorKind.WebStorm; + public bool OpensFolderOnly => Kind is EditorKind.WebStorm or EditorKind.Console or EditorKind.FileExplorer; /// Explicit path to the executable/app bundle; auto-detected from when null/empty. public string? Path { get; set; } @@ -36,7 +37,8 @@ public sealed class Editor /// public string? Arguments { get; set; } - /// The built-in editors offered out of the box, in shortcut order; Rider is the default. + /// The built-in targets offered out of the box, in shortcut order; Rider is the default. The + /// last two — Console and File Explorer — open the folder in a terminal or the OS file manager. public static List Defaults() => new() { new Editor { Name = "Rider", Kind = EditorKind.Rider, Slug = "rider" }, @@ -44,5 +46,7 @@ public sealed class Editor new Editor { Name = "VS Code", Kind = EditorKind.VsCode, Slug = "vsc" }, new Editor { Name = "Visual Studio", Kind = EditorKind.VisualStudio, Slug = "vs" }, new Editor { Name = "Zed", Kind = EditorKind.Zed, Slug = "zed" }, + new Editor { Name = "Console", Kind = EditorKind.Console, Slug = "term" }, + new Editor { Name = "File Explorer", Kind = EditorKind.FileExplorer, Slug = "files" }, }; } diff --git a/src/Models/EditorKind.cs b/src/Models/EditorKind.cs index b54d2af..0abafad 100644 --- a/src/Models/EditorKind.cs +++ b/src/Models/EditorKind.cs @@ -23,4 +23,19 @@ public enum EditorKind /// Zed. Zed, + + /// + /// A terminal/console opened at the folder. Not an editor: the located program is a terminal emulator + /// (Windows Terminal / cmd / PowerShell, macOS Terminal, a Linux terminal) and it's always handed the + /// folder, never a .sln. The configured picks the terminal; blank + /// auto-detects the OS default. + /// + Console, + + /// + /// The OS file manager (Windows Explorer, macOS Finder, a Linux file manager) revealing the folder. + /// Like it always opens the folder; overrides the + /// file manager, blank auto-detects the OS default. + /// + FileExplorer, } diff --git a/src/Services/ConfigService.cs b/src/Services/ConfigService.cs index a8072d6..9ac26da 100644 --- a/src/Services/ConfigService.cs +++ b/src/Services/ConfigService.cs @@ -101,6 +101,15 @@ private static AppConfig Normalize(AppConfig cfg) if (cfg.Editors.All(e => e.Kind != EditorKind.WebStorm)) cfg.Editors.Add(Editor.Defaults().First(e => e.Kind == EditorKind.WebStorm)); } + if (cfg.ConfigVersion < 2) + { + // Console and File Explorer became built-in open targets. Append whichever a pre-v2 config is + // missing — appended, like WebStorm, so existing editor positions and the default index hold. + // The version stamp below stops this re-running, so removing them later makes the removal stick. + foreach (var kind in new[] { EditorKind.Console, EditorKind.FileExplorer }) + if (cfg.Editors.All(e => e.Kind != kind)) + cfg.Editors.Add(Editor.Defaults().First(e => e.Kind == kind)); + } cfg.ConfigVersion = AppConfig.CurrentConfigVersion; cfg.DefaultEditorIndex = Math.Clamp(cfg.DefaultEditorIndex, 0, cfg.Editors.Count - 1); diff --git a/src/Services/EditorLauncher.cs b/src/Services/EditorLauncher.cs index aa3ac0e..bf9286a 100644 --- a/src/Services/EditorLauncher.cs +++ b/src/Services/EditorLauncher.cs @@ -26,44 +26,103 @@ public sealed class EditorLauncher : IEditorLauncher EditorKind.VsCode => LocateVsCode(), EditorKind.VisualStudio => LocateVisualStudio(), EditorKind.Zed => LocateZed(), + EditorKind.Console => LocateConsole(), + EditorKind.FileExplorer => LocateFileExplorer(), _ => null, // Custom with no (or a missing) path → not found }; } /// Starts on without waiting for it. public void Launch(Editor editor, string executable, string targetPath) + => Run(BuildLaunchSpec(editor, executable, targetPath)); + + /// + /// Resolves how to invoke for : editors take the + /// target as an argument, while / + /// open the folder via the platform's terminal / file-manager conventions. Pure (no process is started) + /// so the per-platform command construction can be unit-tested. + /// + internal static LaunchSpec BuildLaunchSpec(Editor editor, string executable, string targetPath) => + editor.Kind switch + { + EditorKind.Console => BuildConsoleSpec(editor, executable, targetPath), + EditorKind.FileExplorer => BuildFileExplorerSpec(executable, targetPath), + _ => BuildEditorSpec(editor, executable, targetPath), + }; + + /// An editor: the target path is passed as the final argument (after any extra args). + private static LaunchSpec BuildEditorSpec(Editor editor, string executable, string targetPath) { var extra = SplitArguments(editor.Arguments); if (OperatingSystem.IsMacOS() && executable.EndsWith(".app", StringComparison.OrdinalIgnoreCase)) - { - Start("open", ["-na", executable, "--args", .. extra, targetPath]); - return; - } + return new LaunchSpec("open", ["-na", executable, "--args", .. extra, targetPath]); var ext = Path.GetExtension(executable).ToLowerInvariant(); - if (ext is ".cmd" or ".bat") - Start("cmd.exe", ["/c", executable, .. extra, targetPath]); // run the shim via the batch interpreter - else - Start(executable, [.. extra, targetPath]); // plain exe / unix binary + return ext is ".cmd" or ".bat" + ? new LaunchSpec("cmd.exe", ["/c", executable, .. extra, targetPath]) // shim via the batch interpreter + : new LaunchSpec(executable, [.. extra, targetPath]); // plain exe / unix binary + } + + /// + /// A terminal opened at . Most terminals start in the inherited working directory, + /// so the folder is set as the process's working directory rather than passed as an argument — except + /// Windows Terminal (wt), which ignores the inherited directory and needs an explicit -d, and + /// macOS, where terminals are app bundles launched through open -a. + /// + private static LaunchSpec BuildConsoleSpec(Editor editor, string executable, string folder) + { + var extra = SplitArguments(editor.Arguments); + + if (OperatingSystem.IsMacOS()) + return executable.EndsWith(".app", StringComparison.OrdinalIgnoreCase) || !executable.Contains('/') + // `open -a ` opens the folder in the terminal; extra args go after `--args` (which + // forwards them to the app), mirroring the editor `.app` arm. Omit `--args` when there are none. + ? new LaunchSpec("open", extra.Length > 0 ? ["-a", executable, folder, "--args", .. extra] : ["-a", executable, folder]) + : new LaunchSpec(executable, [.. extra], WorkingDirectory: folder); + + // Windows: shell-execute so a console window opens (and the Windows Terminal Store alias resolves). + // Windows Terminal ignores the inherited directory, so it gets an explicit -d ; cmd / + // PowerShell (or a custom shell) start in the folder via the working directory. + if (OperatingSystem.IsWindows()) + return string.Equals(Path.GetFileNameWithoutExtension(executable), "wt", StringComparison.OrdinalIgnoreCase) + ? new LaunchSpec(executable, [.. extra, "-d", folder], WorkingDirectory: folder, UseShellExecute: true) + : new LaunchSpec(executable, [.. extra], WorkingDirectory: folder, UseShellExecute: true); + + // Linux: virtually every terminal emulator opens in the inherited working directory. + return new LaunchSpec(executable, [.. extra], WorkingDirectory: folder); } + /// + /// The OS file manager revealing : explorer.exe <folder> on Windows, + /// xdg-open / a file manager on Linux, and Finder via open on macOS (a custom .app + /// file manager goes through open -a). + /// + private static LaunchSpec BuildFileExplorerSpec(string executable, string folder) => + OperatingSystem.IsMacOS() && executable.EndsWith(".app", StringComparison.OrdinalIgnoreCase) + ? new LaunchSpec("open", ["-a", executable, folder]) + : new LaunchSpec(executable, [folder]); + /// Splits the user's extra-arguments string on whitespace; null/blank yields nothing. private static string[] SplitArguments(string? arguments) => string.IsNullOrWhiteSpace(arguments) ? [] : arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - private static void Start(string fileName, string[] args) + /// Starts the planned process detached — we intentionally don't hold or await the handle. + private static void Run(LaunchSpec spec) { var psi = new ProcessStartInfo { - FileName = fileName, - UseShellExecute = false, - CreateNoWindow = true, + FileName = spec.FileName, + UseShellExecute = spec.UseShellExecute, + // A shell-executed console must keep its window; everything else launches quietly. + CreateNoWindow = !spec.UseShellExecute, }; - foreach (var arg in args) psi.ArgumentList.Add(arg); - _ = Process.Start(psi); // detached — we intentionally don't hold or await the handle + if (!string.IsNullOrEmpty(spec.WorkingDirectory)) + psi.WorkingDirectory = spec.WorkingDirectory; + foreach (var arg in spec.Arguments) psi.ArgumentList.Add(arg); + _ = Process.Start(psi); } // --- Rider -------------------------------------------------------------------------- @@ -273,10 +332,81 @@ private static void Start(string fileName, string[] args) return null; } + // --- Console (terminal) ------------------------------------------------------------- + + private static string? LocateConsole() + { + if (OperatingSystem.IsWindows()) + { + // Prefer Windows Terminal, then PowerShell, falling back to cmd (which always exists). + if (FindOnPath(["wt.exe", "pwsh.exe", "powershell.exe", "cmd.exe"]) is { } onPath) + return onPath; + + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var wt = Path.Combine(localAppData, "Microsoft", "WindowsApps", "wt.exe"); + if (File.Exists(wt)) return wt; + + var comSpec = Environment.GetEnvironmentVariable("ComSpec"); + if (!string.IsNullOrEmpty(comSpec) && File.Exists(comSpec)) return comSpec; + + var cmd = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe"); + return File.Exists(cmd) ? cmd : null; + } + + if (OperatingSystem.IsMacOS()) + { + foreach (var app in new[] { "/System/Applications/Utilities/Terminal.app", "/Applications/Utilities/Terminal.app" }) + if (Directory.Exists(app)) return app; + return "Terminal"; // resolved by name through `open -a` even if the bundle lives elsewhere + } + + // Linux: honour the Debian terminal alternative, then the common emulators. Both lookups derive from + // the one list so the PATH probe and the /usr/bin fallback can't drift apart. + return FindOnPath(LinuxTerminals) + ?? FirstExisting([.. LinuxTerminals.Select(t => "/usr/bin/" + t)]); + } + + /// Linux terminal emulators probed for the Console target, in preference order. + private static readonly string[] LinuxTerminals = + ["x-terminal-emulator", "gnome-terminal", "konsole", "xfce4-terminal", "kitty", "alacritty", "tilix", "xterm"]; + + // --- File explorer ------------------------------------------------------------------ + + private static string? LocateFileExplorer() + { + if (OperatingSystem.IsWindows()) + { + var windows = Environment.GetEnvironmentVariable("WINDIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var explorer = Path.Combine(windows, "explorer.exe"); + return File.Exists(explorer) ? explorer : FindOnPath(["explorer.exe"]); + } + + if (OperatingSystem.IsMacOS()) + return File.Exists("/usr/bin/open") ? "/usr/bin/open" : "open"; + + // Linux: xdg-open honours the user's default file manager; fall back to common ones. Both lookups + // derive from the one list so the PATH probe and the /usr/bin fallback can't drift apart. + return FindOnPath(LinuxFileManagers) + ?? FirstExisting([.. LinuxFileManagers.Select(m => "/usr/bin/" + m)]); + } + + /// Linux file managers probed for the File Explorer target; xdg-open (the default) first. + private static readonly string[] LinuxFileManagers = + ["xdg-open", "nautilus", "dolphin", "thunar", "nemo", "pcmanfm"]; + // --- Shared helpers ----------------------------------------------------------------- private static bool PathExists(string path) => File.Exists(path) || Directory.Exists(path); + /// The first of that exists as a file, or null. + private static string? FirstExisting(params string[] paths) + { + foreach (var path in paths) + if (File.Exists(path)) return path; + return null; + } + private static IEnumerable ProgramFilesDirs() { foreach (var dir in new[] @@ -344,3 +474,14 @@ private static IEnumerable SafeEnumerateDirectories(string dir, string p catch { return []; } } } + +/// +/// A planned process launch: the program to run, its arguments, an optional working directory, and whether +/// to shell-execute (used to give a terminal its own console window). Produced by +/// so the per-platform command construction is unit-testable. +/// +internal readonly record struct LaunchSpec( + string FileName, + IReadOnlyList Arguments, + string? WorkingDirectory = null, + bool UseShellExecute = false); diff --git a/src/ViewModels/EditorChoice.cs b/src/ViewModels/EditorChoice.cs index ff9a61d..9ddeb79 100644 --- a/src/ViewModels/EditorChoice.cs +++ b/src/ViewModels/EditorChoice.cs @@ -31,7 +31,8 @@ public EditorChoice(Editor editor, bool isDefault) /// The editor kinds offered in the row's drop-down. public static IReadOnlyList Kinds { get; } = new[] { - EditorKind.Rider, EditorKind.WebStorm, EditorKind.VsCode, EditorKind.VisualStudio, EditorKind.Zed, EditorKind.Custom, + EditorKind.Rider, EditorKind.WebStorm, EditorKind.VsCode, EditorKind.VisualStudio, EditorKind.Zed, + EditorKind.Console, EditorKind.FileExplorer, EditorKind.Custom, }; /// Instance view of for the row's compiled-binding ComboBox. @@ -80,7 +81,13 @@ public bool IsDefault } /// Hint shown in the path box — names the field and notes auto-detect for known kinds, "required" for Custom. - public string PathPlaceholder => _kind == EditorKind.Custom ? "path to executable (required)" : "path (blank = auto-detect)"; + public string PathPlaceholder => _kind switch + { + EditorKind.Custom => "path to executable (required)", + EditorKind.Console => "terminal program (blank = OS default)", + EditorKind.FileExplorer => "file manager (blank = OS default)", + _ => "path (blank = auto-detect)", + }; /// Materialises the row back into a persisted . public Editor ToEditor() => new() diff --git a/tests/Fido.Tests/E2E/EditorSelectionTests.cs b/tests/Fido.Tests/E2E/EditorSelectionTests.cs index 3732b3e..499c960 100644 --- a/tests/Fido.Tests/E2E/EditorSelectionTests.cs +++ b/tests/Fido.Tests/E2E/EditorSelectionTests.cs @@ -76,7 +76,7 @@ await Harness.WithWindow(services, async window => window.SetText("BranchBox", "main"); window.SetText("SolutionBox", "Foo"); - // Seeded editor order is [Rider, WebStorm, VS Code, Visual Studio, Zed]; Ctrl+3 → index 2 → VS Code. + // Seeded order is [Rider, WebStorm, VS Code, Visual Studio, Zed, Console, File Explorer]; Ctrl+3 → index 2 → VS Code. window.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, @@ -174,6 +174,32 @@ await Harness.WithWindow(services, async window => }); } + [Test] + public async Task Opening_with_the_console_hands_over_the_folder() + { + using var world = new TestRepoWorld(); + var origin = world.CreateOrigin("Foo", "Foo"); + var root = world.SearchRoot("root"); + var clone = world.Clone(origin, root, "Foo"); + + var launcher = new FakeEditorLauncher(); + var services = world.BuildServices([root], launcher, new FakeDialogService()); + + await Harness.WithWindow(services, async window => + { + var console = new Editor { Name = "Console", Kind = EditorKind.Console }; + + window.SetText("BranchBox", "main"); + window.SetText("SolutionBox", "Foo"); // solution mode, but a console only ever opens the folder + await window.RunOpenAsync(editor: console); + + await Assert.That(launcher.LastLaunch!.Value.Editor.Kind).IsEqualTo(EditorKind.Console); + await Assert.That(launcher.LastLaunch!.Value.Target).DoesNotEndWith("Foo.sln"); + await Assert.That(Paths.StartsWith(launcher.LastLaunch!.Value.Target, clone)).IsTrue(); + await Assert.That(window.Vm().StatusText).Contains("folder"); + }); + } + [Test] public async Task Secondary_editors_carry_numbered_shortcut_gestures() { diff --git a/tests/Fido.Tests/Locator/EditorLauncherTests.cs b/tests/Fido.Tests/Locator/EditorLauncherTests.cs index e62169d..f92c639 100644 --- a/tests/Fido.Tests/Locator/EditorLauncherTests.cs +++ b/tests/Fido.Tests/Locator/EditorLauncherTests.cs @@ -100,4 +100,49 @@ public async Task A_custom_editor_without_a_path_is_not_found() await Assert.That(found).IsNull(); } + + [Test] + public async Task A_console_with_an_explicit_existing_path_is_returned() + { + using var world = new TestRepoWorld(); + var exe = Path.Combine(world.Root, OperatingSystem.IsWindows() ? "myterm.exe" : "myterm"); + File.WriteAllText(exe, ""); + + var found = new EditorLauncher().Locate(new Editor { Kind = EditorKind.Console, Path = exe }); + + await Assert.That(found).IsEqualTo(exe); + } + + [Test] + public async Task A_file_explorer_with_an_explicit_existing_path_is_returned() + { + using var world = new TestRepoWorld(); + var exe = Path.Combine(world.Root, OperatingSystem.IsWindows() ? "myfiles.exe" : "myfiles"); + File.WriteAllText(exe, ""); + + var found = new EditorLauncher().Locate(new Editor { Kind = EditorKind.FileExplorer, Path = exe }); + + await Assert.That(found).IsEqualTo(exe); + } + + [Test] + public async Task A_console_auto_detects_a_terminal_on_windows() + { + // Windows always ships cmd.exe; a bare Linux runner may have no terminal emulator, so only assert here. + if (!OperatingSystem.IsWindows()) return; + + var found = new EditorLauncher().Locate(new Editor { Kind = EditorKind.Console }); + + await Assert.That(found).IsNotNull(); + } + + [Test] + public async Task A_file_explorer_auto_detects_on_windows() + { + if (!OperatingSystem.IsWindows()) return; // explorer.exe always exists; xdg-open may not on a bare runner + + var found = new EditorLauncher().Locate(new Editor { Kind = EditorKind.FileExplorer }); + + await Assert.That(found).IsNotNull(); + } } diff --git a/tests/Fido.Tests/Locator/LaunchSpecTests.cs b/tests/Fido.Tests/Locator/LaunchSpecTests.cs new file mode 100644 index 0000000..4f3979f --- /dev/null +++ b/tests/Fido.Tests/Locator/LaunchSpecTests.cs @@ -0,0 +1,89 @@ +using Fido.Models; +using Fido.Services; + +namespace Fido.Tests.Locator; + +/// +/// The per-platform command construction in . Pure (no process is +/// started), so the launch plan for editors, the console and the file explorer can be asserted directly. The +/// CI matrix (Windows + Linux) exercises each platform branch; the macOS branch is left to manual verification. +/// +public class LaunchSpecTests +{ + private static string Folder => OperatingSystem.IsWindows() ? @"C:\work\repo" : "/work/repo"; + + [Test] + public async Task An_editor_takes_the_target_as_its_final_argument() + { + var spec = EditorLauncher.BuildLaunchSpec( + new Editor { Kind = EditorKind.VsCode, Arguments = "--new-window" }, "/opt/ed/code", Folder); + + await Assert.That(spec.FileName).IsEqualTo("/opt/ed/code"); + await Assert.That(spec.Arguments[0]).IsEqualTo("--new-window"); // extra args first + await Assert.That(spec.Arguments[^1]).IsEqualTo(Folder); // then the target + await Assert.That(spec.UseShellExecute).IsFalse(); + await Assert.That(string.IsNullOrEmpty(spec.WorkingDirectory)).IsTrue(); + } + + [Test] + public async Task The_console_opens_a_terminal_at_the_folder() + { + var editor = new Editor { Kind = EditorKind.Console }; + + if (OperatingSystem.IsWindows()) + { + // cmd / PowerShell: the folder is the working directory, not an argument; shell-execute gives a window. + var cmd = EditorLauncher.BuildLaunchSpec(editor, @"C:\Windows\System32\cmd.exe", Folder); + await Assert.That(cmd.WorkingDirectory).IsEqualTo(Folder); + await Assert.That(cmd.UseShellExecute).IsTrue(); + await Assert.That(cmd.Arguments.Contains(Folder)).IsFalse(); + + // Windows Terminal ignores the inherited directory, so it gets an explicit -d . + var wt = EditorLauncher.BuildLaunchSpec(editor, @"C:\Users\me\AppData\Local\Microsoft\WindowsApps\wt.exe", Folder); + await Assert.That(wt.Arguments.Contains("-d")).IsTrue(); + await Assert.That(wt.Arguments.Contains(Folder)).IsTrue(); + } + else if (OperatingSystem.IsLinux()) + { + var term = EditorLauncher.BuildLaunchSpec(editor, "/usr/bin/gnome-terminal", Folder); + await Assert.That(term.FileName).IsEqualTo("/usr/bin/gnome-terminal"); + await Assert.That(term.WorkingDirectory).IsEqualTo(Folder); // terminals inherit the working directory + await Assert.That(term.UseShellExecute).IsFalse(); + await Assert.That(term.Arguments.Contains(Folder)).IsFalse(); + } + } + + [Test] + public async Task The_console_forwards_the_configured_extra_arguments() + { + var editor = new Editor { Kind = EditorKind.Console, Arguments = "--flag" }; + + if (OperatingSystem.IsWindows()) + { + var cmd = EditorLauncher.BuildLaunchSpec(editor, @"C:\Windows\System32\cmd.exe", Folder); + await Assert.That(cmd.Arguments.Contains("--flag")).IsTrue(); + + // Extra args precede the wt -d the builder appends. + var wt = EditorLauncher.BuildLaunchSpec(editor, @"C:\…\WindowsApps\wt.exe", Folder).Arguments.ToList(); + await Assert.That(wt.IndexOf("--flag")).IsLessThan(wt.IndexOf("-d")); + } + else if (OperatingSystem.IsLinux()) + { + var term = EditorLauncher.BuildLaunchSpec(editor, "/usr/bin/gnome-terminal", Folder); + await Assert.That(term.Arguments.Contains("--flag")).IsTrue(); + } + } + + [Test] + public async Task The_file_explorer_passes_the_folder_to_the_file_manager() + { + var exe = OperatingSystem.IsWindows() ? @"C:\Windows\explorer.exe" : "/usr/bin/xdg-open"; + + var spec = EditorLauncher.BuildLaunchSpec(new Editor { Kind = EditorKind.FileExplorer }, exe, Folder); + + await Assert.That(spec.FileName).IsEqualTo(exe); + await Assert.That(spec.Arguments.Count).IsEqualTo(1); + await Assert.That(spec.Arguments[0]).IsEqualTo(Folder); + await Assert.That(spec.UseShellExecute).IsFalse(); + } +} diff --git a/tests/Fido.Tests/Models/AppConfigTests.cs b/tests/Fido.Tests/Models/AppConfigTests.cs index 6167e6e..c3ad551 100644 --- a/tests/Fido.Tests/Models/AppConfigTests.cs +++ b/tests/Fido.Tests/Models/AppConfigTests.cs @@ -18,12 +18,16 @@ public async Task The_built_in_editors_each_carry_a_slug() await Assert.That(defaults.First(e => e.Kind == EditorKind.VsCode).Slug).IsEqualTo("vsc"); await Assert.That(defaults.First(e => e.Kind == EditorKind.VisualStudio).Slug).IsEqualTo("vs"); await Assert.That(defaults.First(e => e.Kind == EditorKind.Zed).Slug).IsEqualTo("zed"); + await Assert.That(defaults.First(e => e.Kind == EditorKind.Console).Slug).IsEqualTo("term"); + await Assert.That(defaults.First(e => e.Kind == EditorKind.FileExplorer).Slug).IsEqualTo("files"); } [Test] [Arguments("zed", EditorKind.Zed)] [Arguments("VS", EditorKind.VisualStudio)] // case-insensitive [Arguments(" rider ", EditorKind.Rider)] // trimmed + [Arguments("term", EditorKind.Console)] // open the folder in a terminal + [Arguments("files", EditorKind.FileExplorer)] // open the folder in the file manager public async Task FindEditorBySlug_resolves_a_known_slug(string slug, EditorKind expected) { var match = WithDefaults().FindEditorBySlug(slug); diff --git a/tests/Fido.Tests/Services/ConfigServiceTests.cs b/tests/Fido.Tests/Services/ConfigServiceTests.cs index f4be790..3971312 100644 --- a/tests/Fido.Tests/Services/ConfigServiceTests.cs +++ b/tests/Fido.Tests/Services/ConfigServiceTests.cs @@ -61,7 +61,7 @@ public async Task A_current_version_editor_list_is_left_intact() } [Test] - public async Task WebStorm_is_appended_to_a_config_that_predates_it() + public async Task The_newer_built_in_targets_are_appended_to_a_config_that_predates_them() { using var world = new TestRepoWorld(); var svc = InTempDir(world); @@ -80,26 +80,30 @@ public async Task WebStorm_is_appended_to_a_config_that_predates_it() var loaded = svc.Load(); - // WebStorm is appended (not inserted), so existing positions and the default pointer are preserved. - await Assert.That(loaded.Editors.Count).IsEqualTo(5); - await Assert.That(loaded.Editors[^1].Kind).IsEqualTo(EditorKind.WebStorm); + // WebStorm, then Console and File Explorer, are appended (not inserted), so existing positions + // and the default pointer are preserved. + await Assert.That(loaded.Editors.Count).IsEqualTo(7); await Assert.That(loaded.Editors[0].Kind).IsEqualTo(EditorKind.Rider); + await Assert.That(loaded.Editors[4].Kind).IsEqualTo(EditorKind.WebStorm); + await Assert.That(loaded.Editors[5].Kind).IsEqualTo(EditorKind.Console); + await Assert.That(loaded.Editors[6].Kind).IsEqualTo(EditorKind.FileExplorer); await Assert.That(loaded.DefaultEditor!.Kind).IsEqualTo(EditorKind.VsCode); await Assert.That(loaded.ConfigVersion).IsEqualTo(AppConfig.CurrentConfigVersion); } [Test] - public async Task The_webstorm_migration_does_not_add_a_duplicate() + public async Task The_built_in_target_migration_does_not_add_duplicates() { using var world = new TestRepoWorld(); var svc = InTempDir(world); svc.Save(new AppConfig { - // Predates versioning, but the user already has a WebStorm editor → don't add a second. + // Predates versioning, but the user already has WebStorm and a Console → don't add a second of either. Editors = new() { new Editor { Name = "Rider", Kind = EditorKind.Rider }, new Editor { Name = "WebStorm", Kind = EditorKind.WebStorm }, + new Editor { Name = "Console", Kind = EditorKind.Console }, }, DefaultEditorIndex = 0, }); @@ -107,7 +111,10 @@ public async Task The_webstorm_migration_does_not_add_a_duplicate() var loaded = svc.Load(); await Assert.That(loaded.Editors.Count(e => e.Kind == EditorKind.WebStorm)).IsEqualTo(1); - await Assert.That(loaded.Editors.Count).IsEqualTo(2); + await Assert.That(loaded.Editors.Count(e => e.Kind == EditorKind.Console)).IsEqualTo(1); + // Only the still-missing File Explorer is appended → 3 originals + 1. + await Assert.That(loaded.Editors.Count).IsEqualTo(4); + await Assert.That(loaded.Editors[^1].Kind).IsEqualTo(EditorKind.FileExplorer); } [Test] From 7c89f46644f2dfe39ac1ff68811da7bd015bc073 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:00:58 +0000 Subject: [PATCH 2/2] Fix Windows console detection: honour typed names, prefer wt over cmd Two bugs meant the Console target always opened cmd.exe on Windows: 1. A typed command name (e.g. "wt", "wt.exe", "pwsh") was ignored. Locate only honoured a path that existed verbatim on disk, so a bare command name failed File.Exists and fell back to auto-detection. Locate now resolves a bare command name on PATH (and the Windows Store-app alias folder, where wt.exe lives) before falling back. 2. Auto-detection preferred PATH-directory order over name order, so cmd.exe in System32 (early on PATH) beat wt.exe / powershell.exe in later directories. FindOnPath now tries names in the caller's preference order across all directories, so Windows Terminal / PowerShell win over cmd as intended. Docs updated to note a command name (not just a full path) is accepted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018AFP3DvYu9aiaNvtbtnBjy --- Docs/Features.md | 10 ++-- src/Services/EditorLauncher.cs | 53 ++++++++++++++++--- .../Fido.Tests/Locator/EditorLauncherTests.cs | 51 ++++++++++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/Docs/Features.md b/Docs/Features.md index 1994b1f..d9b087d 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -161,9 +161,10 @@ macOS, and Linux**: OS default: **Windows** — Windows Terminal (`wt`), else PowerShell (`pwsh`/`powershell`), else `cmd`; **macOS** — the **Terminal** app (via `open -a`); **Linux** — the first of `x-terminal-emulator`, `gnome-terminal`, `konsole`, `xfce4-terminal`, `kitty`, `alacritty`, `tilix`, `xterm` on `PATH`. - **The terminal is configurable:** set the Console row's **path** to a specific terminal program (and add - arguments if needed). Most terminals open in the folder because Fido sets it as their working directory; - Windows Terminal is pointed at it explicitly with `-d`. + **The terminal is configurable:** set the Console row's **path** to a specific terminal program — a full + path *or* just a command name like `wt`, `pwsh`, or `gnome-terminal` (resolved on `PATH`, including Windows + Terminal's Store alias) — and add arguments if needed. Most terminals open in the folder because Fido sets + it as their working directory; Windows Terminal is pointed at it explicitly with `-d`. - **File Explorer** *(folder-only, slug `files`)* — reveals the folder in the OS file manager: **Windows** `explorer.exe`, **macOS** Finder (via `open`), **Linux** `xdg-open` (honouring your default file manager), else `nautilus` / `dolphin` / `thunar` / `nemo` / `pcmanfm`. The file manager is configurable via the @@ -251,7 +252,8 @@ silently falling back to the default. command-line token that selects it, e.g. `rider`), a **kind** (Rider, WebStorm, VS Code, Visual Studio, Zed, **Console**, **File Explorer**, or Custom), and an optional path (blank = auto-detect for known kinds; required for Custom). For **Console** the path is the **terminal program** and for **File Explorer** the - **file manager** (blank = the OS default), so you can point Fido at the terminal you prefer. Tick the + **file manager** (blank = the OS default; a full path or a bare command name like `wt` / `pwsh` both work), + so you can point Fido at the terminal you prefer. Tick the **●** radio to set the default (the Open button / Enter); the rest are reached by **Ctrl+1 … Ctrl+9** or by their slug on the command line. **Add** appends a new row; **✕** removes one. - **Worktree root** — leave blank for the sibling `.worktrees` convention. diff --git a/src/Services/EditorLauncher.cs b/src/Services/EditorLauncher.cs index bf9286a..8bfa494 100644 --- a/src/Services/EditorLauncher.cs +++ b/src/Services/EditorLauncher.cs @@ -15,9 +15,16 @@ public sealed class EditorLauncher : IEditorLauncher /// Returns the editor's executable/app-bundle path, or null if none is found. public string? Locate(Editor editor) { - // An explicit, existing path always wins — for any kind, including Custom. - if (!string.IsNullOrWhiteSpace(editor.Path) && PathExists(editor.Path)) - return editor.Path; + if (!string.IsNullOrWhiteSpace(editor.Path)) + { + // An explicit, existing path always wins — for any kind, including Custom. + if (PathExists(editor.Path)) return editor.Path; + + // A bare command name the user typed into the path field — e.g. "wt", "pwsh", + // "gnome-terminal" — rather than a full path: resolve it on PATH (and the Windows + // Store-app aliases) so a name, not just a full path, can be configured for a terminal. + if (ResolveCommandName(editor.Path) is { } resolved) return resolved; + } return editor.Kind switch { @@ -417,16 +424,23 @@ private static IEnumerable ProgramFilesDirs() if (!string.IsNullOrEmpty(dir)) yield return dir; } - private static string? FindOnPath(string[] names) + /// + /// Finds the first of present in any PATH directory. Names are tried in the + /// caller's preference order across all directories before the next name — so a preferred name in a + /// later directory still wins over a less-preferred name in an earlier one (e.g. Windows Terminal in + /// %LOCALAPPDATA%\Microsoft\WindowsApps beats cmd.exe in System32, which sits earlier on PATH). + /// + internal static string? FindOnPath(string[] names) { var pathVar = Environment.GetEnvironmentVariable("PATH"); if (string.IsNullOrEmpty(pathVar)) return null; - foreach (var dir in pathVar.Split(Path.PathSeparator)) + var dirs = pathVar.Split(Path.PathSeparator); + foreach (var name in names) { - if (string.IsNullOrWhiteSpace(dir)) continue; - foreach (var name in names) + foreach (var dir in dirs) { + if (string.IsNullOrWhiteSpace(dir)) continue; try { var candidate = Path.Combine(dir, name); @@ -438,6 +452,31 @@ private static IEnumerable ProgramFilesDirs() return null; } + /// + /// Resolves a bare command name (no directory separator) — e.g. "wt", "pwsh", "gnome-terminal" — to a full + /// executable path via PATH, plus the Windows Store-app alias folder. Returns null when the value + /// looks like a (missing) full path, or can't be found, so the caller falls back to kind-based auto-detection. + /// + private static string? ResolveCommandName(string name) + { + name = name.Trim(); + if (name.Length == 0 || name.Contains('/') || name.Contains('\\')) return null; // a path, not a name + + if (!OperatingSystem.IsWindows()) + return FindOnPath([name]); + + // On Windows, a bare name usually omits the extension; try the common executable extensions. + string[] candidates = Path.HasExtension(name) + ? [name] + : [name + ".exe", name + ".cmd", name + ".bat", name]; + if (FindOnPath(candidates) is { } onPath) return onPath; + + // Windows Terminal and other Store apps live here as execution aliases (which File.Exists sees). + var windowsApps = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "WindowsApps"); + return FirstExisting([.. candidates.Select(c => Path.Combine(windowsApps, c))]); + } + private static string? NewestFile(string baseDir, string fileName) { if (!Directory.Exists(baseDir)) return null; diff --git a/tests/Fido.Tests/Locator/EditorLauncherTests.cs b/tests/Fido.Tests/Locator/EditorLauncherTests.cs index f92c639..3efa351 100644 --- a/tests/Fido.Tests/Locator/EditorLauncherTests.cs +++ b/tests/Fido.Tests/Locator/EditorLauncherTests.cs @@ -145,4 +145,55 @@ public async Task A_file_explorer_auto_detects_on_windows() await Assert.That(found).IsNotNull(); } + + [Test] + public async Task A_console_configured_by_a_bare_command_name_is_resolved_on_path() + { + using var world = new TestRepoWorld(); + var binDir = Path.Combine(world.Root, "bin"); + Directory.CreateDirectory(binDir); + var exe = Path.Combine(binDir, OperatingSystem.IsWindows() ? "myterm.exe" : "myterm"); + File.WriteAllText(exe, ""); + + var originalPath = Environment.GetEnvironmentVariable("PATH"); + Environment.SetEnvironmentVariable("PATH", binDir + Path.PathSeparator + originalPath); + try + { + // The user typed the bare command name "myterm" (no directory, no extension) into the Console row. + var found = new EditorLauncher().Locate(new Editor { Kind = EditorKind.Console, Path = "myterm" }); + await Assert.That(found).IsEqualTo(exe); + } + finally + { + Environment.SetEnvironmentVariable("PATH", originalPath); + } + } + + [Test] + public async Task FindOnPath_prefers_an_earlier_name_over_an_earlier_directory() + { + using var world = new TestRepoWorld(); + var earlyDir = Path.Combine(world.Root, "early"); + var lateDir = Path.Combine(world.Root, "late"); + Directory.CreateDirectory(earlyDir); + Directory.CreateDirectory(lateDir); + + // The less-preferred name sits in the earlier PATH dir; the preferred name in the later dir. + File.WriteAllText(Path.Combine(earlyDir, "bbb"), ""); + var preferred = Path.Combine(lateDir, "aaa"); + File.WriteAllText(preferred, ""); + + var originalPath = Environment.GetEnvironmentVariable("PATH"); + Environment.SetEnvironmentVariable("PATH", earlyDir + Path.PathSeparator + lateDir + Path.PathSeparator + originalPath); + try + { + // "aaa" is preferred, so it must win even though "bbb" lives in an earlier directory. + var found = EditorLauncher.FindOnPath(["aaa", "bbb"]); + await Assert.That(found).IsEqualTo(preferred); + } + finally + { + Environment.SetEnvironmentVariable("PATH", originalPath); + } + } }