diff --git a/AGENTS.md b/AGENTS.md index fe44e6f..c6d3b5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ What follows is how TuiCode implements it today. 1. Constant in `TuiCode.Abstractions.CommandIds`. 2. Register handler in `WorkbenchHost.RegisterDefaultCommands` via the labelled `Register(id, label, handler, scope)` overload. 3. Bind in `WorkbenchHost.BindDefaults` (called by `ApplyKeybindings`). + 4. Give it a home in the menu bar: its place in `CommandMenu.Layout`. `MenuBarHostTests` fails for a command with a mnemonic and no menu item, unless it's in `CommandMenu.Unlisted`. - **Command scopes (#132).** Every command has a fixed `CommandScope` from registration: `Global` (the default), `Editor`, `Explorer`, `Find` or `Diff`. A binding lands in its command's scope, user overrides and `-` removals included, so the keybindings file carries no scope. The store is one trie per scope; `Handle` tries the focused scope first (`WorkbenchHost.FocusedScope`: Find = the Find sidebar's inputs, Explorer = the explorer, Editor = the editor content, Diff = a diff tab), then Global. So one chord can mean different things per scope, and a scoped binding beats a Global one on the same key. A chord finishes in the scope it started in. A command registered with `isEnabled` that says no is skipped, so its key falls through to Global: explorer `Esc` cancels only a pending cut and editor `Esc` only removes extra cursors, otherwise it focuses the editor. **The document-changing commands are `Editor`** (#283): move/duplicate line up/down, add cursor above/below, select next/previous/all occurrences, toggle column select, plus remove secondary cursors and go to symbol. So are the commands that act on the open file (#286): go to line, change grammar, show document info and previous/next cursor position. Save, close, next/previous tab and focus-tab-N stay `Global` — they act on the tab as a whole and are useful from the file tree. `ScopedKeybindingsHostTests` pins the whole Global set, so a new document-changing command registered without a scope fails there rather than becoming a key that edits the file you aren't in. **The command palette (`Ctrl+E`) lists Global plus the scope the keys were in when it opened, less whatever `isEnabled` says no to** (#284): `ActionView` filters `ICommandService.Registered` on the `CommandScope` `WorkbenchHost.OpenActions` captures — captured there because opening the palette pushes a modal input scope over the region it came from — and on `IsEnabled`, so a command with no editor open, no git repo or no pull request loaded isn't offered. The exceptions are the commands whose no-editor behaviour is the point: `Ctrl+F`/`Ctrl+H` with no tab open the Find pane, and `Esc` says "Nothing to focus in Editor". Settings › Keyboard Shortcuts stays the unfiltered reference. **Scope governs the keys, the palette's list and the leader's list** (#285): a command you can't run where you are isn't offered anywhere but that reference. Both filter on scope and `isEnabled` alike. - Chord trie keyed on `Key`; `"Ctrl+W X"` is a single binding. Esc cancels in-flight chords; stray keys abort and are consumed silently. - **Showing a key to the user goes through `KeyChord.Display`**, which renders the cursor keys as arrows (`Alt+↓`, not TG's `Alt+CursorDown`, #194) and everything else as `Key.ToString()`. It's display-only and never parsed back, so identity stays `KeyChord.Canonical`. The status-bar hints, the keybindings picker, the command palette and the in-flight chord all use it. @@ -101,6 +102,13 @@ What follows is how TuiCode implements it today. - **Narrowing the list can't make a mnemonic fire early**, so there's no second, unfiltered copy of the table to keep in step (#285). `ResolveExact` fires only on a prefix that is itself a complete mnemonic, and the table is prefix-free, so a complete mnemonic has nothing under it that hiding could reveal. The guard is `MnemonicScopeHostTests.Narrowing_the_list_never_makes_a_mnemonic_fire_a_keystroke_earlier`, over the real commands; break the prefix-free invariant and it says so. A keystroke no *in-scope* mnemonic could complete is ignored by the same rejection `OnKey` always did, over the smaller set — so there's no abort path and no message, which is what makes an out-of-scope command seem not to exist. - The matching logic is the pure, TG-free `MnemonicResolver` (`Matching` for the hint list, `ResolveExact` for the fire-now decision) so it's unit-tested directly. `MnemonicView` is a thin capture-scope shell over it — it reads keys via `KeyCaptureScope` rather than a focused TextField precisely so a command that disposes the view executes on the app key-dispatch path, not inside a TextChanged callback. +## Menu bar (#340) + +- `CommandMenu` (`Workbench/Menus/`) fills TG's `MenuBar` across the top of the workbench from `CommandMenu.Layout`: eight menus, every command with a mnemonic in exactly one, plus *Show all commands* and *Show mnemonics*. The exceptions are the focus moves in `CommandMenu.Unlisted`, since opening the menu moves focus itself. View lists the sidebar panels by name (`CommandMenu.Titles`), as VS Code does. An item runs its command by id, through `RunLaunched` like the palette. Its key column is `KeyView.Text`, set from the live bindings by `ApplyKeybindings`, not `MenuItem.Key`: that holds one `Key`, not a chord, and the workbench already dispatches the key, so `BindKeyToApplication` is off. +- `F10` is `ShowMenu`, rebindable like any other; TG's own F10 hot key is removed so an unbound key can't open it. `Alt+` opens a menu through TG's hot keys; the bare letters are removed, since TG would otherwise open a menu on a typed `f` wherever the keys are. Esc is re-added to each popover's Quit, which `NeutralizeBuiltinQuitKey` took away. +- While a menu is open the host pushes an empty key scope, so arrows, Enter and Esc reach the menu rather than workbench bindings. The region is captured as it opens, and each item is greyed out unless its key would run there, in its scope and enabled: the palette's rule. A menu with no item that could run leaves the bar (`CommandMenu.ShowAvailable`), re-checked on each focus move and a few times a second, since some `isEnabled` checks touch the disk. It's taken off rather than hidden because TG's bar keeps a hidden item's place. A picked command is checked again before it runs. The menu won't open over a dialog (`Workbench.HasDialog`). +- `CommandMenu.Closed` fires on the loop iteration after the last popover shuts: moving between menus shuts one before opening the next, and TG is still moving focus as it shuts. `App.Invoke` runs inline on the UI thread, so it doesn't defer; `AddTimeout(TimeSpan.Zero, ...)` does. Moving focus any earlier knocked the active tab over to another (see *Terminal.Gui v2*). + ## Getting Started dialog (F1) - `HelpView` is onboarding, not a shortcut reference — the command palette (`Ctrl+E`) lists the commands that apply where you are with their keys, and Settings › Keyboard Shortcuts every one of them. It shows only how to find everything else (palette, mnemonics) plus the handful of keys needed in the first few minutes (open, new, save, close, find, quit). Don't add a row per feature; a new row has to displace one. diff --git a/src/TuiCode.Abstractions/CommandIds.cs b/src/TuiCode.Abstractions/CommandIds.cs index 5bafd15..9930356 100644 --- a/src/TuiCode.Abstractions/CommandIds.cs +++ b/src/TuiCode.Abstractions/CommandIds.cs @@ -30,6 +30,7 @@ public static class CommandIds public const string ShowActions = "workbench.action.showActions"; public const string ShowMnemonics = "workbench.action.showMnemonics"; + public const string ShowMenu = "workbench.action.showMenu"; public const string ShowHelp = "workbench.action.showHelp"; public const string ShowDiagnostics = "workbench.action.showDiagnostics"; public const string ShowAbout = "workbench.action.showAbout"; diff --git a/src/TuiCode.Workbench/Menus/CommandMenu.cs b/src/TuiCode.Workbench/Menus/CommandMenu.cs new file mode 100644 index 0000000..aedb61f --- /dev/null +++ b/src/TuiCode.Workbench/Menus/CommandMenu.cs @@ -0,0 +1,223 @@ +using TuiCode.Abstractions; + +namespace TuiCode.Workbench.Menus; + +/// +/// The menu bar's eight menus (#340). Every item runs a registered command by id through the same path as the +/// palette, so the menu, the key, the mnemonic and the palette are four doors into one list. +/// +public sealed class CommandMenu +{ + public const string Separator = "-"; + + /// Commands with a mnemonic but no menu item: opening the menu moves focus itself. + public static readonly string[] Unlisted = + [ + CommandIds.FocusSidebar, CommandIds.FocusEditorBody, CommandIds.FocusEditorTabStrip, + .. Enumerable.Range(1, 9).Select(CommandIds.FocusEditorByIndex), + ]; + + /// Items titled for their menu rather than with the command's label. + public static readonly IReadOnlyDictionary Titles = new Dictionary + { + [CommandIds.ShowExplorer] = "Explorer", + [CommandIds.FindGlobally] = "Find", + [CommandIds.FocusReview] = "Review", + }; + + public static readonly (string Title, string[] Ids)[] Layout = + [ + ("_File", + [ + CommandIds.New, CommandIds.Open, CommandIds.SaveActiveEditor, CommandIds.ReloadFromDisk, CommandIds.CloseActiveEditor, + Separator, + CommandIds.RenameFile, CommandIds.CutFile, CommandIds.PasteFile, CommandIds.DeleteFile, + Separator, + CommandIds.ShowDocumentInfo, + Separator, + CommandIds.OpenSettings, + Separator, + CommandIds.Quit, + ]), + ("_Edit", + [ + CommandIds.MoveLinesUp, CommandIds.MoveLinesDown, CommandIds.DuplicateLinesUp, CommandIds.DuplicateLinesDown, + Separator, + CommandIds.FindInFile, CommandIds.ReplaceInFile, CommandIds.ReplaceGlobally, + Separator, + CommandIds.ChangeGrammar, + ]), + ("_Selection", + [ + CommandIds.AddCursorAbove, CommandIds.AddCursorBelow, CommandIds.RemoveSecondaryCursors, + Separator, + CommandIds.ToggleColumnSelect, + Separator, + CommandIds.SelectNextOccurrence, CommandIds.SelectPreviousOccurrence, CommandIds.SelectAllOccurrences, + ]), + ("_View", + [ + CommandIds.ShowExplorer, CommandIds.FindGlobally, CommandIds.FocusReview, + Separator, + CommandIds.ToggleSidebar, CommandIds.WidenSidebar, CommandIds.NarrowSidebar, + Separator, + CommandIds.RefreshExplorer, + Separator, + CommandIds.ToggleGutter, + ]), + ("_Go", + [ + CommandIds.GoToLine, CommandIds.GoToSymbol, + Separator, + CommandIds.NavigateBack, CommandIds.NavigateForward, + Separator, + CommandIds.NextEditor, CommandIds.PreviousEditor, + ]), + ("_Diff", + [ + CommandIds.CompareToSaved, CommandIds.CompareToRevision, CommandIds.CompareToOtherFile, + Separator, + CommandIds.NextChange, CommandIds.PreviousChange, CommandIds.GoToChangeLine, + Separator, + CommandIds.RevertChange, CommandIds.RevertAllChanges, + Separator, + CommandIds.GitBlame, + ]), + ("_Review", + [ + CommandIds.OpenPullRequest, CommandIds.PullRequestOverview, + Separator, + CommandIds.CreateComment, CommandIds.SubmitReview, + ]), + ("_Help", + [ + CommandIds.ShowHelp, CommandIds.ShowActions, CommandIds.ShowMnemonics, + Separator, + CommandIds.ShowDiagnostics, CommandIds.ShowAbout, + ]), + ]; + + private readonly MenuBar _bar; + private readonly ICommandService _commands; + private readonly IKeybindingService _keybindings; + private readonly Func _canOpen; + private readonly Func _isAvailable; + private readonly List<(string Id, MenuItem Item)> _items = []; + private readonly List<(MenuBarItem Menu, (string Id, MenuItem Item)[] Items)> _menus = []; + private string? _picked; + + public CommandMenu(MenuBar bar, ICommandService commands, IKeybindingService keybindings, Func canOpen, + Func isAvailable) + { + _bar = bar; + _commands = commands; + _keybindings = keybindings; + _canOpen = canOpen; + _isAvailable = isAvailable; + // Our own keybinding service opens it, so the key can be rebound. + _bar.HotKeyBindings.Remove(_bar.Key); + _bar.Menus = [.. Layout.Select(Build)]; + Refresh(); + } + + public bool IsOpen { get; private set; } + + public IReadOnlyList<(string Id, MenuItem Item)> Items => _items; + + public event EventHandler? Opened; + + /// The menu has shut, carrying the command picked from it, if any. + public event EventHandler? Closed; + + public void Open() + { + if (!IsOpen) _bar.InvokeCommand(Command.HotKey); + } + + /// Shows each item's current key, e.g. after a rebind in Settings. + public void Refresh() + { + var keys = _keybindings.Bindings + .GroupBy(b => b.CommandId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().Display, StringComparer.Ordinal); + foreach (var (id, item) in _items) + { + var key = keys.GetValueOrDefault(id, string.Empty); + if (item.KeyView.Text == key) continue; + item.KeyView.Text = key; + item.SetNeedsLayout(); + } + } + + /// Greys out each item that can't run, and takes a menu with none that can off the bar. + public void ShowAvailable() + { + GreyOutUnavailable(); + var shown = _menus.Where(m => m.Items.Any(i => i.Item.Enabled)).Select(m => m.Menu).ToArray(); + // TG's bar keeps a hidden item's place, so an empty menu leaves the bar rather than hiding. + if (!shown.SequenceEqual(_bar.SubViews.OfType())) _bar.Menus = shown; + } + + private void GreyOutUnavailable() + { + foreach (var (id, item) in _items) item.Enabled = _isAvailable(id); + } + + private MenuBarItem Build((string Title, string[] Ids) entry) + { + var first = _items.Count; + var menu = new MenuBarItem(entry.Title, entry.Ids.Select(Entry).ToArray()); + _menus.Add((menu, _items[first..].ToArray())); + // A title's bare letter would open its menu from anywhere and swallow typing; keep only Alt+letter. + menu.HotKeyBindings.Remove(menu.HotKey); + menu.HotKeyBindings.Remove(menu.HotKey.WithShift); + // NeutralizeBuiltinQuitKey took Esc off TG's Quit command, and the menu's close with it. + menu.PopoverMenu?.KeyBindings.Add(Key.Esc, Command.Quit); + menu.PopoverMenuOpenChanged += (_, e) => OnOpenChanged(e.NewValue); + return menu; + } + + private View Entry(string id) => id == Separator ? new Line() : Item(id); + + private MenuItem Item(string id) + { + var item = new MenuItem + { + Title = Titles.GetValueOrDefault(id) ?? _commands.Registered.FirstOrDefault(c => c.Id == id)?.Label ?? id, + // The workbench already dispatches the key; binding it here too would run the command twice. + BindKeyToApplication = false, + Action = () => _picked = id, + }; + _items.Add((id, item)); + return item; + } + + // Moving between menus closes one before opening the next, and TG is still moving focus as a menu + // shuts, so decide it has shut on the next loop iteration. + private void OnOpenChanged(bool open) + { + if (open) + { + if (IsOpen) return; + if (!_canOpen()) + { + _bar.HideActiveItem(); + return; + } + IsOpen = true; + _picked = null; + Opened?.Invoke(this, EventArgs.Empty); + GreyOutUnavailable(); + return; + } + _bar.App?.AddTimeout(TimeSpan.Zero, () => + { + if (!IsOpen || _bar.IsOpen()) return false; + IsOpen = false; + var picked = _picked; + _picked = null; + Closed?.Invoke(this, picked); + return false; + }); + } +} diff --git a/src/TuiCode.Workbench/Themes/themes.json b/src/TuiCode.Workbench/Themes/themes.json index f33e54e..5b9e699 100644 --- a/src/TuiCode.Workbench/Themes/themes.json +++ b/src/TuiCode.Workbench/Themes/themes.json @@ -69,10 +69,10 @@ "Menu": { "Normal": { "Foreground": "#000000", "Background": "#AAAAAA" }, "Focus": { "Foreground": "#000000", "Background": "#00AA00" }, - "HotNormal": { "Foreground": "#AA0000", "Background": "#AAAAAA" }, - "HotFocus": { "Foreground": "#AA0000", "Background": "#00AA00" }, + "HotNormal": { "Foreground": "#AA0000", "Background": "#AAAAAA", "Style": "Underline" }, + "HotFocus": { "Foreground": "#AA0000", "Background": "#00AA00", "Style": "Underline" }, "Active": { "Foreground": "#000000", "Background": "#00AA00" }, - "HotActive": { "Foreground": "#AA0000", "Background": "#00AA00" }, + "HotActive": { "Foreground": "#AA0000", "Background": "#00AA00", "Style": "Underline" }, "Highlight": { "Foreground": "#000000", "Background": "#00AAAA" }, "Editable": { "Foreground": "#FFFF55", "Background": "#0000AA" }, "ReadOnly": { "Foreground": "#555555", "Background": "#AAAAAA" }, @@ -175,16 +175,16 @@ }, { "Menu": { - "Normal": { "Foreground": "#000000", "Background": "#00A8A8" }, + "Normal": { "Foreground": "#000000", "Background": "#A8A8A8" }, "Focus": { "Foreground": "#FFFFFF", "Background": "#308800" }, - "HotNormal": { "Foreground": "#FFFFFF", "Background": "#00A8A8" }, - "HotFocus": { "Foreground": "#FFFF57", "Background": "#308800" }, + "HotNormal": { "Foreground": "#A80000", "Background": "#A8A8A8", "Style": "Underline" }, + "HotFocus": { "Foreground": "#FFFF57", "Background": "#308800", "Style": "Underline" }, "Active": { "Foreground": "#FFFFFF", "Background": "#308800" }, - "HotActive": { "Foreground": "#FFFF57", "Background": "#308800" }, + "HotActive": { "Foreground": "#FFFF57", "Background": "#308800", "Style": "Underline" }, "Highlight": { "Foreground": "#FFFFFF", "Background": "#006C90" }, "Editable": { "Foreground": "#FFFF57", "Background": "#003078" }, - "ReadOnly": { "Foreground": "#004E84", "Background": "#00A8A8" }, - "Disabled": { "Foreground": "#004E84", "Background": "#00A8A8" } + "ReadOnly": { "Foreground": "#545454", "Background": "#A8A8A8" }, + "Disabled": { "Foreground": "#545454", "Background": "#A8A8A8" } } }, { @@ -283,16 +283,16 @@ }, { "Menu": { - "Normal": { "Foreground": "#C5CAD3", "Background": "#262A33" }, - "Focus": { "Foreground": "#E6E9EF", "Background": "#2F4A6D" }, - "HotNormal": { "Foreground": "#6CB6FF", "Background": "#262A33" }, - "HotFocus": { "Foreground": "#6CB6FF", "Background": "#2F4A6D" }, - "Active": { "Foreground": "#E6E9EF", "Background": "#2F4A6D" }, - "HotActive": { "Foreground": "#6CB6FF", "Background": "#2F4A6D" }, - "Highlight": { "Foreground": "#E6C07B", "Background": "#262A33" }, + "Normal": { "Foreground": "#FFFFFF", "Background": "#2B5A8A" }, + "Focus": { "Foreground": "#FFFFFF", "Background": "#0F3557" }, + "HotNormal": { "Foreground": "#FFD866", "Background": "#2B5A8A", "Style": "Underline" }, + "HotFocus": { "Foreground": "#FFD866", "Background": "#0F3557", "Style": "Underline" }, + "Active": { "Foreground": "#FFFFFF", "Background": "#0F3557" }, + "HotActive": { "Foreground": "#FFD866", "Background": "#0F3557", "Style": "Underline" }, + "Highlight": { "Foreground": "#FFD866", "Background": "#2B5A8A" }, "Editable": { "Foreground": "#D4D7DD", "Background": "#1B1E24" }, - "ReadOnly": { "Foreground": "#7F8794", "Background": "#262A33" }, - "Disabled": { "Foreground": "#4B5263", "Background": "#262A33" } + "ReadOnly": { "Foreground": "#C7D4E2", "Background": "#2B5A8A" }, + "Disabled": { "Foreground": "#9CB4CC", "Background": "#2B5A8A" } } }, { @@ -391,16 +391,16 @@ }, { "Menu": { - "Normal": { "Foreground": "#383A42", "Background": "#EDEEF1" }, - "Focus": { "Foreground": "#1F2328", "Background": "#CFE0F5" }, - "HotNormal": { "Foreground": "#0969DA", "Background": "#EDEEF1" }, - "HotFocus": { "Foreground": "#0969DA", "Background": "#CFE0F5" }, - "Active": { "Foreground": "#1F2328", "Background": "#CFE0F5" }, - "HotActive": { "Foreground": "#0969DA", "Background": "#CFE0F5" }, - "Highlight": { "Foreground": "#9A6700", "Background": "#EDEEF1" }, + "Normal": { "Foreground": "#FFFFFF", "Background": "#2B5A8A" }, + "Focus": { "Foreground": "#FFFFFF", "Background": "#0F3557" }, + "HotNormal": { "Foreground": "#FFD866", "Background": "#2B5A8A", "Style": "Underline" }, + "HotFocus": { "Foreground": "#FFD866", "Background": "#0F3557", "Style": "Underline" }, + "Active": { "Foreground": "#FFFFFF", "Background": "#0F3557" }, + "HotActive": { "Foreground": "#FFD866", "Background": "#0F3557", "Style": "Underline" }, + "Highlight": { "Foreground": "#FFD866", "Background": "#2B5A8A" }, "Editable": { "Foreground": "#383A42", "Background": "#FFFFFF" }, - "ReadOnly": { "Foreground": "#6A6F78", "Background": "#EDEEF1" }, - "Disabled": { "Foreground": "#A0A4AC", "Background": "#EDEEF1" } + "ReadOnly": { "Foreground": "#C7D4E2", "Background": "#2B5A8A" }, + "Disabled": { "Foreground": "#9CB4CC", "Background": "#2B5A8A" } } }, { diff --git a/src/TuiCode.Workbench/Workbench.cs b/src/TuiCode.Workbench/Workbench.cs index 9867001..425a420 100644 --- a/src/TuiCode.Workbench/Workbench.cs +++ b/src/TuiCode.Workbench/Workbench.cs @@ -14,6 +14,10 @@ public sealed class Workbench : Window public SidebarPart Sidebar { get; } public EditorPart Editor { get; } public StatusBarPart StatusBar { get; } + public MenuBar MenuBar { get; } = new(); + + /// A dialog is open over the workbench: every view the host adds beside the four parts is one. + public bool HasDialog => SubViews.Any(view => view != MenuBar && view != Sidebar && view != Editor && view != StatusBar); public bool IsSidebarVisible { get; private set; } = true; @@ -39,12 +43,12 @@ public Workbench(SidebarPart sidebar, EditorPart editor, StatusBarPart statusBar BorderStyle = LineStyle.None; sidebar.X = 0; - sidebar.Y = 0; + sidebar.Y = Pos.Bottom(MenuBar); sidebar.Width = SidebarSizing.Default; sidebar.Height = Dim.Fill(1); editor.X = Pos.Right(sidebar); - editor.Y = 0; + editor.Y = Pos.Bottom(MenuBar); editor.Width = Dim.Fill(); editor.Height = Dim.Fill(1); @@ -53,7 +57,7 @@ public Workbench(SidebarPart sidebar, EditorPart editor, StatusBarPart statusBar statusBar.Width = Dim.Fill(); statusBar.Height = 1; - Add(sidebar, editor, statusBar); + Add(MenuBar, sidebar, editor, statusBar); sidebar.Explorer.FileActivated += (_, file) => OpenFile(file); diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index b25ec6f..a450100 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -17,6 +17,7 @@ using TuiCode.Workbench.Git; using TuiCode.Workbench.Grammars; using TuiCode.Workbench.Help; +using TuiCode.Workbench.Menus; using TuiCode.Workbench.Mnemonics; using TuiCode.Workbench.Navigation; using TuiCode.Workbench.Parts; @@ -57,6 +58,11 @@ public sealed class WorkbenchHost : IDisposable private readonly IGitCli _git; private readonly IGitHubCli _gitHub; private readonly TerminalCursors _terminalCursors; + private readonly CommandMenu _menu; + // Open menus take arrows, Enter and Esc for themselves, so no workbench key fires under one. + private readonly KeybindingService _menuKeys = new(new CommandService()); + private FocusRegion _menuOpenedFrom; + private long _menuShownAt; private readonly FindController _find; private readonly FocusService _focus; private readonly DiskChanges _diskChanges; @@ -145,6 +151,10 @@ public WorkbenchHost( _focus = new FocusService(FocusedView); RegisterDefaultCommands(); + _menu = new CommandMenu(_workbench.MenuBar, _commands, _keybindings, () => !_workbench.HasDialog, + id => IsAvailableFrom(id, _menu.IsOpen ? _menuOpenedFrom : _focus.Region)); + _menu.Opened += (_, _) => OnMenuOpened(); + _menu.Closed += (_, picked) => OnMenuClosed(picked); ApplyKeybindings(_settings.KeybindingOverrides); _workbench.Editor.Group.Settings = _settings.Editor; _workbench.SetSidebarWidth(_settings.SidebarWidth); @@ -229,6 +239,7 @@ private void RegisterFocusRegions() { var inSidebar = region is FocusRegion.Explorer or FocusRegion.Find or FocusRegion.Review; _workbench.StatusBar.SetFocusRegion(FocusService.Label(region)); + ShowAvailableMenus(); sidebarBorder.Show(inSidebar); editorBorder.Show(!inSidebar); }; @@ -299,6 +310,8 @@ private static void WriteToTerminal(string sequence) } public IApplication App => _app; + + internal CommandMenu Menu => _menu; public Workbench Workbench => _workbench; public void Run() => _app.Run(_workbench, errorHandler: null!); @@ -472,6 +485,7 @@ private void RegisterDefaultCommands() () => { if (search.ReplaceVisible) search.RequestReplaceAll(); }, CommandScope.Find, () => search.InputsHaveFocus); _commands.Register(CommandIds.ShowActions, "Show all commands", OpenActions); _commands.Register(CommandIds.ShowMnemonics, "Show mnemonics", OpenMnemonics); + _commands.Register(CommandIds.ShowMenu, "Show menu", () => _menu.Open()); _commands.Register(CommandIds.ShowHelp, "Getting Started (help)", OpenHelp); _commands.Register(CommandIds.GoToLine, "Go to line:column", OpenGoToLine, CommandScope.Editor, FileOpen); // No default key (#137): VS Code's Ctrl+Shift+O collapses onto Ctrl+O in Terminal.app. @@ -548,6 +562,7 @@ public void ApplyKeybindings(IEnumerable overrides) } } + _menu.Refresh(); var help = _keybindings.Bindings.FirstOrDefault(b => b.CommandId == CommandIds.ShowHelp); _workbench.StatusBar.SetIdleHint(help is null ? null : $"Press {help.Display} for help"); _workbench.DiffKeysHint = DiffKeys("revert"); @@ -625,6 +640,7 @@ private static void BindDefaults(IKeybindingService keybindings) // the mnemonics it dispatches are fixed (CommandMnemonics). keybindings.Bind("Ctrl+Space", CommandIds.ShowMnemonics); keybindings.Bind("F1", CommandIds.ShowHelp); + keybindings.Bind("F10", CommandIds.ShowMenu); // Ctrl+G is a chord family (#35): L = go-to-line, P/N = previous/next cursor location, B = blame (#330). keybindings.Bind("Ctrl+G L", CommandIds.GoToLine); keybindings.Bind("Ctrl+G P", CommandIds.NavigateBack); @@ -931,6 +947,33 @@ private void RunLaunched(string commandId, bool fromExplorer) finally { _launchedFromExplorer = false; } } + private void OnMenuOpened() + { + _menuOpenedFrom = _focus.Region; + _scopes.Push(_menuKeys); + } + + // The palette's rule: a command is offered where its key would run, in its scope and enabled. + private bool IsAvailableFrom(string id, FocusRegion region) + { + var scope = _commands.ScopeOf(id); + return (scope == CommandScope.Global || scope == FocusService.ScopeOf(region)) && _commands.IsEnabled(id); + } + + private void ShowAvailableMenus() + { + _menuShownAt = Environment.TickCount64; + if (!_menu.IsOpen) _menu.ShowAvailable(); + } + + private void OnMenuClosed(string? picked) + { + _scopes.Pop(_menuKeys); + FocusCallingRegion(); + if (picked is null || !IsAvailableFrom(picked, _menuOpenedFrom)) return; + RunLaunched(picked, _menuOpenedFrom == FocusRegion.Explorer); + } + private void OpenMnemonics() { if (_activeMnemonics is not null) return; @@ -1062,6 +1105,8 @@ private void OnIteration(object? sender, EventArgs e) // Picks up focus Terminal.Gui moved on its own, and any move that didn't land where it was asked to. _focus.Reconcile(); _activeSymbolPicker?.Advance(); + // Some isEnabled checks touch the disk, so not on every iteration. + if (Environment.TickCount64 - _menuShownAt >= 250) ShowAvailableMenus(); } private void OnEditorCursorMoved(object? sender, (IFileInfo File, int Row, int Column) e) diff --git a/tests/TuiCode.Tests/EditorScrollBarTests.cs b/tests/TuiCode.Tests/EditorScrollBarTests.cs index f29e263..b584051 100644 --- a/tests/TuiCode.Tests/EditorScrollBarTests.cs +++ b/tests/TuiCode.Tests/EditorScrollBarTests.cs @@ -321,7 +321,7 @@ private static MockFileSystem LongFile() } private static Action Size(WorkbenchHost host, int width, int height) => - () => host.App.Driver!.SetScreenSize(width, height); + () => HostSteps.PinScreenSize(host, width, height); private static EditorTab Tab(Workbench.Workbench workbench) => workbench.Editor.Group.ActiveTab!; diff --git a/tests/TuiCode.Tests/FocusHostTests.cs b/tests/TuiCode.Tests/FocusHostTests.cs index 6a813a5..9678363 100644 --- a/tests/TuiCode.Tests/FocusHostTests.cs +++ b/tests/TuiCode.Tests/FocusHostTests.cs @@ -281,7 +281,7 @@ await HostSteps.Run(host, } // The workbench's own three parts are the sidebar, the editor and the status bar; anything else is a modal. - private static int Modals(Workbench.Workbench workbench) => workbench.SubViews.Count - 3; + private static int Modals(Workbench.Workbench workbench) => workbench.SubViews.Count - 4; [Fact] public async Task Saving_leaves_the_keys_in_the_diff() diff --git a/tests/TuiCode.Tests/HostSteps.cs b/tests/TuiCode.Tests/HostSteps.cs index c816e2b..00c35ff 100644 --- a/tests/TuiCode.Tests/HostSteps.cs +++ b/tests/TuiCode.Tests/HostSteps.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; +using Terminal.Gui.Drivers; using TuiCode.Workbench; namespace TuiCode.Tests; @@ -37,4 +39,26 @@ void OnIteration(object? sender, EventArgs e) static bool Execute(Action act) { act(); return true; } } + + private static readonly ConditionalWeakTable> PinnedSizes = new(); + + // The Windows console answers TG's periodic size query with its own size, which would undo a test's resize. + public static void PinScreenSize(WorkbenchHost host, int width, int height) + { + var driver = host.App.Driver!; + var size = new System.Drawing.Size(width, height); + if (PinnedSizes.TryGetValue(driver, out var pinned)) + pinned.Value = size; + else + { + pinned = new StrongBox(size); + PinnedSizes.Add(driver, pinned); + driver.SizeChanged += (_, e) => + { + if (e.Size is { } actual && actual != pinned.Value) + driver.SetScreenSize(pinned.Value.Width, pinned.Value.Height); + }; + } + driver.SetScreenSize(width, height); + } } diff --git a/tests/TuiCode.Tests/MenuBarHostTests.cs b/tests/TuiCode.Tests/MenuBarHostTests.cs new file mode 100644 index 0000000..cd6332c --- /dev/null +++ b/tests/TuiCode.Tests/MenuBarHostTests.cs @@ -0,0 +1,393 @@ +using System.Drawing; +using Terminal.Gui.Configuration; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using TuiCode.Abstractions; +using TuiCode.Explorer; +using TuiCode.Workbench; +using TuiCode.Workbench.Menus; +using TuiCode.Workbench.Parts; +using TuiCode.Workbench.Review; +using TuiCode.Workbench.Services; +using TuiCode.Workbench.Themes; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace TuiCode.Tests; + +// The menu bar (#340). Boots a TG Application — serialised (#77). +public class MenuBarHostTests : StaticConfigurationTest +{ + private readonly MockFileSystem _fs = new(); + private readonly FakeGitCli _git = new() { Root = "/work" }; + private readonly FakeGitHubCli _gitHub = new(); + + public MenuBarHostTests() + { + _fs.AddDirectory("/work/.git"); + _fs.AddFile("/work/a.txt", new MockFileData("one\ntwo\nthree\n")); + } + + [Fact] + public void Every_command_with_a_mnemonic_but_the_focus_moves_has_exactly_one_menu_item() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out var commands); + var mapped = MenuIds().ToList(); + + Assert.All(CommandMnemonics.All.Where(pair => !CommandMenu.Unlisted.Contains(pair.Key)), + pair => Assert.Single(mapped, id => id == pair.Key)); + Assert.All(CommandMenu.Unlisted, id => Assert.DoesNotContain(id, mapped)); + Assert.Contains(CommandIds.ShowActions, mapped); + Assert.Contains(CommandIds.ShowMnemonics, mapped); + Assert.Equal(mapped.Count, mapped.Distinct().Count()); + Assert.All(mapped, id => Assert.True(commands.IsRegistered(id), $"{id} isn't a registered command")); + } + + [Fact] + public void The_menus_read_across_the_top_with_distinct_hot_letters() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + var menus = workbench.MenuBar.SubViews.OfType().ToList(); + + Assert.Equal(["File", "Edit", "Selection", "View", "Go", "Diff", "Review", "Help"], + menus.Select(m => m.Title.Replace("_", ""))); + Assert.Equal(menus.Count, menus.Select(m => m.HotKey).Distinct().Count()); + Assert.All(menus, m => Assert.Equal(m.Title[1], (char)m.HotKey.KeyCode)); + } + + [Fact] + public async Task The_bar_sits_above_the_sidebar_and_the_editor() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + var (bar, sidebar, editor, width) = (default(Rectangle), default(Rectangle), default(Rectangle), 0); + + await HostSteps.Run(host, + () => workbench.MenuBar.Frame.Width > 0, + () => { (bar, sidebar, editor, width) = (workbench.MenuBar.FrameToScreen(), workbench.Sidebar.FrameToScreen(), + workbench.Editor.FrameToScreen(), workbench.FrameToScreen().Width); }); + + Assert.Equal(new Rectangle(0, 0, width, 1), bar); + Assert.Equal(1, sidebar.Y); + Assert.Equal(1, editor.Y); + Assert.Equal(workbench.StatusBar.FrameToScreen().Y, sidebar.Bottom); + } + + [Fact] + public async Task F10_opens_File_and_Esc_hands_the_keys_back() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + string? first = null; + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor", + () => host.App.InjectKey(Key.F10), + () => workbench.MenuBar.IsOpen(), + () => { first = Focused(workbench); host.App.InjectKey(Key.Esc); }, + () => !workbench.MenuBar.IsOpen() && workbench.Editor.Group.ActiveTab!.HasFocus); + + Assert.Equal("New file or folder", first); + Assert.Equal("Editor", workbench.StatusBar.DisplayedFocus); + } + + [Fact] + public async Task Show_menu_can_be_rebound() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + + await HostSteps.Run(host, + () => host.ApplyKeybindings( + [ + new KeybindingOverride(TestKeys.Chord("F10"), "-" + CommandIds.ShowMenu), + new KeybindingOverride(TestKeys.Chord("F9"), CommandIds.ShowMenu), + ]), + () => host.App.InjectKey(Key.F10), + () => Assert.False(workbench.MenuBar.IsOpen()), + () => host.App.InjectKey(Key.F9), + () => workbench.MenuBar.IsOpen(), + () => host.App.InjectKey(Key.Esc), + () => !workbench.MenuBar.IsOpen()); + } + + // Arrows walk the menu, and Enter runs the item once: a second binding would move the line twice. + [Fact] + public async Task Edit_Move_line_down_moves_the_editors_line_once() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor", + () => host.App.InjectKey(Key.F10), + () => workbench.MenuBar.IsOpen(), + () => host.App.InjectKey(Key.CursorRight), + () => host.App.InjectKey(Key.CursorDown), + () => Focused(workbench) == "Move line down", + () => host.App.InjectKey(Key.Enter), + () => !workbench.MenuBar.IsOpen() && workbench.Editor.Group.ActiveTab!.Content != "one\ntwo\nthree\n"); + + Assert.Equal("two\none\nthree\n", workbench.Editor.Group.ActiveTab!.Content.ReplaceLineEndings("\n")); + Assert.Equal("Editor", workbench.StatusBar.DisplayedFocus); + } + + [Fact] + public async Task Diff_Next_change_moves_the_diff_the_menu_was_opened_from() + { + _fs.AddFile("/work/a.txt", new MockFileData("one\ntwo\nthree\nfour\nfive\nsix\n")); + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out var commands); + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor", + () => { workbench.Editor.Group.ActiveTab!.Content = "one\ntwo\nthree\nFOUR\nfive\nSIX\n"; }, + () => commands.TryExecute(CommandIds.CompareToSaved), + () => workbench.StatusBar.DisplayedFocus == "Diff", + () => host.App.InjectKey(Key.D.WithAlt), + () => Focused(workbench) == "Next change", + () => host.App.InjectKey(Key.Enter), + () => !workbench.MenuBar.IsOpen() && workbench.Editor.Group.ActiveDiffTab?.CurrentChange == 1); + + Assert.Equal("Diff", workbench.StatusBar.DisplayedFocus); + } + + // The palette's rule: an item is live only where its key would run. + [Fact] + public async Task With_no_editor_open_the_editor_commands_are_greyed_out() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + string? first = null; + + await HostSteps.Run(host, + () => host.App.InjectKey(Key.E.WithAlt), + () => workbench.MenuBar.IsOpen(), + () => { first = Focused(workbench); host.App.InjectKey(Key.Esc); }, + () => !workbench.MenuBar.IsOpen()); + + Assert.False(Item(host, CommandIds.MoveLinesUp).Enabled); + Assert.False(Item(host, CommandIds.ChangeGrammar).Enabled); + Assert.False(Item(host, CommandIds.AddCursorAbove).Enabled); + Assert.False(Item(host, CommandIds.NextChange).Enabled); + Assert.True(Item(host, CommandIds.FindInFile).Enabled); + Assert.True(Item(host, CommandIds.ToggleSidebar).Enabled); + Assert.Equal("Find in file", first); + } + + [Fact] + public async Task A_menu_with_nothing_that_can_run_is_hidden_and_the_rest_close_up() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + string[] before = [], after = []; + var viewX = 0; + + await HostSteps.Run(host, + () => !Titles(workbench).Contains("Selection"), + () => + { + before = Titles(workbench); + viewX = Title(workbench, "View").Frame.X; + host.App.InjectKey(Key.S.WithAlt); + }, + () => { }, + () => Assert.False(workbench.MenuBar.IsOpen()), + () => host.App.InjectKey(Key.E.WithAlt), + () => workbench.MenuBar.IsOpen(), + () => host.App.InjectKey(Key.CursorRight), + () => Title(workbench, "View").PopoverMenuOpen, + () => host.App.InjectKey(Key.Esc), + () => !workbench.MenuBar.IsOpen(), + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor" && Titles(workbench).Contains("Selection"), + () => { after = Titles(workbench); }); + + Assert.DoesNotContain("Selection", before); + Assert.Contains("Selection", after); + Assert.Equal(Title(workbench, "Edit").Frame.Right, viewX); + Assert.Equal(Title(workbench, "Selection").Frame.Right, Title(workbench, "View").Frame.X); + } + + [Fact] + public async Task The_items_follow_where_the_menu_was_opened_from() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out var commands); + bool fromExplorer = true, fromEditor = false; + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor", + () => commands.TryExecute(CommandIds.FocusSidebar), + () => workbench.StatusBar.DisplayedFocus == "Explorer", + () => host.App.InjectKey(Key.E.WithAlt), + () => workbench.MenuBar.IsOpen(), + () => { fromExplorer = Item(host, CommandIds.MoveLinesDown).Enabled; host.App.InjectKey(Key.Esc); }, + () => !workbench.MenuBar.IsOpen() && workbench.StatusBar.DisplayedFocus == "Explorer", + () => commands.TryExecute(CommandIds.FocusEditorBody), + () => workbench.StatusBar.DisplayedFocus == "Editor", + () => host.App.InjectKey(Key.E.WithAlt), + () => workbench.MenuBar.IsOpen(), + () => { fromEditor = Item(host, CommandIds.MoveLinesDown).Enabled; host.App.InjectKey(Key.Esc); }, + () => !workbench.MenuBar.IsOpen()); + + Assert.False(fromExplorer); + Assert.True(fromEditor); + } + + [Fact] + public async Task Clicking_a_title_opens_it_clicking_an_item_runs_it_and_clicking_outside_closes_it() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => workbench.StatusBar.DisplayedFocus == "Editor", + Click(host, () => Title(workbench, "View").FrameToScreen().Location), + () => workbench.MenuBar.IsOpen(), + Click(host, () => new Point(workbench.FrameToScreen().Width - 2, workbench.FrameToScreen().Height - 3)), + () => !workbench.MenuBar.IsOpen(), + Click(host, () => Title(workbench, "View").FrameToScreen().Location), + () => workbench.MenuBar.IsOpen(), + Click(host, () => Item(host, CommandIds.ToggleSidebar).FrameToScreen().Location), + () => !workbench.MenuBar.IsOpen() && !workbench.IsSidebarVisible); + } + + // Running a command under a dialog would take the keys out from under it. + [Fact] + public async Task The_menu_stays_shut_while_a_dialog_is_open() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + + await HostSteps.Run(host, + () => host.App.InjectKey(Key.E.WithCtrl), + () => workbench.HasDialog, + Click(host, () => Title(workbench, "File").FrameToScreen().Location), + () => { }, + () => Assert.False(workbench.MenuBar.IsOpen()), + () => host.App.InjectKey(Key.Esc), + () => !workbench.HasDialog); + } + + [Fact] + public void Each_item_shows_its_current_key_and_follows_a_rebind() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + + Assert.Equal("Alt+↓", KeyShown(host, CommandIds.MoveLinesDown)); + Assert.Equal("Ctrl+G l", KeyShown(host, CommandIds.GoToLine)); + Assert.Equal("", KeyShown(host, CommandIds.ToggleSidebar)); + + host.ApplyKeybindings( + [ + new KeybindingOverride(TestKeys.Chord("Alt+CursorDown"), "-" + CommandIds.MoveLinesDown), + new KeybindingOverride(TestKeys.Chord("F7"), CommandIds.MoveLinesDown), + new KeybindingOverride(TestKeys.Chord("Ctrl+G L"), "-" + CommandIds.GoToLine), + ]); + + Assert.Equal("F7", KeyShown(host, CommandIds.MoveLinesDown)); + Assert.Equal("", KeyShown(host, CommandIds.GoToLine)); + } + + [Fact] + public void View_opens_with_the_sidebar_panels_by_name_then_toggle_widen_and_narrow() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + var view = Title(workbench, "View").PopoverMenu!.Root!.SubViews.ToList(); + + Assert.Equal(["Explorer", "Find", "Review", "-", "Toggle sidebar", "Widen sidebar", "Narrow sidebar"], + view.Take(7).Select(v => v is MenuItem item ? item.Title : "-")); + } + + [Fact] + public void Quit_stands_apart_from_Open_settings() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + var file = Title(workbench, "File").PopoverMenu!.Root!.SubViews.ToList(); + var quit = file.IndexOf(Item(host, CommandIds.Quit)); + + Assert.IsType(file[quit - 1]); + Assert.Same(Item(host, CommandIds.OpenSettings), file[quit - 2]); + } + + [Fact] + public void The_bar_takes_the_themes_menu_colours_and_stands_apart_from_the_explorer() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out _); + ConfigurationManager.Enable(ConfigLocations.None); + try + { + ConfigurationManager.RuntimeConfig = BundledThemes.Config; + ConfigurationManager.Load(ConfigLocations.LibraryResources | ConfigLocations.Runtime); + var drawn = new List(); + foreach (var theme in BundledThemes.Names) + { + ThemeManager.Theme = theme; + ConfigurationManager.Apply(); + var menu = SchemeManager.GetScheme(Schemes.Menu).GetAttributeForRole(VisualRole.Normal, null); + var sidebar = SchemeManager.GetScheme("Sidebar").GetAttributeForRole(VisualRole.Normal, null); + Assert.Equal(menu, workbench.MenuBar.GetAttributeForRole(VisualRole.Normal)); + Assert.NotEqual(sidebar.Background, menu.Background); + drawn.Add(menu); + } + Assert.True(drawn.Distinct().Count() > 1); + } + finally + { + ThemeManager.Theme = "Default"; + ConfigurationManager.Disable(resetToHardCodedDefaults: true); + } + } + + private static IEnumerable MenuIds() => + CommandMenu.Layout.SelectMany(menu => menu.Ids).Where(id => id != CommandMenu.Separator); + + private static string? Focused(Workbench.Workbench workbench) => + workbench.MenuBar.SubViews.OfType().FirstOrDefault(m => m.PopoverMenuOpen)?.PopoverMenu?.Root?.Focused is MenuItem item + ? item.Title + : null; + + private static string[] Titles(Workbench.Workbench workbench) => + [.. workbench.MenuBar.SubViews.OfType().Select(m => m.Title.Replace("_", ""))]; + + private static MenuBarItem Title(Workbench.Workbench workbench, string title) => + workbench.MenuBar.SubViews.OfType().Single(m => m.Title.Replace("_", "") == title); + + private static MenuItem Item(WorkbenchHost host, string id) => host.Menu.Items.Single(i => i.Id == id).Item; + + private static string KeyShown(WorkbenchHost host, string id) => Item(host, id).KeyView.Text; + + private static Action Click(WorkbenchHost host, Func at) => () => + { + var point = at(); + host.App.InjectMouse(new Mouse { Flags = MouseFlags.LeftButtonPressed, ScreenPosition = point }); + host.App.InjectMouse(new Mouse { Flags = MouseFlags.LeftButtonReleased, ScreenPosition = point }); + }; + + private Workbench.Workbench BuildWorkbench(string root = "/work") + { + var sidebar = new SidebarPart(new FileExplorerView(), review: new ReviewView(_git, _gitHub)); + var workbench = new Workbench.Workbench(sidebar, new EditorPart(), new StatusBarPart()); + workbench.Sidebar.Explorer.Open(_fs.DirectoryInfo.New(root)); + return workbench; + } + + private WorkbenchHost BuildHost(Workbench.Workbench workbench, out CommandService commands) + { + commands = new CommandService(); + return new WorkbenchHost(workbench, commands, new KeybindingService(commands), new InputScopeStack(), + new InMemorySettingsService(), driverName: DriverRegistry.Names.ANSI, git: _git, gitHub: _gitHub); + } +} diff --git a/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs b/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs index 4e42f65..c12b64d 100644 --- a/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs +++ b/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs @@ -347,7 +347,7 @@ public void Every_command_that_keeps_no_scope_is_one_whose_key_belongs_in_every_ CommandIds.OpenSettings, CommandIds.PreviousEditor, CommandIds.PullRequestOverview, CommandIds.Quit, CommandIds.RefreshExplorer, CommandIds.ReplaceGlobally, CommandIds.ReplaceInFile, CommandIds.SaveActiveEditor, CommandIds.ShowAbout, CommandIds.ShowActions, CommandIds.ShowDiagnostics, - CommandIds.ShowExplorer, CommandIds.ShowHelp, CommandIds.ShowMnemonics, + CommandIds.ShowExplorer, CommandIds.ShowHelp, CommandIds.ShowMenu, CommandIds.ShowMnemonics, CommandIds.SubmitReview, CommandIds.ToggleGutter, CommandIds.ToggleSidebar, CommandIds.WidenSidebar, .. Enumerable.Range(1, 9).Select(CommandIds.FocusEditorByIndex), ];