From 69f1230b3faf272fad7f71b145b780255ec5f6bc Mon Sep 17 00:00:00 2001 From: Dmytro Soyenko Date: Sat, 3 Oct 2026 22:38:33 +0300 Subject: [PATCH] Focus panels on click, open documents without waiting, add Shift+Enter - Only a mouse click moves the focus between panels; hovering no longer does, and the wheel scrolls whichever panel it is over. - A click on the sort letter in a panel's top-left corner opens that panel's drive menu. - Enter on a document or a windowed program hands it to the system and returns at once instead of locking the console until Word or Excel is closed. Console programs, batch files and documents opened by a console program still run on the user screen; the choice reads the subsystem from the PE header (the associated program's, for a document). - Shift+Enter opens a folder in the system file manager and a file with its associated program, without waiting. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 ++ README.md | 4 +- docs/index.html | 3 +- src/OpenStus/Core/Application.cs | 34 ++- src/OpenStus/Core/IAppContext.cs | 15 ++ src/OpenStus/Panels/FilePanel.cs | 83 +++++- src/OpenStus/Shell/CommandExecutor.cs | 242 +++++++++++++++-- src/OpenStus/Ui/HelpScreen.cs | 4 +- tests/OpenStus.Tests/CommandLineTests.cs | 8 + tests/OpenStus.Tests/LaunchTests.cs | 318 +++++++++++++++++++++++ 10 files changed, 691 insertions(+), 36 deletions(-) create mode 100644 tests/OpenStus.Tests/LaunchTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e69ce7b..d8dd0e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ All notable changes to Open Stus are recorded here. The format follows ### Added +- **Shift+Enter opens outside Open Stus.** On a folder it opens the system file manager there + (Explorer on Windows, the desktop's own elsewhere) - on `..` the folder the panel shows; on a file + it starts the file with its associated program and comes straight back. Inside an archive a file + is taken out first, and a folder opens the archive file itself. + +- **A click on the sort letter changes the drive.** The letter in the top-left corner of a panel's + column titles (`n` for by name) opens that panel's drive menu, as `Alt+F1` / `Alt+F2` do. + - **Archives open in the panel.** Enter (or `Ctrl+PgDn`) on a zip - and its relatives jar, war, nupkg, vsix, whl, apk - a tar, a `.tar.gz` / `.tgz` or a lone `.gz` walks into it as if it were a folder: the title and the prompt show `...\tools.zip\bin`, `..` climbs back out onto the archive, @@ -177,6 +185,14 @@ the four reported issues at the top of this list. ### Changed +- **Enter no longer waits for Word or Excel.** A document, or a program with a window of its own, + is handed to the system and the panels come straight back, instead of the console sitting + locked until the program is closed. Console programs, batch files and documents a console + program opens (a `.py` run by `py.exe`) still run on the user screen as before. On Windows the + choice comes from the subsystem in the program's header; elsewhere a file without the execute + bit goes to `xdg-open` / `open` rather than failing in the shell. +- **Only a click moves the focus between panels.** The pointer passing over the other panel no + longer makes it the active one; the wheel scrolls whichever panel it is over, focus or not. - **The drive menu lines the panels up.** Picking, with `Alt+F1` / `Alt+F2`, the drive the other panel is on opens the other panel's folder rather than the drive's root. - **The editor's caret is the console's own cursor.** It is no longer an inverted cell drawn into diff --git a/README.md b/README.md index 7d04125..cea785d 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,8 @@ generated from `HelpScreen.Bindings`, so they cannot drift apart. | `Left / Right` | Move one column, or edit the command line | | `PgUp / PgDn` | Scroll one page | | `Home / End` | First / last item | -| `Enter` | Enter a folder or an archive, or run the file under the cursor | +| `Enter` | Enter a folder or an archive, or run the file; a document or windowed program opens without waiting | +| `Shift+Enter` | Open the folder in the system file manager (on .. the folder shown), or the file with its program | | `Ctrl+PgDn` | Enter the folder or archive under the cursor | | `Ctrl+PgUp` | Go to the parent folder | | `Ctrl+\` | Go to the root of the current drive | @@ -291,6 +292,7 @@ generated from `HelpScreen.Bindings`, so they cannot drift apart. | `Ctrl+H` | Show or hide hidden and system files | | `Ctrl+B` | Show or hide the function key bar | | `Alt+F1 / Alt+F2` | Change the drive of the left / right panel; the other panel's folder when it is on that drive | +| `Click the sort letter` | Change that panel's drive; the sort letter is the top-left corner of the column titles | | `Alt+` | Quick search by name | | `Ctrl+T` | Open a new tab on the current folder | | `Ctrl+W` | Close the current tab | diff --git a/docs/index.html b/docs/index.html index ba5b1d2..764be22 100644 --- a/docs/index.html +++ b/docs/index.html @@ -126,7 +126,8 @@

