From 02078249fbc9f0199a4831390134519d45a0819b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:27:21 +0000 Subject: [PATCH 1/2] Show a numbered menu on Linux and macOS instead of crashing [patch] Running with no arguments opened a DustInTheWind ScrollMenu, which reads Console.CursorVisible. That getter throws PlatformNotSupportedException on every platform but Windows, so the documented default entry point crashed with exit code 134 on Linux and macOS. The scrolling menu is now kept for an interactive Windows console, and everywhere else the menu is a numbered prompt read a line at a time. Both have an Exit item, and the numbered prompt also exits at the end of input. Fixes ktsu-dev/FileDeduplicator#161 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EMKbHPuiquQwvUr7NBWPvL --- FileDeduplicator.Test/MenuTests.cs | 69 ++++++++++++++++++++++ FileDeduplicator/Verbs/Menu.cs | 94 ++++++++++++++++++++++++++---- README.md | 3 + 3 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 FileDeduplicator.Test/MenuTests.cs diff --git a/FileDeduplicator.Test/MenuTests.cs b/FileDeduplicator.Test/MenuTests.cs new file mode 100644 index 0000000..e175473 --- /dev/null +++ b/FileDeduplicator.Test/MenuTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.FileDeduplicator.Test; + +using ktsu.FileDeduplicator.Verbs; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests the interactive menu that runs when no verb is given. +/// +/// +/// Input is always redirected here, so these drive the numbered prompt. That prompt is also the +/// menu on Linux and macOS, where the scrolling menu crashed reading Console.CursorVisible +/// (ktsu-dev/FileDeduplicator#161). +/// +[TestClass] +[DoNotParallelize] +public sealed class MenuTests +{ + [TestMethod] + public void MenuListsTheVerbsAndExitsOnZero() + { + string output = ConsoleCapture.Run(new Menu(), "0\n", out int exitCode); + + Assert.AreEqual(0, exitCode, output); + Assert.Contains("Scan - ", output); + Assert.Contains("Deduplicate - ", output); + Assert.Contains("0. Exit", output); + Assert.DoesNotContain("Menu", output, "The menu offered itself."); + } + + [TestMethod] + public void MenuExitsAtTheEndOfInput() + { + _ = ConsoleCapture.Run(new Menu(), string.Empty, out int exitCode); + + Assert.AreEqual(0, exitCode); + } + + [TestMethod] + public void MenuRunsTheChosenVerbAndOffersTheMenuAgain() + { + // Arrange + using TempTree tree = new(); + _ = tree.Write("a.txt", "alpha"); + _ = tree.Write("aa.txt", "alpha"); + Type[] menuVerbs = [.. Program.Verbs.Where(t => t != typeof(Menu))]; + int scan = Array.IndexOf(menuVerbs, typeof(Scan)) + 1; + Assert.IsGreaterThan(0, scan, "Scan is not on the menu."); + + // Act + string output = ConsoleCapture.Run(new Menu(), $"{scan}\n{tree.Root.WeakString}\n0\n", out int exitCode); + + // Assert + Assert.AreEqual(0, exitCode, output); + Assert.Contains("Found 1 group(s) of duplicate files", output); + Assert.AreEqual(2, output.Split("0. Exit").Length - 1, "The menu was not offered again after the verb ran."); + } + + [TestMethod] + public void MenuRejectsAChoiceThatIsNotOnIt() + { + string output = ConsoleCapture.Run(new Menu(), "42\n0\n", out int exitCode); + + Assert.AreEqual(0, exitCode, output); + Assert.Contains("Not an option: 42", output); + } +} diff --git a/FileDeduplicator/Verbs/Menu.cs b/FileDeduplicator/Verbs/Menu.cs index 4fdde2a..d75fa2b 100644 --- a/FileDeduplicator/Verbs/Menu.cs +++ b/FileDeduplicator/Verbs/Menu.cs @@ -18,6 +18,23 @@ internal sealed class Menu : BaseVerb internal override bool ScansADirectory => false; internal override void Run(Menu options) + { + // ScrollMenu reads Console.CursorVisible, whose getter throws PlatformNotSupportedException on + // every platform but Windows, and it needs a real keyboard to drive. Elsewhere, and whenever + // input is piped in, the menu is a numbered prompt read a line at a time. + if (OperatingSystem.IsWindows() && !Console.IsInputRedirected) + { + RunScrollMenu(); + } + else + { + RunNumberedMenu(); + } + } + + private Type[] MenuVerbs => [.. Program.Verbs.Where(verb => verb != GetType())]; + + private void RunScrollMenu() { ScrollMenu scrollMenu = new() { @@ -31,32 +48,87 @@ internal override void Run(Menu options) Control = scrollMenu, }; - LabelMenuItem[] menuItems = [.. Program.Verbs - .Where(verb => verb != GetType()) - .Select(CreateMenuItem)]; + ExitCommand exit = new(menuRepeater); + LabelMenuItem[] menuItems = [.. MenuVerbs.Select(CreateMenuItem)]; scrollMenu.AddItems(menuItems); + scrollMenu.AddItems([new LabelMenuItem() { Text = "Exit", Command = exit, IsEnabled = true }]); - while (true) + while (!exit.Requested) { menuRepeater.Display(); } } - private static LabelMenuItem CreateMenuItem(Type verbType) + private void RunNumberedMenu() + { + BaseVerb[] verbs = [.. MenuVerbs.Select(CreateVerb)]; + string[] texts = [.. MenuVerbs.Select(DescribeVerb)]; + + while (true) + { + Console.WriteLine(); + for (int i = 0; i < verbs.Length; i++) + { + Console.WriteLine($" {i + 1}. {texts[i]}"); + } + + Console.WriteLine(" 0. Exit"); + Console.Write("Choose an option: "); + + // End of input is an exit too, so a piped script cannot leave the menu waiting forever. + string? input = Console.ReadLine()?.Trim(); + if (input is null or "0") + { + return; + } + + if (int.TryParse(input, out int choice) && choice >= 1 && choice <= verbs.Length) + { + verbs[choice - 1].Execute(); + } + else + { + Console.WriteLine($"Not an option: {input}"); + } + } + } + + private static BaseVerb CreateVerb(Type verbType) { BaseVerb? verb = Activator.CreateInstance(verbType) as BaseVerb; Debug.Assert(verb != null); + return verb; + } + private static string DescribeVerb(Type verbType) + { string name = verbType.Name; string? helpText = verbType.GetCustomAttribute()?.HelpText; - string text = string.IsNullOrEmpty(helpText) ? name : $"{name} - {helpText}"; + return string.IsNullOrEmpty(helpText) ? name : $"{name} - {helpText}"; + } - return new LabelMenuItem() + private static LabelMenuItem CreateMenuItem(Type verbType) => new() + { + Text = DescribeVerb(verbType), + Command = CreateVerb(verbType), + IsEnabled = true, + }; + + /// + /// The menu item that leaves the menu, which otherwise could only be left with Ctrl+C. + /// + /// The repeater to stop, which otherwise redisplays the menu indefinitely. + private sealed class ExitCommand(ControlRepeater repeater) : ICommand + { + internal bool Requested { get; private set; } + + public bool IsActive => true; + + public void Execute() { - Text = text, - Command = verb, - IsEnabled = true, - }; + Requested = true; + repeater.RequestClose(); + } } } diff --git a/README.md b/README.md index 5168972..dde47bc 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Run without arguments to launch the interactive menu: FileDeduplicator ``` +On Windows the menu is navigated with the arrow keys. On Linux and macOS, and whenever input is piped +in, it is a numbered list: type the number of a command, or `0` to exit. + ### Commands ```sh From 0484ad77a6d57fdf5b3ab85fdbf93a8bb69fae60 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 00:33:25 +0000 Subject: [PATCH 2/2] Build the scroll menu apart from its display loop so it is tested Only the loop that displays the menu needs an interactive Windows console. Building the menu and its Exit command does not, so a test now covers both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EMKbHPuiquQwvUr7NBWPvL --- FileDeduplicator.Test/MenuTests.cs | 25 +++++++++++++++++++++++++ FileDeduplicator/Verbs/Menu.cs | 23 +++++++++++++++++------ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/FileDeduplicator.Test/MenuTests.cs b/FileDeduplicator.Test/MenuTests.cs index e175473..0de466c 100644 --- a/FileDeduplicator.Test/MenuTests.cs +++ b/FileDeduplicator.Test/MenuTests.cs @@ -2,6 +2,9 @@ namespace ktsu.FileDeduplicator.Test; +using DustInTheWind.ConsoleTools.Controls; +using DustInTheWind.ConsoleTools.Controls.Menus; + using ktsu.FileDeduplicator.Verbs; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -66,4 +69,26 @@ public void MenuRejectsAChoiceThatIsNotOnIt() Assert.AreEqual(0, exitCode, output); Assert.Contains("Not an option: 42", output); } + + /// + /// The scrolling menu used in an interactive Windows console has an Exit item whose command ends + /// the display loop, so it can be left without Ctrl+C. + /// + [TestMethod] + public void ScrollMenuHasAnExitItemThatClosesIt() + { + // Arrange + Menu menu = new(); + ControlRepeater repeater = menu.BuildScrollMenu(out Menu.ExitCommand exit); + + // Act + bool requestedBefore = exit.Requested; + exit.Execute(); + + // Assert + Assert.IsInstanceOfType(repeater.Control); + Assert.IsTrue(exit.IsActive); + Assert.IsFalse(requestedBefore); + Assert.IsTrue(exit.Requested); + } } diff --git a/FileDeduplicator/Verbs/Menu.cs b/FileDeduplicator/Verbs/Menu.cs index d75fa2b..246af7d 100644 --- a/FileDeduplicator/Verbs/Menu.cs +++ b/FileDeduplicator/Verbs/Menu.cs @@ -35,6 +35,20 @@ internal override void Run(Menu options) private Type[] MenuVerbs => [.. Program.Verbs.Where(verb => verb != GetType())]; private void RunScrollMenu() + { + ControlRepeater menuRepeater = BuildScrollMenu(out ExitCommand exit); + while (!exit.Requested) + { + menuRepeater.Display(); + } + } + + /// + /// Builds the scrolling menu: one item per verb, then Exit. + /// + /// The Exit item's command, which says when the menu has been left. + /// The repeater that displays the menu until Exit is chosen. + internal ControlRepeater BuildScrollMenu(out ExitCommand exit) { ScrollMenu scrollMenu = new() { @@ -48,16 +62,13 @@ private void RunScrollMenu() Control = scrollMenu, }; - ExitCommand exit = new(menuRepeater); + exit = new(menuRepeater); LabelMenuItem[] menuItems = [.. MenuVerbs.Select(CreateMenuItem)]; scrollMenu.AddItems(menuItems); scrollMenu.AddItems([new LabelMenuItem() { Text = "Exit", Command = exit, IsEnabled = true }]); - while (!exit.Requested) - { - menuRepeater.Display(); - } + return menuRepeater; } private void RunNumberedMenu() @@ -119,7 +130,7 @@ private static string DescribeVerb(Type verbType) /// The menu item that leaves the menu, which otherwise could only be left with Ctrl+C. /// /// The repeater to stop, which otherwise redisplays the menu indefinitely. - private sealed class ExitCommand(ControlRepeater repeater) : ICommand + internal sealed class ExitCommand(ControlRepeater repeater) : ICommand { internal bool Requested { get; private set; }