Get it running

Ctrl+T / Ctrl+WOpen / close a tab on the current folder Ctrl+OHide the panels and see the user screen Ctrl+RReverse search through the command history - Alt+F1 / Alt+F2Change the left / right drive + Shift+EnterOpen the folder in the system file manager, or the file with its program + Alt+F1 / Alt+F2Change the left / right drive (or click the sort letter) Alt+F7Find files by mask and content F1Every binding, generated from the same table as the README diff --git a/src/OpenStus/Core/Application.cs b/src/OpenStus/Core/Application.cs index 4f60fc9..2363f05 100644 --- a/src/OpenStus/Core/Application.cs +++ b/src/OpenStus/Core/Application.cs @@ -949,11 +949,20 @@ private void HandleGlobalMouse(MouseEvent mouse) return; } - if (!target.IsActive) + // Only a click hands the focus over. The pointer drifting across the other panel - or the + // wheel turning above it, which scrolls it - leaves the focus where it is. + if (!target.IsActive && mouse.IsPress) { SetActivePanel(target); } + // The sort letter in the top-left corner doubles as the panel's drive button. + if (mouse.IsPress && mouse.Button == MouseButton.Left && target.IsDriveButtonAt(mouse.X, mouse.Y)) + { + ShowDriveMenu(left: target == _left); + return; + } + target.HandleMouse(mouse, this); } @@ -1066,6 +1075,29 @@ public void RunShellCommand(string command) _dirty = true; } + /// + public void RunFile(string path) + { + if (CommandExecutor.RunsInConsole(path)) + { + RunShellCommand("\"" + path + "\""); + return; + } + + OpenExternally(path); + } + + /// + public void OpenExternally(string path) + { + if (!CommandExecutor.Open(path, ActiveFilePanel.WorkingDirectory, Terminal, out string? error)) + { + _ui.Error("Open", Shorten(path, 60) + "\n" + (error ?? "The system could not open it.")); + } + + _dirty = true; + } + /// public void InsertIntoCommandLine(string text) { diff --git a/src/OpenStus/Core/IAppContext.cs b/src/OpenStus/Core/IAppContext.cs index ad35f21..b2cf5e8 100644 --- a/src/OpenStus/Core/IAppContext.cs +++ b/src/OpenStus/Core/IAppContext.cs @@ -59,6 +59,21 @@ public interface IAppContext /// The command line, exactly as typed. void RunShellCommand(string command); + /// + /// Runs a file the way Enter does: a console program or script runs on the user screen like a + /// typed command, while a document or a windowed program is handed to the operating system + /// and left running, so the panels come straight back instead of waiting for it to close. + /// + /// The file to run. + void RunFile(string path); + + /// + /// Hands a file or folder to the operating system without waiting (Shift+Enter): a folder + /// opens in the system file manager, a file with the program the system associates with it. + /// + /// The file or folder to open. + void OpenExternally(string path); + /// Inserts text at the command line caret (Ctrl+J, Ctrl+F, Ctrl+[ and friends). /// The text to insert. void InsertIntoCommandLine(string text); diff --git a/src/OpenStus/Panels/FilePanel.cs b/src/OpenStus/Panels/FilePanel.cs index 7430232..06a962b 100644 --- a/src/OpenStus/Panels/FilePanel.cs +++ b/src/OpenStus/Panels/FilePanel.cs @@ -1303,6 +1303,9 @@ public bool HandleKey(KeyEvent key, IAppContext ctx) case ConsoleKey.Enter when none: Activate(app); return true; + case ConsoleKey.Enter when shift: + OpenExternally(app); + return true; case ConsoleKey.Enter when ctrl: InsertCurrentName(app); return true; @@ -1394,11 +1397,12 @@ public bool HandleKey(KeyEvent key, IAppContext ctx) /// /// - /// A click in a panel that does not have the focus is not consumed: the method returns - /// so the shell can make this panel active and, if it wants to, replay - /// the event. Everything else is handled here - the wheel scrolls the window without moving the - /// cursor, the left button moves the cursor (and activates the entry when it was already under - /// the cursor, or on a double click), and the right button toggles the tag and moves the cursor. + /// The wheel scrolls the window without moving the cursor, whether or not the panel has the + /// focus. A click in a panel that does not have the focus is not consumed: the method + /// returns so the shell can make this panel active and replay the + /// event. Everything else is handled here - the left button moves the cursor (and activates + /// the entry when it was already under the cursor, or on a double click), and the right button + /// toggles the tag and moves the cursor. /// public bool HandleMouse(MouseEvent m, IAppContext ctx) { @@ -1407,17 +1411,17 @@ public bool HandleMouse(MouseEvent m, IAppContext ctx) return false; } - if (!IsActive) - { - return false; - } - if (m.Kind == MouseKind.Wheel) { ScrollBy(-m.Wheel * WheelRows); return true; } + if (!IsActive) + { + return false; + } + if (m.Kind is not (MouseKind.Down or MouseKind.DoubleClick)) { return false; @@ -1502,6 +1506,23 @@ public int IndexAt(int screenX, int screenY) return index >= 0 && index < _entries.Count ? index : -1; } + /// + /// Whether a screen cell is the sort letter in the top-left corner of the column titles, + /// which a click turns into the drive menu. + /// + /// Screen column. + /// Screen row. + /// on that one cell of a drawn panel. + public bool IsDriveButtonAt(int screenX, int screenY) + { + Rect b = Bounds; + return IsVisible && + b.Width >= MinWidth && + b.Height >= RequiredHeight && + screenX == b.X + 1 && + screenY == b.Y + HeaderRows - 1; + } + /// Scrolls the window without moving the cursor, the way the wheel does. /// How many rows to scroll; positive scrolls towards the end of the listing. public void ScrollBy(int rows) @@ -1701,7 +1722,7 @@ private void Activate(IAppContext? app) return; } - app?.RunShellCommand("\"" + extracted + "\""); + app?.RunFile(extracted); return; } @@ -1711,7 +1732,45 @@ private void Activate(IAppContext? app) return; } - app?.RunShellCommand("\"" + current.FullPath + "\""); + app?.RunFile(current.FullPath); + } + + /// + /// Shift+Enter: hands the entry under the cursor to the operating system without waiting - a + /// folder to the system file manager, a file (an archive included) to its associated program. + /// + /// + /// .. opens the folder the panel shows. Inside an archive a file is taken out to the + /// scratch area first, and a folder - which has no real path - opens the archive file itself. + /// + private void OpenExternally(IAppContext? app) + { + FileEntry? current = Current; + if (app is null || current is null) + { + return; + } + + if (_archiveFile is not null) + { + if (current.IsDirectory) + { + app.OpenExternally(_archiveFile); + return; + } + + string? extracted = ExtractToTemp(current, out string? error); + if (extracted is null) + { + app.Ui.Error("Open", error ?? "The file could not be extracted."); + return; + } + + app.OpenExternally(extracted); + return; + } + + app.OpenExternally(current.IsParent ? CurrentPath : current.FullPath); } /// diff --git a/src/OpenStus/Shell/CommandExecutor.cs b/src/OpenStus/Shell/CommandExecutor.cs index ca2b439..983afc4 100644 --- a/src/OpenStus/Shell/CommandExecutor.cs +++ b/src/OpenStus/Shell/CommandExecutor.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Runtime.InteropServices; using OpenStus.Files; using OpenStus.Rendering; @@ -300,47 +301,233 @@ private static bool ContainsShellOperator(string argument) } /// - /// Opens a file or folder with the association the operating system has for it. + /// Whether running a file needs the console: a console program, a batch file, or a document + /// whose associated program is a console program (a .py opened by py.exe). /// - /// The file or folder to open. - /// The directory the launched process starts in. + /// The file Enter was pressed on. + /// + /// when the file should run on the user screen like a typed command, + /// which waits for it; when it is a windowed program or a document + /// that opens in one, which starts without waiting. + /// /// - /// Never throws: an unassociated file type, a missing helper or a refused launch simply does - /// nothing, because there is no useful way for a panel to recover from it. + /// On Windows the answer comes from the subsystem field of the program's header - the program + /// itself for an .exe, the associated program for a document. An unreadable program is + /// taken to be a console one, which is the old, safe behaviour; a document with no association + /// goes to the system, which asks what to open it with. Elsewhere a file with an execute bit + /// runs in the terminal and anything else goes to the desktop's opener. /// - public static void Launch(string path, string workingDirectory) + public static bool RunsInConsole(string path) { if (string.IsNullOrWhiteSpace(path)) { - return; + return false; + } + + if (!OperatingSystem.IsWindows()) + { + try + { + const UnixFileMode Execute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + return (File.GetUnixFileMode(path) & Execute) != 0; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException) + { + return true; + } } + string extension = Path.GetExtension(path); + if (extension.Equals(".bat", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".com", StringComparison.OrdinalIgnoreCase)) + { + return IsConsoleProgram(path) ?? true; + } + + string? program = AssociatedProgram(extension); + return program is not null && IsConsoleProgram(program) == true; + } + + /// + /// Reads the subsystem a Windows program was built for from its PE header. + /// + /// The program file. + /// + /// for a console program, for a windowed one, + /// and when the file cannot be read or is not a PE image. + /// + public static bool? IsConsoleProgram(string path) + { + const ushort WindowsGui = 2; + const ushort WindowsConsole = 3; + try { - ProcessStartInfo info; - if (OperatingSystem.IsWindows()) + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new BinaryReader(stream); + + // "MZ", then the offset of the "PE\0\0" signature at 0x3C. + if (stream.Length < 0x40 || reader.ReadUInt16() != 0x5A4D) { - info = new ProcessStartInfo(path) { UseShellExecute = true }; + return null; } - else + + stream.Position = 0x3C; + long signature = reader.ReadUInt32(); + + // The subsystem sits 68 bytes into the optional header, which follows the four-byte + // signature and the 20-byte file header - at the same offset for 32 and 64 bit images. + long subsystemAt = signature + 4 + 20 + 68; + if (subsystemAt + 2 > stream.Length) { - string opener = OperatingSystem.IsMacOS() ? "open" : "xdg-open"; - info = new ProcessStartInfo(opener) { UseShellExecute = false }; - info.ArgumentList.Add(path); + return null; } - if (!string.IsNullOrWhiteSpace(workingDirectory)) + stream.Position = signature; + if (reader.ReadUInt32() != 0x00004550) + { + return null; + } + + stream.Position = subsystemAt; + return reader.ReadUInt16() switch + { + WindowsConsole => true, + WindowsGui => false, + _ => null, + }; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + // Among others, the zero-byte app execution aliases in WindowsApps cannot be opened. + return null; + } + } + + /// + /// Hands a file or folder to the operating system and returns at once, without waiting for + /// whatever it starts: a folder opens in the system file manager, a file with its associated + /// program - Word for a .docx, its own window for a program. + /// + /// The file or folder to open. + /// The directory the launched process starts in. + /// The terminal; nothing is started while it is headless. + /// Why the launch failed, or . + /// + /// only when the launch failed for a reason worth telling the user; + /// cancelling the system's own "open with" dialog is not one. + /// + public static bool Open(string path, string? workingDirectory, Terminal terminal, out string? error) + { + ArgumentNullException.ThrowIfNull(terminal); + + error = null; + + // Headless is screenshot mode and the tests: nothing on the desktop should start. + if (string.IsNullOrWhiteSpace(path) || terminal.IsHeadless) + { + return true; + } + + try + { + // The started program outlives the handle; disposing it does not end the program. + using Process? process = Process.Start(BuildOpenStartInfo(path, workingDirectory)); + return true; + } + catch (System.ComponentModel.Win32Exception e) when (e.NativeErrorCode == ErrorCancelled) + { + return true; + } + catch (Exception e) when (e is System.ComponentModel.Win32Exception or InvalidOperationException or PlatformNotSupportedException or IOException or UnauthorizedAccessException) + { + error = e.Message; + return false; + } + } + + /// + /// Builds the start info uses. + /// + /// The file or folder to open. + /// The directory the launched process starts in. + /// + /// On Windows, Explorer for a folder and the shell's own "open" for a file, its error and + /// "open with" dialogs allowed; elsewhere xdg-open (open on macOS) through + /// /bin/sh, cut off from the terminal so nothing it prints lands on the panels. + /// + public static ProcessStartInfo BuildOpenStartInfo(string path, string? workingDirectory) + { + ProcessStartInfo info; + if (OperatingSystem.IsWindows()) + { + if (Directory.Exists(path)) { - info.WorkingDirectory = workingDirectory; + string windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + info = new ProcessStartInfo(windows.Length > 0 ? Path.Combine(windows, "explorer.exe") : "explorer.exe") + { + UseShellExecute = false, + }; + info.ArgumentList.Add(path); } + else + { + info = new ProcessStartInfo(path) { UseShellExecute = true, ErrorDialog = true }; + } + } + else + { + string opener = OperatingSystem.IsMacOS() ? "open" : "xdg-open"; + info = new ProcessStartInfo("/bin/sh") { UseShellExecute = false }; + info.ArgumentList.Add("-c"); + info.ArgumentList.Add("exec \"$0\" \"$1\" /dev/null 2>&1"); + info.ArgumentList.Add(opener); + info.ArgumentList.Add(path); + } + + if (!string.IsNullOrWhiteSpace(workingDirectory)) + { + info.WorkingDirectory = workingDirectory; + } + + return info; + } - // The launched process outlives us; disposing the handle does not kill it. - using Process? process = Process.Start(info); + /// + /// The program Windows opens files of an extension with, through AssocQueryStringW. + /// + /// The extension, dot included. + /// The program's full path, or when there is no association. + private static string? AssociatedProgram(string extension) + { + if (extension.Length < 2) + { + return null; } - catch (Exception e) when (e is System.ComponentModel.Win32Exception or InvalidOperationException or PlatformNotSupportedException or ObjectDisposedException or IOException) + + var buffer = new char[1024]; + uint length = (uint)buffer.Length; + int result = AssocQueryStringW( + AssocFlagNoTruncate | AssocFlagIgnoreUnknown, + AssocStrExecutable, + extension, + null, + buffer, + ref length); + + if (result != 0 || length <= 1) { - // No association, no helper, or the user cancelled the shell's own dialog. + return null; } + + string program = new(buffer, 0, (int)length - 1); + return File.Exists(program) ? program : null; } /// @@ -439,6 +626,21 @@ private static void WriteLine(string text) } } + /// ERROR_CANCELLED: the user closed the shell's "open with" dialog. + private const int ErrorCancelled = 1223; + + private const uint AssocFlagNoTruncate = 0x20; + + /// ASSOCF_INIT_IGNOREUNKNOWN: fail for an unassociated type instead of naming the "open with" helper. + private const uint AssocFlagIgnoreUnknown = 0x400; + + private const uint AssocStrExecutable = 2; + +#pragma warning disable SYSLIB1054 // DllImport keeps the project free of , as elsewhere. + [DllImport("shlwapi.dll", CharSet = CharSet.Unicode, EntryPoint = "AssocQueryStringW", ExactSpelling = true)] + private static extern int AssocQueryStringW(uint flags, uint str, string assoc, string? extra, [Out] char[] output, ref uint outputLength); +#pragma warning restore SYSLIB1054 + private static readonly char[] Whitespace = [' ', '\t']; /// The directory-change verbs, longest first so a prefix test tries them in order. diff --git a/src/OpenStus/Ui/HelpScreen.cs b/src/OpenStus/Ui/HelpScreen.cs index f714c67..f32579c 100644 --- a/src/OpenStus/Ui/HelpScreen.cs +++ b/src/OpenStus/Ui/HelpScreen.cs @@ -223,7 +223,8 @@ private void DrawScrollBar(ScreenBuffer buffer, Rect area, int firstRow, int row ("Panels", "Left / Right", "Move one column, or edit the command line"), ("Panels", "PgUp / PgDn", "Scroll one page"), ("Panels", "Home / End", "First / last item"), - ("Panels", "Enter", "Enter a folder or an archive, or run the file under the cursor"), + ("Panels", "Enter", "Enter a folder or an archive, or run the file; a document or windowed program opens without waiting"), + ("Panels", "Shift+Enter", "Open the folder in the system file manager (on .. the folder shown), or the file with its program"), ("Panels", "Ctrl+PgDn", "Enter the folder or archive under the cursor"), ("Panels", "Ctrl+PgUp", "Go to the parent folder"), ("Panels", "Ctrl+\\", "Go to the root of the current drive"), @@ -236,6 +237,7 @@ private void DrawScrollBar(ScreenBuffer buffer, Rect area, int firstRow, int row ("Panels", "Ctrl+H", "Show or hide hidden and system files"), ("Panels", "Ctrl+B", "Show or hide the function key bar"), ("Panels", "Alt+F1 / Alt+F2", "Change the drive of the left / right panel; the other panel's folder when it is on that drive"), + ("Panels", "Click the sort letter", "Change that panel's drive; the sort letter is the top-left corner of the column titles"), ("Panels", "Alt+", "Quick search by name"), ("Panels", "Ctrl+T", "Open a new tab on the current folder"), ("Panels", "Ctrl+W", "Close the current tab"), diff --git a/tests/OpenStus.Tests/CommandLineTests.cs b/tests/OpenStus.Tests/CommandLineTests.cs index 6b6df51..2256b1a 100644 --- a/tests/OpenStus.Tests/CommandLineTests.cs +++ b/tests/OpenStus.Tests/CommandLineTests.cs @@ -44,6 +44,10 @@ internal sealed class RecordingContext : IAppContext public void RunShellCommand(string command) => Commands.Add(command); + public void RunFile(string path) => throw new NotSupportedException(); + + public void OpenExternally(string path) => throw new NotSupportedException(); + public void InsertIntoCommandLine(string text) => throw new NotSupportedException(); } @@ -1815,6 +1819,10 @@ public void RunShellCommand(string command) { } + public void RunFile(string path) => throw new NotSupportedException(); + + public void OpenExternally(string path) => throw new NotSupportedException(); + public void InsertIntoCommandLine(string text) => throw new NotSupportedException(); } diff --git a/tests/OpenStus.Tests/LaunchTests.cs b/tests/OpenStus.Tests/LaunchTests.cs new file mode 100644 index 0000000..824eacc --- /dev/null +++ b/tests/OpenStus.Tests/LaunchTests.cs @@ -0,0 +1,318 @@ +using System.Diagnostics; +using OpenStus.Core; +using OpenStus.Input; +using OpenStus.Panels; +using OpenStus.Rendering; +using OpenStus.Shell; +using OpenStus.Theming; + +namespace OpenStus.Tests; + +/// +/// A context that records the files the panel asks to run or open. Every other member throws, so +/// an accidental new dependency shows up as a failing test. +/// +internal sealed class LaunchRecorder : IAppContext +{ + public List Ran { get; } = []; + + public List Opened { get; } = []; + + public Theme Theme { get; } = Theme.Classic(); + + public Settings Settings { get; } = new(); + + public Terminal Terminal => throw new NotSupportedException(); + + public IUiServices Ui => throw new NotSupportedException(); + + public IFilePanel ActivePanel => throw new NotSupportedException(); + + public IFilePanel PassivePanel => throw new NotSupportedException(); + + public IFilePanel LeftPanel => throw new NotSupportedException(); + + public IFilePanel RightPanel => throw new NotSupportedException(); + + public void SwapPanels() => throw new NotSupportedException(); + + public void SwitchPanel() => throw new NotSupportedException(); + + public void RequestQuit() => throw new NotSupportedException(); + + public void Redraw() => throw new NotSupportedException(); + + public void RefreshBothPanels() => throw new NotSupportedException(); + + public void RunShellCommand(string command) => throw new NotSupportedException(); + + public void RunFile(string path) => Ran.Add(path); + + public void OpenExternally(string path) => Opened.Add(path); + + public void InsertIntoCommandLine(string text) => throw new NotSupportedException(); +} + +/// Enter and Shift+Enter on the panel: what gets run, and what gets handed to the system. +public class PanelLaunchTests +{ + private static (FilePanel Panel, LaunchRecorder Ctx) New(int cursor) + { + var ctx = new LaunchRecorder(); + FilePanel panel = PanelFixture.Panel(); + panel.CursorIndex = cursor; + return (panel, ctx); + } + + [Fact] + public void EnterOnAFileRunsItThroughTheShellsChoice() + { + (FilePanel panel, LaunchRecorder ctx) = New(4); // notes.txt + + Assert.True(panel.HandleKey(PanelFixture.Key(ConsoleKey.Enter), ctx)); + + Assert.Equal(new[] { Path.Combine(PanelFixture.DemoPath, "notes.txt") }, ctx.Ran); + Assert.Empty(ctx.Opened); + } + + [Fact] + public void ShiftEnterOnAFolderOpensItInTheSystemFileManager() + { + (FilePanel panel, LaunchRecorder ctx) = New(1); // Documents + + Assert.True(panel.HandleKey(PanelFixture.Key(ConsoleKey.Enter, KeyMods.Shift), ctx)); + + Assert.Equal(new[] { Path.Combine(PanelFixture.DemoPath, "Documents") }, ctx.Opened); + Assert.Equal(PanelFixture.DemoPath, panel.CurrentPath); // the panel stays where it was + } + + [Fact] + public void ShiftEnterOnDotDotOpensTheFolderShown() + { + (FilePanel panel, LaunchRecorder ctx) = New(0); + + panel.HandleKey(PanelFixture.Key(ConsoleKey.Enter, KeyMods.Shift), ctx); + + Assert.Equal(new[] { PanelFixture.DemoPath }, ctx.Opened); + } + + [Fact] + public void ShiftEnterOnAFileOpensItRatherThanWalkingIntoIt() + { + (FilePanel panel, LaunchRecorder ctx) = New(3); // archive.zip + + panel.HandleKey(PanelFixture.Key(ConsoleKey.Enter, KeyMods.Shift), ctx); + + Assert.Equal(new[] { Path.Combine(PanelFixture.DemoPath, "archive.zip") }, ctx.Opened); + Assert.Empty(ctx.Ran); + Assert.Equal(PanelFixture.DemoPath, panel.CurrentPath); + } + + [Fact] + public void TheSortLetterIsTheDriveButton() + { + FilePanel panel = PanelFixture.Panel(); + + Assert.True(panel.IsDriveButtonAt(1, 1)); + Assert.False(panel.IsDriveButtonAt(2, 1)); // the rest of the column titles + Assert.False(panel.IsDriveButtonAt(1, 0)); // the top frame + Assert.False(panel.IsDriveButtonAt(0, 1)); // the left frame + + panel.IsVisible = false; + Assert.False(panel.IsDriveButtonAt(1, 1)); + } +} + +/// Deciding between the console and the desktop, and the hand-over to the desktop. +public class CommandExecutorLaunchTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "oc-tests", "launch-" + Guid.NewGuid().ToString("N")[..8]); + + public CommandExecutorLaunchTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + } + } + + /// The smallest file the PE reader accepts: "MZ", the header offset, "PE\0\0" and a subsystem. + private string Image(string name, ushort subsystem) + { + const int peAt = 0x80; + var bytes = new byte[0x100]; + bytes[0] = (byte)'M'; + bytes[1] = (byte)'Z'; + BitConverter.GetBytes(peAt).CopyTo(bytes, 0x3C); + bytes[peAt] = (byte)'P'; + bytes[peAt + 1] = (byte)'E'; + BitConverter.GetBytes(subsystem).CopyTo(bytes, peAt + 4 + 20 + 68); + + string path = Path.Combine(_root, name); + File.WriteAllBytes(path, bytes); + return path; + } + + [Fact] + public void TheSubsystemTellsConsoleProgramsFromWindowedOnes() + { + Assert.True(CommandExecutor.IsConsoleProgram(Image("tool.exe", 3))); + Assert.False(CommandExecutor.IsConsoleProgram(Image("app.exe", 2))); + Assert.Null(CommandExecutor.IsConsoleProgram(Image("driver.sys", 1))); + } + + [Fact] + public void AnythingButAProgramHasNoSubsystem() + { + string text = Path.Combine(_root, "notes.txt"); + File.WriteAllText(text, new string('x', 512)); + + Assert.Null(CommandExecutor.IsConsoleProgram(text)); + Assert.Null(CommandExecutor.IsConsoleProgram(Path.Combine(_root, "missing.exe"))); + } + + [Fact] + public void WindowedProgramsLeaveTheConsoleAndConsoleProgramsStay() + { + if (OperatingSystem.IsWindows()) + { + Assert.True(CommandExecutor.RunsInConsole(Image("tool.exe", 3))); + Assert.False(CommandExecutor.RunsInConsole(Image("app.exe", 2))); + Assert.True(CommandExecutor.RunsInConsole(Path.Combine(_root, "build.cmd"))); + Assert.True(CommandExecutor.RunsInConsole(Path.Combine(Environment.SystemDirectory, "cmd.exe"))); + } + else + { + string script = Path.Combine(_root, "build.sh"); + File.WriteAllText(script, "#!/bin/sh\n"); + Assert.False(CommandExecutor.RunsInConsole(script)); + + File.SetUnixFileMode(script, File.GetUnixFileMode(script) | UnixFileMode.UserExecute); + Assert.True(CommandExecutor.RunsInConsole(script)); + } + } + + [Fact] + public void AFolderGoesToTheFileManagerAndAFileToItsAssociation() + { + string file = Path.Combine(_root, "report.docx"); + File.WriteAllText(file, "x"); + + ProcessStartInfo folder = CommandExecutor.BuildOpenStartInfo(_root, _root); + ProcessStartInfo document = CommandExecutor.BuildOpenStartInfo(file, _root); + + if (OperatingSystem.IsWindows()) + { + Assert.EndsWith("explorer.exe", folder.FileName, StringComparison.OrdinalIgnoreCase); + Assert.Equal(new[] { _root }, folder.ArgumentList); + + Assert.Equal(file, document.FileName); + Assert.True(document.UseShellExecute); + } + else + { + string opener = OperatingSystem.IsMacOS() ? "open" : "xdg-open"; + Assert.Equal("/bin/sh", document.FileName); + Assert.Equal(opener, document.ArgumentList[2]); + Assert.Equal(file, document.ArgumentList[3]); + Assert.Equal(_root, folder.ArgumentList[3]); + } + + Assert.Equal(_root, document.WorkingDirectory); + } + + [Fact] + public void AHeadlessTerminalStartsNothing() + { + Terminal terminal = Terminal.Create(80, 25); + + Assert.True(CommandExecutor.Open(_root, _root, terminal, out string? error)); + Assert.Null(error); + } +} + +/// The mouse moves the focus only when it clicks. +public class PanelFocusTests +{ + private static Application Build(string root, IInputBackend? input = null) + { + Terminal terminal = Terminal.Create(120, 40); + var app = new Application(terminal, new Settings { ShowClock = false }, Theme.Classic(), input); + app.Initialize(new CommandLineArgs { LeftPath = root, RightPath = root }); + app.RenderNow(); + return app; + } + + private static InputEvent Mouse(MouseKind kind, int x, int y, int wheel = 0) => + InputEvent.FromMouse(new MouseEvent( + kind, + x, + y, + kind is MouseKind.Move or MouseKind.Wheel ? MouseButton.None : MouseButton.Left, + wheel, + KeyMods.None)); + + [Fact] + public void ThePointerPassingOverThePassivePanelLeavesTheFocusAlone() + { + using var tree = new ShellTree("focus-hover"); + using Application app = Build(tree.Root); + FilePanel right = app.RightFilePanel; + int x = right.Bounds.X + 5; + int y = right.Bounds.Y + 3; + + app.ProcessInput(Mouse(MouseKind.Move, x, y)); + app.ProcessInput(Mouse(MouseKind.Up, x, y)); + + Assert.True(app.LeftFilePanel.IsActive); + Assert.False(right.IsActive); + + app.ProcessInput(Mouse(MouseKind.Down, x, y)); + + Assert.True(right.IsActive); + Assert.False(app.LeftFilePanel.IsActive); + } + + [Fact] + public void TheWheelScrollsThePassivePanelWithoutFocusingIt() + { + using var tree = new ShellTree("focus-wheel"); + for (int i = 0; i < 120; i++) + { + File.WriteAllText(Path.Combine(tree.Root, $"file{i:D3}.txt"), "x"); + } + + using Application app = Build(tree.Root); + FilePanel right = app.RightFilePanel; + right.Reload(); + + app.ProcessInput(Mouse(MouseKind.Wheel, right.Bounds.X + 5, right.Bounds.Y + 3, wheel: -1)); + + Assert.Equal(FilePanel.WheelRows, right.TopIndex); + Assert.False(right.IsActive); + Assert.True(app.LeftFilePanel.IsActive); + } + + [Fact] + public void AClickOnTheSortLetterOpensThatPanelsDriveMenu() + { + using var tree = new ShellTree("focus-drives"); + var input = new ScriptedInput(ScriptedInput.Key(ConsoleKey.Escape)); + using Application app = Build(tree.Root, input); + FilePanel right = app.RightFilePanel; + + // Elsewhere on the column titles a click only moves the focus; nothing modal opens. + app.ProcessInput(Mouse(MouseKind.Down, right.Bounds.X + 5, right.Bounds.Y + 1)); + Assert.True(right.IsActive); + Assert.Equal(1, input.Remaining); + + // On the letter the drive menu (or, with no drives, its message) opens and takes the Escape. + app.ProcessInput(Mouse(MouseKind.Down, right.Bounds.X + 1, right.Bounds.Y + 1)); + Assert.Equal(0, input.Remaining); + } +}