diff --git a/AGENTS.md b/AGENTS.md index 0b61e54d..6cf59613 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,7 @@ What follows is how TuiCode implements it today. - F1 (*Show keys*) opens the library's `KeysDialog`, titled *Help*, over a `KeySheet` TuiCode builds itself (`HelpSheet`), since the library's `KeySheet.For` reads its own command registry, not ours. It's hosted like any other modal: added to the workbench, its scope pushed through `LibraryScope`, which adapts the library's `IInputScope` to our stack. It closes on `Esc` only, and swallows every other key while it's open. - **Everywhere** is every `Global` command that has a key and can run now (`IsEnabled`, as the palette filters), read from the live bindings when F1 opens, under its `CommandMenu.Layout` heading in menu order, then *Other* for bound commands with no menu item. A command with several keys is one row (`Ctrl+O, F5`). It shows the command's label, not its menu title. Editor-scoped commands are the editor column's, never Everywhere's. A key the place column already lists isn't repeated in Everywhere, so the find options, sidebar tab keys and tab-strip keys show once, beside the place. *Find in file* and *Replace in file* are listed only while there's a file to search (`CanRunNow`): with no tab their keys open the Find pane, which *Find globally* and *Replace globally* already list. - The place column lists the keys of the place you pressed F1 (#406), captured in `WorkbenchHost.OpenHelp` before the dialog's scope is pushed. A diff has its own hand-picked rows (`DiffHelp`); the Explorer, the Find pane and the editor list every command in their scope (`ScopeHelp`), after a fixed list of keys that aren't commands (`ExplorerKeys`, `FindResultKeys`). The rows come from the live bindings and leave out an unbound or disabled command, as the palette does. The find bar has its own (`FindController.Help`), whose keys aren't rebindable, listing those of the field the keys were in. Review (`ReviewHelp`) and the tab strip (`TabsHelp`) list their fixed keys, then the commands among theirs that are bound; a new Review or tab-strip key adds a row to its column. The Usages tab has only fixed keys (`UsagesKeys`). A sidebar tab's column ends with the keys that show the other sidebar tabs and `Ctrl+PgUp`/`Ctrl+PgDn` (`SidebarTabKeys`), and a panel tab's with the panel's (`PanelTabKeys`). A place with none shows Everywhere alone. The library lays them out: side by side, Everywhere first; stacked with the place on top below the width both need; scrolling with `↑ ↓ PgUp PgDn` when taller than the screen. -- The status bar always ends with `Press F1 for help` (`StatusBarPart.SetHelpKey`), from the live `ShowHelp` binding (`WorkbenchHost.ApplyKeybindings`), so a rebind updates it and an unbound key leaves nothing there. The cursor position sits just before it, and the message on the left never replaces it; the default message is just `TuiCode`. +- The status bar always ends with `Press F1 for help` (`StatusBarPart.SetHelpKey`), from the live `ShowHelp` binding (`WorkbenchHost.ApplyKeybindings`), so a rebind updates it and an unbound key leaves nothing there. The cursor position sits just before it, and the message on the left never replaces it; the resting text with no tab open is just `TuiCode`. ## Find & search (#33) @@ -238,7 +238,7 @@ What follows is how TuiCode implements it today. - **User grammar packages** live in `~/.tui/grammars//`, in VS Code extension layout: a `package.json` whose `contributes.languages` / `contributes.grammars` declare them, plus the JSON grammar files it points at. Copying a VS Code extension's folder works as-is (checked with Even Better TOML). `GrammarBundle.Load(fs, dir)` reads them through `IFileSystem` at startup (a restart picks up changes) **before** the bundled packages, and registration is first-wins, so a user package overrides a bundled grammar scope, language id or extension. Only JSON grammars load (TextMateSharp can't read plist `.tmLanguage` or YAML); `injectTo` grammars register but aren't injected, as with bundled ones. Anything unusable — no `package.json`, bad JSON or wrong types, a missing or non-JSON grammar — is skipped and described in `GrammarBundle.Problems`, which the Grammars settings pane shows (the first problem, and how many more). A user grammar is untrusted input to TextMateSharp, which throws for broken ones either on load (e.g. invalid JSON) or on the first lexed line (e.g. an invalid regex): `SyntaxHighlighter.CreateCache` and `LineTokenCache` catch both and leave the file plain rather than crash a draw. - **Settings → Grammars** (`GrammarAssociationsView`) lists every association, built-in and user, filtered by pattern or grammar name. Enter opens `GrammarPickerView` for the row, Delete resets it to the default, and a filter that's a new pattern gets an `Add "…"` row first — for a bare word only when nothing else matches, since "Python" is likelier a grammar search than a file name. The row logic is the TG-free `GrammarAssociationRows`. Edits apply on Save: `WorkbenchHost.ApplyGrammarAssociations` updates the highlighter and re-infers every open tab. - **`cg`** (Change grammar) opens the same picker for the active tab; `EditorTab.SetGrammar` pins that tab so later association changes leave it alone (`InferGrammar` skips it). It deliberately has **no default keybinding** — users who want one bind it themselves. -- The status bar shows the active tab's grammar after its path (`path • C#`, or `Plain Text`). `Workbench` refreshes both on `ActiveTabChanged` and `EditorGroup.GrammarChanged`, so switching tabs now also resets the message to that tab's path. +- The status bar shows the active tab's grammar at its right end (`C#`, or `Plain Text`). `Workbench` refreshes it and the resting path on `ActiveTabChanged` and `EditorGroup.GrammarChanged`. - `EditorDrawBenchmarkTests.Syntax_colouring_keeps_drawing_a_large_file_fast` (Explicit): on a 50k-line C# file a frame costs the same ~14 ms with colours as without. ## About dialog (#107) @@ -421,10 +421,11 @@ What follows is how TuiCode implements it today. ## Wiring - `Program.cs` is the only DI consumer. -- `Workbench` (the root `Window`) wires cross-part events in its ctor: `explorer.FileActivated → editor.Open + tab.FocusContent + statusBar.SetMessage`, `editor.FileSaved → statusBar.SetMessage`. Add new cross-part wiring here. +- **Status messages (#592)** follow the style guide's rule through the library's `AppStatusBar`, which `StatusBarPart` hosts as `Bar`. Pick the kind at the call site: `ShowMessage` for a reply to what the user just did (goes at their next key or after 5 s), `ShowNews` for something that happened by itself (5 s; an error stays until a key after that), and `Severity.Error` for a failure, which never times out. Keep the `StatusMessage` news returns and `Withdraw` it when it stops being true (language server back, review refresh worked, cut cleared). Between messages the bar rests on the active tab's path (`StatusBarPart.SetResting`). `WorkbenchHost.OnAppKeyDown` calls `KeyPressed` after handling, skipping chord keys and keys whose command said something. *Show messages* (`sm`, Help menu, or clicking `⚠ n`) opens the library's `MessagesDialog` and clears `⚠ n`. +- `Workbench` (the root `Window`) wires cross-part events in its ctor: `explorer.FileActivated → editor.Open + tab.FocusContent`, `editor.FileSaved → statusBar.ShowMessage`. Add new cross-part wiring here. - `WorkbenchHost` owns `IApplication`, the key intercept, and workbench-scoped command/keybinding registrations. - Parts (`SidebarPart` / `EditorPart` / `StatusBarPart`) are thin layout slots over feature views. -- Logging is `Microsoft.Extensions.Logging`, with one provider, `LogFile` (#473). **Warning and above go to `~/.tui/logs/TuiCode.log` and count towards the status bar's `⚠ n`; Information and Debug aren't written anywhere.** So log a Warning only for something the user should know went wrong, naming the file and the reason. Each session starts a fresh log and keeps the last as `TuiCode.1.log`; the first write starts it, as does `Program.cs` before the UI. `sl` (*Show log*) opens it as an ordinary tab and clears the count, as does switching to that tab or clicking `⚠ n`; the host polls `LogFile.Unseen` every iteration, since warnings come from watcher threads too. The status bar is never used for diagnostics, with one exception: a `TuiCode.settings.json` that doesn't parse says so once at startup (`WorkbenchHost.SettingsErrorMessage`). A crash out of the main loop is logged, then the terminal restored, then the log's path printed (`Crash.Report`). +- Logging is `Microsoft.Extensions.Logging`, with one provider, `LogFile` (#473). **Warning and above go to `~/.tui/logs/TuiCode.log`, and into Messages and the status bar's `⚠ n` without showing on the bar; Information and Debug aren't written anywhere.** So log a Warning only for something the user should know went wrong, naming the file and the reason. Each session starts a fresh log and keeps the last as `TuiCode.1.log`; the first write starts it, as does `Program.cs` before the UI. `sl` (*Show log*) opens it as an ordinary tab. The host drains `LogFile.TakeWarnings` into `AppStatusBar.Record` every iteration, since warnings come from watcher threads too. The status bar is never used for diagnostics, with one exception: a `TuiCode.settings.json` that doesn't parse says so once at startup (`WorkbenchHost.SettingsErrorMessage`). A crash out of the main loop is logged, then the terminal restored, then the log's path printed (`Crash.Report`). ## Navigation @@ -454,7 +455,7 @@ What follows is how TuiCode implements it today. - `Languages/` is a hand-rolled LSP client: StreamJsonRpc is only partly AOT-safe. `JsonRpcConnection` is JSON-RPC over `Content-Length` framing, symmetric so the same class is the fake server in tests and in `--smoke-language-server`, which CI runs on every RID's AOT binary. Build JSON with `JsonNode`, and a `JsonArray` with its `params` constructor: a collection expression goes through the generic `Add`, which only the AOT publish warns about. - `LanguageServers` (owned by the host, read by `Workbench.Languages`) runs one `LanguageServer` per language for the open folder, or the file's own folder with none open. It starts on the first tab of that language, and stops on `Workbench.OpenFolder` and quit (`shutdown`, `exit`, then a kill after a second). A server that exits isn't restarted until the folder changes. Tabs are kept in step with full-text `didChange`s, debounced `ChangeDelay` and flushed before every request. -- Ready means initialized with no work-done progress in flight: csharp-ls reports loading the solution as progress. A server that reports none is ready after `QuietLoad`. The status bar shows the state after the language (`C# ◌ loading`, `C# ● ready`, `C# stopped`), and the install command in the message slot while the server isn't on `PATH`. +- Ready means initialized with no work-done progress in flight: csharp-ls reports loading the solution as progress. A server that reports none is ready after `QuietLoad`. The status bar shows the state after the language (`C# ◌ loading`, `C# ● ready`, `C# stopped`), and its trouble as news (`Workbench.LanguageTrouble`): the install command while the server isn't on `PATH`, or `… language server stopped: …` as an error. Each shows once and is withdrawn when the server recovers. - **Settings › Language Servers** (#456, `LanguageServersView`) lists every highlighted language with its server and whether `CommandPath` finds it on `PATH`. Enter opens `LanguageServerDialog`: None, the language's `KnownLanguageServers`, or Custom, over the TG-free `LanguageServerForm`. Choices persist in `TuiCode.settings.json` under `LanguageServers`, keyed by grammar id, only where they differ from the default (`csharp-ls` for C#, none elsewhere). On Save, `LanguageServers.Configure` stops only the languages whose server changed and starts the new one for their open tabs. A server is picked by the tab's grammar, so `cg` changes it too. - Host tests pass a `FakeLanguageServer` as the host's `languageServers`; with none (the default) no server ever starts, so other tests opening `.cs` files are unaffected. diff --git a/src/TuiCode.Abstractions/CommandIds.cs b/src/TuiCode.Abstractions/CommandIds.cs index 06c0108d..60729a83 100644 --- a/src/TuiCode.Abstractions/CommandIds.cs +++ b/src/TuiCode.Abstractions/CommandIds.cs @@ -46,6 +46,7 @@ public static class CommandIds public const string ShowHelp = "workbench.action.showHelp"; public const string ShowDiagnostics = "workbench.action.showDiagnostics"; public const string ShowLog = "workbench.action.showLog"; + public const string ShowMessages = "workbench.action.showMessages"; public const string ShowAbout = "workbench.action.showAbout"; public const string WatchTutorials = "workbench.action.watchTutorials"; public const string ShowDocumentInfo = "workbench.action.showDocumentInfo"; diff --git a/src/TuiCode.Abstractions/CommandMnemonics.cs b/src/TuiCode.Abstractions/CommandMnemonics.cs index be893cba..c938822b 100644 --- a/src/TuiCode.Abstractions/CommandMnemonics.cs +++ b/src/TuiCode.Abstractions/CommandMnemonics.cs @@ -39,6 +39,7 @@ private static IReadOnlyDictionary Build() [CommandIds.SaveAll] = "saf", [CommandIds.ShowDiagnostics] = "sd", [CommandIds.ShowLog] = "sl", + [CommandIds.ShowMessages] = "sm", [CommandIds.ShowAbout] = "tui", [CommandIds.WatchTutorials] = "wt", [CommandIds.New] = "nf", diff --git a/src/TuiCode.Workbench/Logging/LogFile.cs b/src/TuiCode.Workbench/Logging/LogFile.cs index 23d98bfb..f8688ade 100644 --- a/src/TuiCode.Workbench/Logging/LogFile.cs +++ b/src/TuiCode.Workbench/Logging/LogFile.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Globalization; using System.Text; using Microsoft.Extensions.Logging; @@ -6,7 +7,7 @@ namespace TuiCode.Workbench.Logging; /// /// Writes Warning and above to ~/.tui/logs/TuiCode.log, one file per session with the previous kept as -/// TuiCode.1.log (#473), and counts the warnings written since the log was last opened. +/// TuiCode.1.log (#473), and keeps each warning for the status bar's Messages. /// public sealed class LogFile : ILoggerProvider { @@ -18,7 +19,7 @@ public sealed class LogFile : ILoggerProvider private readonly Func _now; private readonly Lock _lock = new(); private bool _started; - private int _unseen; + private readonly ConcurrentQueue _warnings = new(); public LogFile(IFileSystem fs, string directory, Func? now = null) { @@ -45,10 +46,13 @@ public string DisplayPath } } - /// Warnings written since the log was last opened. - public int Unseen => Volatile.Read(ref _unseen); - - public void MarkSeen() => Interlocked.Exchange(ref _unseen, 0); + /// The warnings written since the last call, oldest first. + public IReadOnlyList TakeWarnings() + { + var taken = new List(); + while (_warnings.TryDequeue(out var warning)) taken.Add(warning); + return taken; + } /// Start this session's log, keeping the last one as . The first write starts it too. public void Start() @@ -84,7 +88,7 @@ public void Write(LogLevel level, string message, Exception? exception) { } } - Interlocked.Increment(ref _unseen); + _warnings.Enqueue(exception is null ? message : $"{message}: {exception.Message}"); } internal static string Format(DateTimeOffset time, LogLevel level, string message, Exception? exception) diff --git a/src/TuiCode.Workbench/Menus/CommandMenu.cs b/src/TuiCode.Workbench/Menus/CommandMenu.cs index 3ebb39bc..70040e22 100644 --- a/src/TuiCode.Workbench/Menus/CommandMenu.cs +++ b/src/TuiCode.Workbench/Menus/CommandMenu.cs @@ -66,7 +66,7 @@ .. Enumerable.Range(1, 9).Select(CommandIds.FocusEditorByIndex), [CommandIds.ToggleViewed] = 'v', [CommandIds.SubmitReview] = 's', [CommandIds.ShowHelp] = 'k', [CommandIds.ShowActions] = 's', [CommandIds.ShowMnemonics] = 'm', - [CommandIds.WatchTutorials] = 't', [CommandIds.ShowDiagnostics] = 'd', [CommandIds.ShowLog] = 'l', [CommandIds.ShowAbout] = 'a', + [CommandIds.WatchTutorials] = 't', [CommandIds.ShowDiagnostics] = 'd', [CommandIds.ShowLog] = 'l', [CommandIds.ShowMessages] = 'e', [CommandIds.ShowAbout] = 'a', }; public static readonly (string Title, string[] Ids)[] Layout = @@ -143,7 +143,7 @@ public static readonly (string Title, string[] Ids)[] Layout = [ CommandIds.ShowHelp, CommandIds.ShowActions, CommandIds.ShowMnemonics, Separator, - CommandIds.WatchTutorials, CommandIds.ShowDiagnostics, CommandIds.ShowLog, CommandIds.ShowAbout, + CommandIds.WatchTutorials, CommandIds.ShowDiagnostics, CommandIds.ShowLog, CommandIds.ShowMessages, CommandIds.ShowAbout, ]), ]; diff --git a/src/TuiCode.Workbench/Parts/StatusBarPart.cs b/src/TuiCode.Workbench/Parts/StatusBarPart.cs index 2cfcc247..bb60faf0 100644 --- a/src/TuiCode.Workbench/Parts/StatusBarPart.cs +++ b/src/TuiCode.Workbench/Parts/StatusBarPart.cs @@ -1,165 +1,115 @@ -using System.Text; -using Terminal.Gui.Text; -using TuiCode.Abstractions; +using MentalDesk.Tui; +using MentalDesk.Tui.Chrome; namespace TuiCode.Workbench.Parts; +/// The library's , fed the active file's path, grammar, modes, diff status and position. public sealed class StatusBarPart : View { - private readonly VerbatimLabel _label; - private readonly VerbatimLabel _position; - private readonly VerbatimLabel _help; - private readonly VerbatimLabel _focus; - private readonly Button _warnings; - private int _unseenWarnings; - private string _region = string.Empty; - private string _message; - private string? _chord; + private string? _help; private string? _grammar; private string? _diff; private string? _wrap; private string? _mode; private (int Row, int Column)? _cursor; private (int Carets, int? Selected) _selection = (1, null); + private string _position = string.Empty; public StatusBarPart() { - _message = DefaultMessage; Height = 1; CanFocus = false; SchemeName = "StatusBar"; - - _help = new VerbatimLabel { X = Pos.AnchorEnd() - 1, Y = 0, Text = HelpHintFor("F1") }; - _position = new VerbatimLabel { Y = 0 }; - PlacePosition(); - _focus = new VerbatimLabel { X = 1, Y = 0 }; - _warnings = new Button - { - X = Pos.Left(_position), - Y = 0, - Visible = false, - CanFocus = false, - NoDecorations = true, - NoPadding = true, - ShadowStyle = ShadowStyles.None, - HotKeySpecifier = (Rune)0xffff, - MouseHighlightStates = MouseState.None, - }; - _warnings.Accepting += (_, e) => - { - e.Handled = true; - WarningsClicked?.Invoke(this, EventArgs.Empty); - }; - _warnings.GettingAttributeForRole += (_, e) => - { - e.Result = WarningColour.On(e.Result ?? GetAttributeForRole(e.Role)); - e.Handled = true; - }; - _label = new VerbatimLabel - { - X = Pos.Right(_focus), - Y = 0, - Width = Dim.Fill(2, _warnings), - Text = _message - }; - Add(_focus, _label, _warnings, _position, _help); + Bar = new AppStatusBar { Y = 0, RestingText = DefaultMessage }; + Add(Bar); + SetHelpKey("F1"); } - /// The ⚠ n item was clicked. - public event EventHandler? WarningsClicked; + public AppStatusBar Bar { get; } - /// Warnings logged since the log was last opened, shown as ⚠ n before the position; 0 hides it. - public void SetWarnings(int unseen) + /// The ⚠ n item was clicked. + public event EventHandler? MessagesRequested { - if (unseen == _unseenWarnings) return; - _unseenWarnings = unseen; - _warnings.Text = unseen > 0 ? $"⚠ {unseen}" : string.Empty; - _warnings.Visible = unseen > 0; - _warnings.X = Pos.Left(_position) - (unseen > 0 ? _warnings.Text.GetColumns(false) + 3 : 0); + add => Bar.MessagesRequested += value; + remove => Bar.MessagesRequested -= value; } - internal string DisplayedWarnings => _warnings.Visible ? _warnings.Text : string.Empty; + internal string DisplayedWarnings => Bar.UnreadButton.Visible ? Bar.UnreadButton.Text : string.Empty; - internal View WarningsView => _warnings; + internal View WarningsView => Bar.UnreadButton; /// The one word for where keys will go, shown at the far left; always present (#227). public void SetFocusRegion(string region) { - if (region == _region) return; - _region = region; - _focus.Text = $"{region} • "; + if (region != Bar.FocusWord) Bar.SetFocusWord(region); } - internal string DisplayedFocus => _region; + internal string DisplayedFocus => Bar.FocusWord; - public void SetMessage(string message) - { - _message = message; - _label.SchemeName = null; - UpdateLabel(); - } + /// A reply to what the user just did: it goes at their next key, or after five seconds. + public StatusMessage ShowMessage(string message, Severity severity = Severity.Info) => Bar.ShowMessage(message, severity); - /// Show in the error colours until the next message replaces it. - public void SetError(string message) + /// Something that happened by itself: it stays five seconds, or for an error until a key after that. + public StatusMessage ShowNews(string message, Severity severity = Severity.Info) => Bar.ShowNews(message, severity); + + public void Withdraw(StatusMessage? message) { - SetMessage(message); - _label.SchemeName = "Error"; + if (message is not null) Bar.Withdraw(message); } - internal string Message => _message; + /// What the bar shows while no message is: the active file's path, or . + public void SetResting(string? text) => Bar.RestingText = text ?? DefaultMessage; + + /// The message showing, or the resting text when none is. + internal string Message => Bar.Message ?? Bar.RestingText; - /// What the bar says when nothing else has. public string DefaultMessage => "TuiCode"; /// Ends the bar with Press {key} for help; null leaves nothing there. public void SetHelpKey(string? key) { - _help.Text = HelpHintFor(key); - PlacePosition(); + _help = key is null ? null : $"Press {key} for help"; + ShowState(); } - private static string HelpHintFor(string? key) => key is null ? string.Empty : $"Press {key} for help"; - - internal string DisplayedHelp => _help.Text; + internal string DisplayedHelp => _help ?? string.Empty; - internal bool ShowsError => _label.SchemeName == "Error"; + internal bool ShowsError => Bar.MessageSeverity == Severity.Error; - /// Revert to the default message, unless something else has replaced since. - public void ClearMessage(string message) - { - if (_message == message) SetMessage(DefaultMessage); - } - - /// Where the focused diff tab is, shown after the message; null hides it. + /// Where the focused diff tab is; null hides it. public void SetDiffStatus(string? status) { if (status == _diff) return; _diff = status; - UpdateLabel(); + ShowState(); } - /// The active file's grammar, shown after the message; null hides it. + /// The active file's grammar; null hides it. public void SetGrammar(string? grammar) { + if (grammar == _grammar) return; _grammar = grammar; - UpdateLabel(); + ShowState(); } /// Whether the active tab wraps long lines, shown as Wrap after the grammar. public void SetWrap(bool wraps) { - _wrap = wraps ? "Wrap" : null; - UpdateLabel(); + var wrap = wraps ? "Wrap" : null; + if (wrap == _wrap) return; + _wrap = wrap; + ShowState(); } /// An editor mode to flag after the grammar, e.g. column select; null hides it. public void SetMode(string? mode) { + if (mode == _mode) return; _mode = mode; - UpdateLabel(); + ShowState(); } - /// The active cursor's zero-based position, shown 1-based at the right; null hides it. + /// The active cursor's zero-based position, shown 1-based; null hides it. public void SetPosition((int Row, int Column)? position) { if (position == _cursor) return; @@ -177,33 +127,31 @@ public void SetSelection(int carets, int? selected) private void ShowPosition() { - if (_cursor is not var (row, column)) + var position = string.Empty; + if (_cursor is var (row, column)) { - _position.Text = string.Empty; - PlacePosition(); - return; + var (carets, selected) = _selection; + var where = carets > 1 ? $"{carets} selections" : $"Ln {row + 1}, Col {column + 1}"; + position = selected is { } count ? $"{where} ({count:N0} selected)" : where; } - var (carets, selected) = _selection; - var where = carets > 1 ? $"{carets} selections" : $"Ln {row + 1}, Col {column + 1}"; - _position.Text = selected is { } count ? $"{where} ({count:N0} selected)" : where; - PlacePosition(); + if (position == _position) return; + _position = position; + ShowState(); } - private void PlacePosition() => - _position.X = Pos.Left(_help) - _position.Text.GetColumns(false) - (_help.Text.Length > 0 && _position.Text.Length > 0 ? 3 : 0); + internal string DisplayedPosition => _position; - internal string DisplayedText => _label.Text; + /// The chord, message or resting text at the left. + internal string DisplayedText => Bar.Says; - internal string DisplayedPosition => _position.Text; + /// What sits together at the right end, before the help hint. + internal string DisplayedState => string.Join(HintRow.Separator, StateParts()); - public void SetChord(string? chord) - { - _chord = chord; - UpdateLabel(); - } + public void SetChord(string? chord) => Bar.SetChord(chord); + + private IEnumerable StateParts() => + new[] { _grammar, _mode, _wrap, _diff, _position }.Where(part => !string.IsNullOrEmpty(part)).OfType(); - private void UpdateLabel() => - _label.Text = _chord is not null - ? $"{_chord}…" - : string.Join(" • ", new[] { _message, _diff, _grammar, _mode, _wrap }.Where(part => part is not null)); + private void ShowState() => + Bar.Hints.Show(StateParts().Append(_help).OfType().Select(text => (text, (Action?)null))); } diff --git a/src/TuiCode.Workbench/TuiCode.Workbench.csproj b/src/TuiCode.Workbench/TuiCode.Workbench.csproj index b49133f6..e5d9385a 100644 --- a/src/TuiCode.Workbench/TuiCode.Workbench.csproj +++ b/src/TuiCode.Workbench/TuiCode.Workbench.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/TuiCode.Workbench/Workbench.cs b/src/TuiCode.Workbench/Workbench.cs index 47544067..5a38b782 100644 --- a/src/TuiCode.Workbench/Workbench.cs +++ b/src/TuiCode.Workbench/Workbench.cs @@ -1,3 +1,5 @@ +using MentalDesk.Tui; +using MentalDesk.Tui.Chrome; using TuiCode.Abstractions; using TuiCode.Editor; using TuiCode.Workbench.Configuration; @@ -92,14 +94,19 @@ public Workbench(SidebarPart sidebar, EditorPart editor, StatusBarPart statusBar sidebar.Search.RootProvider = () => sidebar.Explorer.Root; sidebar.Search.OpenBuffers = new EditorBuffers(editor.Groups); sidebar.Search.MatchActivated += (_, hit) => OpenMatch(hit.File, hit.Match); - sidebar.Search.Message += (_, message) => statusBar.SetMessage(message); + sidebar.Search.Message += (_, message) => statusBar.ShowMessage(message); sidebar.Review.RootProvider = () => sidebar.Explorer.Root; - sidebar.Review.RefreshFailed += (_, error) => statusBar.SetMessage($"Couldn't refresh the review: {error}"); + sidebar.Review.RefreshFailed += (_, error) => + { + statusBar.Withdraw(_reviewFailure); + _reviewFailure = statusBar.ShowNews($"Couldn't refresh the review: {error}", Severity.Error); + }; + sidebar.Review.Refreshed += (_, _) => statusBar.Withdraw(_reviewFailure); editor.FileSaved += (_, file) => { - statusBar.SetMessage($"Saved: {file.FullName}"); + statusBar.ShowMessage($"Saved: {file.FullName}"); RefreshReviewIfShowing(); }; @@ -133,6 +140,9 @@ public Workbench(SidebarPart sidebar, EditorPart editor, StatusBarPart statusBar public UnsavedEdits? UnsavedEdits { get; } private int _recovered; + private StatusMessage? _reviewFailure; + private StatusMessage? _languageTrouble; + private LanguageServer? _troubledServer; // Tabs come back one by one, so the count waits for the loop to finish opening them. private void OnRecovered() @@ -140,7 +150,7 @@ private void OnRecovered() if (_recovered++ > 0 || App is not { } app) return; app.AddTimeout(TimeSpan.Zero, () => { - StatusBar.SetMessage($"⟲ Recovered unsaved edits in {Plural.Count(_recovered, "file")}"); + StatusBar.ShowNews($"⟲ Recovered unsaved edits in {Plural.Count(_recovered, "file")}"); _recovered = 0; return false; }); @@ -162,16 +172,24 @@ private void OnLanguageServerChanged(object? sender, EventArgs e) { var tab = Editor.Group.ActiveTab; ShowLanguage(tab); - if (tab is not null && Languages?.ServerFor(tab) is { State: LanguageServerState.Missing or LanguageServerState.Stopped }) - StatusBar.SetMessage(FileMessage(tab)); + ShowLanguageTrouble(tab); + } + + // Once per server: a restarted server is a new one, and takes the old one's trouble off. + private void ShowLanguageTrouble(EditorTab? tab) + { + if (tab is null || Languages?.ServerFor(tab) is not { } server || server == _troubledServer) return; + var trouble = LanguageTrouble(server); + if (trouble is null && server.Spec.LanguageId != _troubledServer?.Spec.LanguageId) return; + StatusBar.Withdraw(_languageTrouble); + (_troubledServer, _languageTrouble) = trouble is var (text, severity) ? (server, StatusBar.ShowNews(text, severity)) : (null, null); } private void ShowActiveFile(EditorTab? tab) { - if (tab is not null) StatusBar.SetMessage(FileMessage(tab)); - else if (Editor.Group.ActiveDiffTab is { } diff) StatusBar.SetMessage(diff.Title); - else if (Editor.Group.ActiveDocumentTab is { } document) StatusBar.SetMessage(document.Title); + StatusBar.SetResting(tab?.File.FullName ?? Editor.Group.ActiveDiffTab?.Title ?? Editor.Group.ActiveDocumentTab?.Title); ShowLanguage(tab); + ShowLanguageTrouble(tab); StatusBar.SetWrap(tab is { WordWrap: true }); } @@ -192,32 +210,24 @@ private void ShowLanguage(EditorTab? tab) }); } - /// The file's path, or what its language server needs from the user. - private string FileMessage(EditorTab tab) => Languages?.ServerFor(tab) switch + /// What the language server needs from the user, if anything. + private static (string Text, Severity Severity)? LanguageTrouble(LanguageServer running) => running switch { - { State: LanguageServerState.Missing } server => server.Spec.NotInstalled, - { State: LanguageServerState.Stopped, Failure: { } failure } server => $"{server.Spec.Name} language server stopped: {failure}", - _ => tab.File.FullName, + { State: LanguageServerState.Missing } server => (server.Spec.NotInstalled, Severity.Info), + { State: LanguageServerState.Stopped, Failure: { } failure } server => ($"{server.Spec.Name} language server stopped: {failure}", Severity.Error), + _ => null, }; - /// How long a successful copy's message shows before the file path comes back. - internal TimeSpan CopyMessageDuration { get; set; } = TimeSpan.FromSeconds(4); - private void ShowCopy(CopyOutcome outcome) { if (outcome is CopyOutcome.Failed(var reason)) { - StatusBar.SetError($"Copy failed: {reason}"); + StatusBar.ShowMessage($"Copy failed: {reason}", Severity.Error); return; } var (lines, characters, throughTerminal) = (CopyOutcome.Copied)outcome; var message = $"Copied {Plural.Count(lines, "line")} • {Plural.Count(characters, "character")}{(throughTerminal ? " through the terminal" : "")}"; - StatusBar.SetMessage(message); - App?.AddTimeout(CopyMessageDuration, () => - { - if (StatusBar.Message == message) ShowActiveFile(Editor.Group.ActiveTab); - return false; - }); + StatusBar.ShowMessage(message); } /// Show the active tab's cursor position and selection; the host calls this every main-loop iteration. @@ -240,16 +250,14 @@ public void ShowCursorPosition() /// Open a file in the editor. Shared by the explorer and the Open dialog. public void OpenFile(IFileInfo file) { - var tab = Editor.Open(file); - StatusBar.SetMessage(FileMessage(tab)); + Editor.Open(file); FileOpened?.Invoke(this, EventArgs.Empty); } /// Reopens the most recently closed file tab where it was (#578). public void ReopenClosedTab() { - if (Editor.Groups.ReopenClosed() is not { } tab) return; - StatusBar.SetMessage(FileMessage(tab)); + if (Editor.Groups.ReopenClosed() is null) return; FileOpened?.Invoke(this, EventArgs.Empty); } @@ -259,7 +267,7 @@ public void OpenMatch(IFileInfo file, TextMatch match) var tab = Editor.Open(file); tab.Select(match); tab.RevealLines(match.Row, match.Row); - StatusBar.SetMessage($"{file.FullName}:{match.Row + 1}:{match.Column + 1}"); + StatusBar.ShowMessage($"{file.FullName}:{match.Row + 1}:{match.Column + 1}"); FileOpened?.Invoke(this, EventArgs.Empty); } @@ -309,7 +317,7 @@ public void OpenFolder(IDirectoryInfo directory, bool rememberTabs = true) Sidebar.Explorer.Open(directory); Sidebar.Search.RunSearch(); RefreshReviewIfShowing(); - StatusBar.SetMessage($"Opened folder: {directory.FullName}"); + StatusBar.ShowMessage($"Opened folder: {directory.FullName}"); if (!rememberTabs) return; RestoreOpenFiles(directory); @@ -350,7 +358,7 @@ private void AfterRemoving(IFileSystemInfo item, string message) { Editor.Groups.CloseUnder(item.FullName); Sidebar.Search.RunSearch(); - StatusBar.SetMessage(message); + StatusBar.ShowMessage(message); SaveWorkspaceState(); } @@ -363,7 +371,7 @@ public IFileSystemInfo Move(IFileSystemInfo item, string relativePath) Editor.Groups.Relocate(item.FullName, moved.FullName); ShowActiveFile(Editor.Group.ActiveTab); Sidebar.Search.RunSearch(); - StatusBar.SetMessage($"Renamed: {moved.FullName}"); + StatusBar.ShowMessage($"Renamed: {moved.FullName}"); SaveWorkspaceState(); return moved; } diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index 42da377a..f2e68849 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -1,6 +1,8 @@ using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using MentalDesk.Tui; +using MentalDesk.Tui.Chrome; using MentalDesk.Tui.Dialogs; using KeyColumn = MentalDesk.Tui.Keys.KeyColumn; using KeyRow = MentalDesk.Tui.Keys.KeyRow; @@ -98,6 +100,9 @@ public sealed class WorkbenchHost : IDisposable private KeysDialog? _activeHelp; private LibraryScope? _helpScope; private GoToLineView? _activeGoToLine; + private MessagesDialog? _activeMessages; + private StatusMessage? _cutMessage; + private StatusMessage? _answeringMessage; private SymbolPickerView? _activeSymbolPicker; private DefinitionPickerView? _activeDefinitionPicker; // Only the latest Go to definition jumps, however the answers come back. @@ -226,12 +231,12 @@ public WorkbenchHost( _diskChanges = new DiskChanges( _workbench.Editor.Groups, _workbench.Sidebar.Explorer, - _workbench.StatusBar.SetMessage, + message => _workbench.StatusBar.ShowNews(message), new DiskWatcher(fileSystem, ScheduleFlush, _logger)); _baselines = new CommittedBaselines( _workbench.Editor.Groups, _git, action => _app.Invoke(action), new HeadWatcher(fileSystem, ScheduleFlush, _logger)); _fileHistory = new FileHistoryPanel(_workbench, _git, action => _app.Invoke(action)); - _fileHistory.NothingToShow += (_, reason) => _workbench.StatusBar.SetMessage(reason); + _fileHistory.NothingToShow += (_, reason) => _workbench.StatusBar.ShowMessage(reason); _fileHistory.ShownOnRequest += (_, _) => MoveFocus(FocusRegion.History); _workbench.HistoryShowingChanged += (_, _) => { @@ -291,7 +296,7 @@ public WorkbenchHost( _workbench.Editor.Groups.CursorMoved += OnEditorCursorMoved; _workbench.Editor.Groups.PinnedLineClicked += (_, pinned) => JumpToLine(pinned.Tab, pinned.Line); _workbench.Editor.Groups.ActiveTabChanged += OnActiveTabChanged; - _workbench.StatusBar.WarningsClicked += (_, _) => ShowLog(); + _workbench.StatusBar.MessagesRequested += (_, _) => ShowMessages(); _workbench.Sidebar.Review.FileActivated += (_, e) => OpenReviewDiff(e.Review, e.Change); _workbench.Sidebar.Review.PullRequestActivated += (_, review) => OpenOverview(review); _workbench.Sidebar.Review.OutdatedThreadsActivated += (_, e) => OpenOutdatedThreads(e.Review, e.Node); @@ -473,7 +478,7 @@ void Open(object? sender, EventArgs e) { _app.Iteration -= Open; _workbench.OpenStartupTarget(target); - if (_settings.SettingsFileInvalid) _workbench.StatusBar.SetMessage(SettingsErrorMessage); + if (_settings.SettingsFileInvalid) _workbench.StatusBar.ShowNews(SettingsErrorMessage, Severity.Error); } } @@ -485,9 +490,14 @@ private void OnAppKeyDown(object? sender, Key key) // Diagnostics shows the raw key as delivered by the driver, so feed it the unnormalized key. _activeDiagnostics?.UpdateLastKey(key); + var shown = _workbench.StatusBar.Bar.Current; + var chording = _keybindings.CurrentChord is not null; // Look the binding up under the normalized chord, but consume the original event object so // the Win32 LF (0x0A) never falls through to the editor when a binding claimed it. var result = _scopes.Handle(NormalizeLegacyCtrlSlash(NormalizeWindowsCtrlEnter(key))); + // After handling, so a key whose command says something doesn't take its own message. + if (!chording && _keybindings.CurrentChord is null && _workbench.StatusBar.Bar.Current == shown) + _workbench.StatusBar.Bar.KeyPressed(); if (result != KeyHandlingResult.Pass) { key.Handled = true; @@ -671,7 +681,7 @@ private void RegisterDefaultCommands() _commands.Register(CommandIds.CancelCut, "Cancel cut", explorer.ClearCut, CommandScope.Explorer, () => explorer.PendingCut is not null); // No default key (#332). _commands.Register(CommandIds.RefreshExplorer, "Refresh explorer", RefreshExplorer, CommandScope.Global, () => explorer.Root is not null); - explorer.CutCleared += (_, item) => _workbench.StatusBar.ClearMessage(CutMessage(item)); + explorer.CutCleared += (_, _) => _workbench.StatusBar.Withdraw(_cutMessage); var search = _workbench.Sidebar.Search; // Find covers the whole pane, results list included; these keys belong to the query/replace inputs only. _commands.Register(CommandIds.SearchFocusResults, "Focus find results", () => search.FocusResults(), @@ -719,6 +729,7 @@ private void RegisterDefaultCommands() _commands.Register(CommandIds.NavigateForward, "Next cursor position", NavigateForward, CommandScope.Editor, FileOpen); _commands.Register(CommandIds.ShowDiagnostics, "Show diagnostics", OpenDiagnostics); _commands.Register(CommandIds.ShowLog, "Show log", ShowLog); + _commands.Register(CommandIds.ShowMessages, "Show messages", ShowMessages); _commands.Register(CommandIds.ShowAbout, "About TuiCode", OpenAbout); _commands.Register(CommandIds.WatchTutorials, "Watch tutorials", WatchTutorials); _commands.Register(CommandIds.ShowDocumentInfo, "Show document info", OpenDocumentInfo, CommandScope.Editor, FileOpen); @@ -1102,7 +1113,7 @@ private void NudgeSidebar(int columns) { if (!_workbench.IsSidebarVisible) { - _workbench.StatusBar.SetMessage($"Sidebar is hidden — use {CommandMnemonics.For(CommandIds.ToggleSidebar)} to show it"); + _workbench.StatusBar.ShowMessage($"Sidebar is hidden — use {CommandMnemonics.For(CommandIds.ToggleSidebar)} to show it"); return; } @@ -1113,7 +1124,7 @@ private void ReportSidebarWidth(int width) { _settings.SidebarWidth = width; _settings.Save(); - _workbench.StatusBar.SetMessage($"Sidebar width: {width}{LimitSuffix(width)}"); + _workbench.StatusBar.ShowMessage($"Sidebar width: {width}{LimitSuffix(width)}"); } private string LimitSuffix(int width) => @@ -1220,7 +1231,7 @@ private void OpenFind(bool replace) return; } var what = group.ActiveDiffTab is null ? "a document" : "a diff"; - _workbench.StatusBar.SetMessage(replace + _workbench.StatusBar.ShowMessage(replace ? $"Nothing to replace in {what} — Ctrl+H needs a file tab" : $"Nothing to find in {what} — Ctrl+F needs a file tab"); return; @@ -1233,7 +1244,7 @@ private void MoveToOtherGroup() { if (!_workbench.Editor.Groups.MoveActiveToOther()) { - _workbench.StatusBar.SetMessage("Open another file to split with"); + _workbench.StatusBar.ShowMessage("Open another file to split with"); return; } FocusEditorBody(); @@ -1257,7 +1268,7 @@ private void FocusEditorBody() => private bool MoveFocus(FocusRegion region) { if (_focus.Focus(region)) return true; - _workbench.StatusBar.SetMessage($"Nothing to focus in {FocusService.Label(region)}"); + _workbench.StatusBar.ShowMessage($"Nothing to focus in {FocusService.Label(region)}"); return false; } @@ -1294,7 +1305,7 @@ private void EditActiveTab(Action edit) private void ToggleLineComment(EditorTab tab) { if (!tab.ToggleLineComment()) - _workbench.StatusBar.SetMessage($"{tab.Grammar?.Name ?? Workbench.PlainTextName} has no comment syntax"); + _workbench.StatusBar.ShowMessage($"{tab.Grammar?.Name ?? Workbench.PlainTextName} has no comment syntax"); } private void FocusEditorTabStrip() @@ -1356,7 +1367,7 @@ private void OpenGrammarPicker() if (_workbench.Editor.Group.ActiveTab is not { } tab) return; if (!tab.HasSyntax || _workbench.Editor.Group.Syntax is not { } syntax) { - _workbench.StatusBar.SetMessage($"{tab.File.Name} has no grammar"); + _workbench.StatusBar.ShowMessage($"{tab.File.Name} has no grammar"); return; } @@ -1503,7 +1514,7 @@ private void GoToMatchingBracket() if (Group.ActiveTab is not { } tab) return; if (!tab.GoToMatchingBracket()) { - _workbench.StatusBar.SetMessage("No matching bracket"); + _workbench.StatusBar.ShowMessage("No matching bracket"); return; } _history.Follow(new CursorLocation(tab.File.FullName, tab.CursorRow, tab.CursorColumn)); @@ -1520,12 +1531,12 @@ private void OpenSymbolPicker() if (_activeSymbolPicker is not null) return; if (_workbench.Editor.Group.ActiveTab is not { } tab) { - _workbench.StatusBar.SetMessage("No file open"); + _workbench.StatusBar.ShowMessage("No file open"); return; } if (tab.ScanSymbols() is not { } scan) { - _workbench.StatusBar.SetMessage(SymbolPickerView.NoSymbols); + _workbench.StatusBar.ShowMessage(SymbolPickerView.NoSymbols); return; } @@ -1576,7 +1587,7 @@ private void GoToDefinition() server.DefinitionAsync(tab.File.FullName, tab.CursorRow, tab.CursorCharacter).ContinueWith(answer => _app.Invoke(() => { if (_disposed || request != _definitionRequest) return; - if (answer.Exception?.InnerException is { } error) status.SetMessage($"Go to definition failed: {error.Message}"); + if (answer.Exception?.InnerException is { } error) status.ShowMessage($"Go to definition failed: {error.Message}", Severity.Error); else ShowDefinitions(tab, symbol, answer.Result); }), TaskScheduler.Default); } @@ -1587,7 +1598,7 @@ private void GoToDefinition() var status = _workbench.StatusBar; if (ActiveLanguageServer() is not { } server) { - status.SetMessage(_workbench.Languages?.SpecFor(tab) is { } spec + status.ShowMessage(_workbench.Languages?.SpecFor(tab) is { } spec ? spec.NotInstalled : $"No language server for {(tab.HasSyntax ? tab.Grammar?.Name : null) ?? Workbench.PlainTextName}"); return null; @@ -1595,13 +1606,13 @@ private void GoToDefinition() switch (server.State) { case LanguageServerState.Missing: - status.SetMessage(server.Spec.NotInstalled); + status.ShowMessage(server.Spec.NotInstalled); return null; case LanguageServerState.Loading: - status.SetMessage($"The {server.Spec.Name} language server is still loading"); + status.ShowMessage($"The {server.Spec.Name} language server is still loading"); return null; case LanguageServerState.Stopped: - status.SetMessage($"The {server.Spec.Name} language server stopped"); + status.ShowMessage($"The {server.Spec.Name} language server stopped"); return null; } _workbench.Languages!.Flush(); @@ -1614,7 +1625,7 @@ private void FindUsages() if (_workbench.Editor.Group.ActiveTab is not { } tab || ReadyLanguageServer(tab) is not { } server) return; if (IdentifierAt(tab.Lines[tab.CursorRow], tab.CursorCharacter) is not { } symbol) { - _workbench.StatusBar.SetMessage("No symbol here to find usages of"); + _workbench.StatusBar.ShowMessage("No symbol here to find usages of"); return; } var (path, row, character) = (tab.File.FullName, tab.CursorRow, tab.CursorCharacter); @@ -1628,7 +1639,7 @@ private void FindUsages() if (answer.Exception?.InnerException is { } error) { pane.ShowFailed(symbol); - _workbench.StatusBar.SetMessage($"Find usages failed: {error.Message}"); + _workbench.StatusBar.ShowMessage($"Find usages failed: {error.Message}", Severity.Error); return; } var (name, usages) = answer.Result; @@ -1668,7 +1679,7 @@ private void ShowDefinitions(EditorTab origin, string? symbol, IReadOnlyList e) { _workbench.ShowCursorPosition(); - // Warnings can be logged from any thread, so the count is read here rather than pushed. - _workbench.StatusBar.SetWarnings(_log?.Unseen ?? 0); + // Warnings can be logged from any thread, so they're collected here rather than pushed. + foreach (var warning in _log?.TakeWarnings() ?? []) + _workbench.StatusBar.Bar.Record(warning, Severity.Error); // Picks up focus Terminal.Gui moved on its own, and any move that didn't land where it was asked to. Reconcile(); _activeSymbolPicker?.Advance(); @@ -1754,7 +1766,6 @@ private void OnEditorCursorMoved(object? sender, (IFileInfo File, int Row, int C private void OnActiveTabChanged(object? sender, TuiCode.Editor.EditorTab? tab) { _find.OnActiveTabChanged(); - if (_log is not null && tab?.File.FullName == _log.File.FullName) _log.MarkSeen(); if (_suppressHistory || tab is null) return; _history.Visit(new CursorLocation(tab.File.FullName, tab.CursorRow, tab.CursorColumn)); } @@ -1769,11 +1780,11 @@ private void FollowLink() if (_workbench.Editor.Group.ActiveTab is not { } tab || LinkUnderCursor() is not { } url) return; if (_environment.IsOverSsh) { - if (tab.CopyText(url) is CopyOutcome.Failed(var reason)) _workbench.StatusBar.SetError($"Couldn't copy the link: {reason}"); - else _workbench.StatusBar.SetMessage($"Copied {url} (no browser over SSH)"); + if (tab.CopyText(url) is CopyOutcome.Failed(var reason)) _workbench.StatusBar.ShowMessage($"Couldn't copy the link: {reason}", Severity.Error); + else _workbench.StatusBar.ShowMessage($"Copied {url} (no browser over SSH)"); } - else if (Browser.Open(url) is { } failure) _workbench.StatusBar.SetError($"Couldn't open the link: {failure}"); - else _workbench.StatusBar.SetMessage($"Opened {url}"); + else if (Browser.Open(url) is { } failure) _workbench.StatusBar.ShowMessage($"Couldn't open the link: {failure}", Severity.Error); + else _workbench.StatusBar.ShowMessage($"Opened {url}"); } internal const string TutorialsUrl = "https://www.youtube.com/playlist?list=PLGokw8JfNpWE"; @@ -1784,11 +1795,11 @@ private void WatchTutorials() { if (_environment.IsOverSsh) { - if (CopyToClipboard(TutorialsUrl) is CopyOutcome.Failed(var reason)) _workbench.StatusBar.SetError($"Couldn't copy the link: {reason}"); - else _workbench.StatusBar.SetMessage("Copied the tutorials link (no browser over SSH)"); + if (CopyToClipboard(TutorialsUrl) is CopyOutcome.Failed(var reason)) _workbench.StatusBar.ShowMessage($"Couldn't copy the link: {reason}", Severity.Error); + else _workbench.StatusBar.ShowMessage("Copied the tutorials link (no browser over SSH)"); } - else if (Browser.Open(TutorialsUrl) is { } failure) _workbench.StatusBar.SetError($"Couldn't open the link: {failure}"); - else _workbench.StatusBar.SetMessage("Opened TuiCode tutorials in your browser"); + else if (Browser.Open(TutorialsUrl) is { } failure) _workbench.StatusBar.ShowMessage($"Couldn't open the link: {failure}", Severity.Error); + else _workbench.StatusBar.ShowMessage("Opened TuiCode tutorials in your browser"); } internal const string SettingsErrorMessage = "TuiCode.settings.json has an error, so defaults are in use. sl shows the log"; @@ -1798,18 +1809,30 @@ private void ShowLog() if (_log is null) return; _log.Start(); _workbench.OpenFile(_log.File); - _log.MarkSeen(); + } + + private void ShowMessages() + { + if (_activeMessages is not null) return; + var view = new MessagesDialog(_workbench.StatusBar.Bar); + _activeMessages = view; + HostedDialog.Show(_workbench, _scopes, view, () => + { + _activeMessages = null; + FocusCallingRegion(); + }); + view.List.SetFocus(); } private void NavigateBack() { - if (!_history.CanGoBack) _workbench.StatusBar.SetMessage("No previous cursor position"); + if (!_history.CanGoBack) _workbench.StatusBar.ShowMessage("No previous cursor position"); else GoToHistory(_history.GoBack()); } private void NavigateForward() { - if (!_history.CanGoForward) _workbench.StatusBar.SetMessage("No next cursor position"); + if (!_history.CanGoForward) _workbench.StatusBar.ShowMessage("No next cursor position"); else GoToHistory(_history.GoForward()); } @@ -1952,7 +1975,7 @@ private void CutEntry() var fromExplorer = ExplorerIsContext; if (FileCommandTarget(fromExplorer, "cut") is not { } item) return; _workbench.Sidebar.Explorer.Cut(item); - _workbench.StatusBar.SetMessage(CutMessage(item)); + _cutMessage = _workbench.StatusBar.ShowMessage(CutMessage(item)); if (fromExplorer) FocusSidebar(); } @@ -1971,12 +1994,12 @@ private void PasteEntry() if (!ReferenceEquals(moved, item)) { _history.Rebase(item.FullName, moved.FullName); - _workbench.StatusBar.SetMessage($"Moved: {moved.FullName}"); + _workbench.StatusBar.ShowMessage($"Moved: {moved.FullName}"); } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) { - _workbench.StatusBar.SetMessage(ex.Message); + _workbench.StatusBar.ShowMessage(ex.Message, Severity.Error); } if (fromExplorer) FocusSidebar(); } @@ -2027,9 +2050,9 @@ private void SaveAll() } if (saved > 0) - _workbench.StatusBar.SetMessage($"Saved {saved} {Plural.Of(saved, "file")}"); + _workbench.StatusBar.ShowMessage($"Saved {saved} {Plural.Of(saved, "file")}"); else if (conflict is null) - _workbench.StatusBar.SetMessage("No unsaved changes"); + _workbench.StatusBar.ShowMessage("No unsaved changes"); if (conflict is not null) ConfirmOverwrite(conflict); } @@ -2073,7 +2096,7 @@ private void SuspendToShell() _cursorColour); if (ShellSuspend.Run(modes) is { } refusal) { - _workbench.StatusBar.SetMessage(refusal); + _workbench.StatusBar.ShowMessage(refusal); return; } _terminalCursors.Forget(); @@ -2152,7 +2175,7 @@ private void SaveAllThen(Func> dirtyTabs, Action proceed) } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - _workbench.StatusBar.SetMessage(ex.Message); + _workbench.StatusBar.ShowMessage(ex.Message, Severity.Error); } } @@ -2198,7 +2221,7 @@ private void ReloadFromDisk() if (_workbench.Editor.Group.ActiveTab is not { } tab) return; if (tab.DiskNow == DiskState.Gone) { - _workbench.StatusBar.SetMessage($"{tab.File.Name} no longer exists on disk — nothing to reload"); + _workbench.StatusBar.ShowMessage($"{tab.File.Name} no longer exists on disk — nothing to reload"); return; } if (!tab.IsDirty) @@ -2211,7 +2234,7 @@ private void ReloadFromDisk() $"Reload '{tab.File.Name}' from disk?\nYour unsaved changes will be lost.", [Main("Reload", ButtonKind.Danger, Reload)]); - void Reload() => _workbench.StatusBar.SetMessage(_diskChanges.Reload(tab, evenIfUnchanged: true) + void Reload() => _workbench.StatusBar.ShowMessage(_diskChanges.Reload(tab, evenIfUnchanged: true) ? $"⟳ Reloaded {tab.File.Name} from disk" : $"{tab.File.Name} no longer exists on disk — nothing to reload"); } @@ -2269,7 +2292,7 @@ private void DeletePermanently(IFileSystemInfo item) } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - _workbench.StatusBar.SetMessage(ex.Message); + _workbench.StatusBar.ShowMessage(ex.Message, Severity.Error); } } @@ -2330,7 +2353,7 @@ private void Confirm(string title, string message, Choice[] choices, Action? can var item = fromExplorer ? explorer.SelectedObject : _workbench.Editor.Group.ActiveTab?.File; if (item is null || explorer.Root is null) return null; if (!explorer.IsRoot(item)) return item; - _workbench.StatusBar.SetMessage($"The workspace root can't be {verb}."); + _workbench.StatusBar.ShowMessage($"The workspace root can't be {verb}."); return null; } @@ -2346,7 +2369,7 @@ private void AskAssistant() if (_assistant is null || agent is null || !CommandInstalled(agent.Program)) { var missing = agent is null ? "No AI agent is set up" : $"No AI agent found: {agent.Program} isn't on PATH"; - _workbench.StatusBar.SetMessage($"{missing}. Choose one in Settings › AI Assistance ({CommandMnemonics.For(CommandIds.OpenSettings)})"); + _workbench.StatusBar.ShowMessage($"{missing}. Choose one in Settings › AI Assistance ({CommandMnemonics.For(CommandIds.OpenSettings)})"); return; } _assistant.Configure(agent); @@ -2412,8 +2435,8 @@ private void ShowAssistant() var answering = _assistant?.IsAnswering == true; if (answering == _answering) return; _answering = answering; - if (answering) _workbench.StatusBar.SetMessage(AnsweringMessage); - else _workbench.StatusBar.ClearMessage(AnsweringMessage); + if (answering) _answeringMessage = _workbench.StatusBar.ShowNews(AnsweringMessage); + else _workbench.StatusBar.Withdraw(_answeringMessage); } private const string AnsweringMessage = "AI assistance: answering…"; @@ -2550,7 +2573,7 @@ private void OpenDocumentInfo() if (_activeDocumentInfo is not null) return; if (_workbench.Editor.Group.ActiveTab is not { } tab) { - _workbench.StatusBar.SetMessage("No file is open."); + _workbench.StatusBar.ShowMessage("No file is open."); return; } @@ -2580,17 +2603,17 @@ private void CompareToSaved() var group = _workbench.Editor.Group; if ((group.ActiveTab ?? group.ActiveDiffTab?.Source) is not { } tab) { - _workbench.StatusBar.SetMessage("No file is open."); + _workbench.StatusBar.ShowMessage("No file is open."); return; } if (!tab.File.FileSystem.File.Exists(tab.File.FullName)) { - _workbench.StatusBar.SetMessage($"{tab.File.Name} has never been saved."); + _workbench.StatusBar.ShowMessage($"{tab.File.Name} has never been saved."); return; } if (group.CompareToSaved(tab) is null) { - _workbench.StatusBar.SetMessage("No changes against saved"); + _workbench.StatusBar.ShowMessage("No changes against saved"); return; } FocusEditorBody(); @@ -2608,7 +2631,7 @@ private void GoToChangeLine() if (diff.ToggleThread()) return; if (diff.Source is not { } tab) { - _workbench.StatusBar.SetMessage("Deleted in this branch"); + _workbench.StatusBar.ShowMessage("Deleted in this branch"); return; } var line = diff.CurrentBufferLine; @@ -2640,11 +2663,11 @@ private void RevertChange() } if (diff.RevertChange() is not { } lines) { - _workbench.StatusBar.SetMessage("No changes"); + _workbench.StatusBar.ShowMessage("No changes"); return; } var undo = lines > LargeRevert ? " — Ctrl+Z to undo" : string.Empty; - _workbench.StatusBar.SetMessage($"Reverted {Plural.Count(lines, "line")} from {diff.LeftLabel}{undo}"); + _workbench.StatusBar.ShowMessage($"Reverted {Plural.Count(lines, "line")} from {diff.LeftLabel}{undo}"); } /// @@ -2657,13 +2680,13 @@ private void RevertAllChanges() if (_workbench.Editor.Group.ActiveDiffTab is not { } diff) return; if (diff.IsDeleted) { - _workbench.StatusBar.SetMessage($"{diff.File.Name} is deleted in this branch — use rc to restore it"); + _workbench.StatusBar.ShowMessage($"{diff.File.Name} is deleted in this branch — use rc to restore it"); return; } var changes = diff.ChangeCount; if (changes == 0) { - _workbench.StatusBar.SetMessage("No changes"); + _workbench.StatusBar.ShowMessage("No changes"); return; } @@ -2672,7 +2695,7 @@ private void RevertAllChanges() [Main("Revert", ButtonKind.Danger, () => { if (diff.RevertAll() is not { } reverted) return; - _workbench.StatusBar.SetMessage( + _workbench.StatusBar.ShowMessage( $"Reverted {Plural.Count(reverted, "change")} in {diff.File.Name} — Ctrl+Z to undo"); })]); } @@ -2685,7 +2708,7 @@ private void RestoreDeleted(DiffTab diff) { _workbench.Editor.Groups.Restore(diff.File, diff.LeftLines); FocusEditorBody(); - _workbench.StatusBar.SetMessage($"{diff.File.Name} restored — Ctrl+S to write it back"); + _workbench.StatusBar.ShowMessage($"{diff.File.Name} restored — Ctrl+S to write it back"); } private void CompareToRevision() @@ -2694,12 +2717,12 @@ private void CompareToRevision() var group = _workbench.Editor.Group; if ((group.ActiveTab ?? group.ActiveDiffTab?.Source) is not { } tab) { - _workbench.StatusBar.SetMessage("No file is open."); + _workbench.StatusBar.ShowMessage("No file is open."); return; } if (!tab.File.FileSystem.File.Exists(tab.File.FullName)) { - _workbench.StatusBar.SetMessage($"{tab.File.Name} has never been saved."); + _workbench.StatusBar.ShowMessage($"{tab.File.Name} has never been saved."); return; } @@ -2711,13 +2734,13 @@ private void GitBlame() if (_activeBlame is not null) return; if (_workbench.Editor.Group.ActiveTab is not { } tab) { - _workbench.StatusBar.SetMessage("No file is open."); + _workbench.StatusBar.ShowMessage("No file is open."); return; } var name = tab.File.Name; if (!tab.File.FileSystem.File.Exists(tab.File.FullName)) { - _workbench.StatusBar.SetMessage($"{name} has never been saved."); + _workbench.StatusBar.ShowMessage($"{name} has never been saved."); return; } var lines = tab.Lines; @@ -2725,7 +2748,7 @@ private void GitBlame() // The row after a final line break has no line of its own for git to blame. if (row > 0 && row == lines.Count - 1 && lines[row].Length == 0) { - _workbench.StatusBar.SetMessage("Nothing to blame on the empty last line."); + _workbench.StatusBar.ShowMessage("Nothing to blame on the empty last line."); return; } @@ -2737,21 +2760,21 @@ private void GitBlame() { if (root.Result.Error is { } error) { - _workbench.StatusBar.SetMessage(error); + _workbench.StatusBar.ShowMessage(error, Severity.Error); return; } if (root.Result.Value is null) { - _workbench.StatusBar.SetMessage($"{name} isn't in a git repository."); + _workbench.StatusBar.ShowMessage($"{name} isn't in a git repository."); return; } var blame = Task.Run(() => _git.BlameAsync(path, number, contents)); WhenDone(blame, () => { if (blame.Result.Error is { } failure) - _workbench.StatusBar.SetMessage(failure); + _workbench.StatusBar.ShowMessage(failure, Severity.Error); else if (blame.Result.Value is not { } line) - _workbench.StatusBar.SetMessage($"{name} isn't tracked by git."); + _workbench.StatusBar.ShowMessage($"{name} isn't tracked by git."); else OpenBlame(name, number, line, tab.File.FileSystem, root.Result.Value); }); @@ -2798,12 +2821,12 @@ private void OpenIntroducingChange(IFileSystem fileSystem, string repoRoot, GitB { if ((after.Result.Error ?? before.Result.Error) is { } error) { - _workbench.StatusBar.SetMessage(error); + _workbench.StatusBar.ShowMessage(error, Severity.Error); return; } if (after.Result.Value is not { } text) { - _workbench.StatusBar.SetMessage($"{line.Path} isn't in {line.ShortHash}"); + _workbench.StatusBar.ShowMessage($"{line.Path} isn't in {line.ShortHash}"); return; } var file = fileSystem.FileInfo.New(fileSystem.Path.Combine(repoRoot, line.Path)); @@ -2819,7 +2842,7 @@ private void CompareToOtherFile() var group = _workbench.Editor.Group; if ((group.ActiveTab ?? group.ActiveDiffTab?.Source) is not { } tab) { - _workbench.StatusBar.SetMessage("No file is open."); + _workbench.StatusBar.ShowMessage("No file is open."); return; } var start = tab.File.Directory is { Exists: true } folder ? folder : _workbench.Sidebar.Explorer.Root; @@ -2843,19 +2866,19 @@ private void CompareTo(EditorTab tab, IFileInfo other) { if (string.Equals(other.FullName, tab.File.FullName, StringComparison.Ordinal)) { - _workbench.StatusBar.SetMessage($"Can't compare {tab.File.Name} with itself"); + _workbench.StatusBar.ShowMessage($"Can't compare {tab.File.Name} with itself"); return; } try { if (_workbench.Editor.Group.Compare(tab, other.Name, () => DiffTab.ReadLines(other), other.FullName) is null) - _workbench.StatusBar.SetMessage($"No changes against {other.Name}"); + _workbench.StatusBar.ShowMessage($"No changes against {other.Name}"); else FocusEditorBody(); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - _workbench.StatusBar.SetMessage($"Can't read {other.Name}: {ex.Message}"); + _workbench.StatusBar.ShowMessage($"Can't read {other.Name}: {ex.Message}", Severity.Error); } } @@ -2892,7 +2915,7 @@ private void OpenRevisionPicker(EditorTab tab) var lines = DiffTab.SplitLines(content.Result.Value); CloseRevisionPicker(view); if (group.Compare(tab, revision, () => lines) is null) - _workbench.StatusBar.SetMessage($"No changes against {revision}"); + _workbench.StatusBar.ShowMessage($"No changes against {revision}"); else FocusEditorBody(); }); @@ -2961,7 +2984,7 @@ void Step(int index) { if (index < 0 || index >= commits.Count) { - _workbench.StatusBar.SetMessage(direction > 0 ? "Oldest commit in the history" : "Newest commit in the history"); + _workbench.StatusBar.ShowMessage(direction > 0 ? "Oldest commit in the history" : "Newest commit in the history"); return; } @@ -2996,7 +3019,7 @@ private void ShowHistoryDiff(HistorySpot spot, bool skipUnchanged, Action= files.Count) { - _workbench.StatusBar.SetMessage(direction > 0 ? "Last change in the review" : "First change in the review"); + _workbench.StatusBar.ShowMessage(direction > 0 ? "Last change in the review" : "First change in the review"); return; } @@ -3065,7 +3088,7 @@ private void ShowPullRequestOverview() WhenDone(review.Refresh(), () => { if (review.Review is { PullRequest: not null } reloaded) OpenOverview(reloaded); - else _workbench.StatusBar.SetMessage("No pull request for this branch."); + else _workbench.StatusBar.ShowMessage("No pull request for this branch."); }); } @@ -3087,13 +3110,13 @@ private void OpenOverview(BranchReview review) return; } - _workbench.StatusBar.SetMessage($"Loading #{pullRequest.Number}…"); + _workbench.StatusBar.ShowMessage($"Loading #{pullRequest.Number}…"); var loading = Task.Run(() => _gitHub.GetConversationAsync(review.RepoRoot, pullRequest.Number)); WhenDone(loading, () => { var text = loading.Result.Error ?? PullRequestOverview.Build(loading.Result.Value); group.OpenDocument(file, text); - _workbench.StatusBar.SetMessage(title); + _workbench.StatusBar.ShowMessage(title); FocusEditorBody(); }); } @@ -3153,7 +3176,7 @@ private void OpenReviewDiff(BranchReview review, GitChange change) while (index < files.Count && files[index].Path != change.Path) index++; ShowReviewDiff(review, change, index, files.Count, diff => { - if (diff is null) _workbench.StatusBar.SetMessage($"No changes against {review.Base}"); + if (diff is null) _workbench.StatusBar.ShowMessage($"No changes against {review.Base}"); }); } @@ -3185,7 +3208,7 @@ private void ShowReviewDiff(BranchReview review, GitChange change, int index, in { if (content.Result.Error is { } error) { - _workbench.StatusBar.SetMessage(error); + _workbench.StatusBar.ShowMessage(error, Severity.Error); return; } var lines = content.Result.Value is { } text ? DiffTab.SplitLines(text) : []; @@ -3301,7 +3324,7 @@ private void OpenWorktree() if (_activeWorktreePicker is not null) return; if (_workbench.Sidebar.Explorer.Root is not { } root) { - _workbench.StatusBar.SetMessage("Not in a git repository."); + _workbench.StatusBar.ShowMessage("Not in a git repository."); return; } @@ -3320,9 +3343,9 @@ private void OpenWorktree() { var (error, rows) = listing.Result; if (error is not null) - _workbench.StatusBar.SetMessage(error); + _workbench.StatusBar.ShowMessage(error, Severity.Error); else if (rows.Count == 0) - _workbench.StatusBar.SetMessage("This repo has no other worktrees."); + _workbench.StatusBar.ShowMessage("This repo has no other worktrees."); else OpenWorktreePicker(root.FileSystem, rows); }); @@ -3405,7 +3428,7 @@ private void SubmitReview() } ClearDrafts(); CloseSubmitReview(view); - _workbench.StatusBar.SetMessage($"Review submitted on #{number}"); + _workbench.StatusBar.ShowMessage($"Review submitted on #{number}"); }); }; @@ -3506,7 +3529,7 @@ private void ToggleViewed() { if (setting.Result.Error is { } error) { - _workbench.StatusBar.SetMessage(error); + _workbench.StatusBar.ShowMessage(error, Severity.Error); return; } Marked(); @@ -3542,7 +3565,7 @@ private void ToggleFolderViewed(BranchReview review, string folder) { tab.SetViewed(targets.Where((_, i) => results[i].Error is null), viewed); if (results.Select(r => r.Error).FirstOrDefault(e => e is not null) is not { } error) return; - _workbench.StatusBar.SetMessage(error); + _workbench.StatusBar.ShowMessage(error, Severity.Error); _ = tab.Refresh(); }); } @@ -3566,7 +3589,7 @@ void Open(int next) if (next >= unviewed.Count) { group.CloseDiff(from); - _workbench.StatusBar.SetMessage(unviewed.Count == 0 ? "All files viewed" : "No unviewed file has changes to show"); + _workbench.StatusBar.ShowMessage(unviewed.Count == 0 ? "All files viewed" : "No unviewed file has changes to show"); return; } @@ -3607,12 +3630,12 @@ private void CreateComment() || _workbench.Sidebar.Review.Review is not { PullRequest: { } pullRequest } review || spot.Key != review.MergeBase) { - _workbench.StatusBar.SetMessage("Open a file from the Review tab to comment"); + _workbench.StatusBar.ShowMessage("Open a file from the Review tab to comment"); return; } if (diff.CurrentHeadLine is not { } line) { - _workbench.StatusBar.SetMessage("Comment on a line on the right"); + _workbench.StatusBar.ShowMessage("Comment on a line on the right"); return; } @@ -3623,12 +3646,12 @@ private void CreateComment() { if (head.Result.Error is { } error) { - _workbench.StatusBar.SetMessage(error); + _workbench.StatusBar.ShowMessage(error, Severity.Error); return; } if (!MatchesHead(head.Result.Value, diff)) { - _workbench.StatusBar.SetMessage( + _workbench.StatusBar.ShowMessage( $"This file differs from #{pullRequest.Number}'s head: comments would land on the wrong lines"); return; } @@ -3646,7 +3669,7 @@ private void OpenReply(GitHubReviewThread thread, bool fromReviewTab) if (_workbench.Sidebar.Review.Review is not { PullRequest: { } pullRequest } review) return; if (thread.ReplyToId == 0) { - _workbench.StatusBar.SetMessage("GitHub didn't say what to reply to on this thread"); + _workbench.StatusBar.ShowMessage("GitHub didn't say what to reply to on this thread"); return; } @@ -3668,7 +3691,7 @@ private void OpenReply(GitHubReviewThread thread, bool fromReviewTab) // editor's Tabs then makes whichever tab takes it active (#191). CloseComment(view, fromReviewTab); ShowReply(thread, posting.Result.Value); - _workbench.StatusBar.SetMessage($"Replied on #{pullRequest.Number}"); + _workbench.StatusBar.ShowMessage($"Replied on #{pullRequest.Number}"); }); }; @@ -3763,7 +3786,7 @@ private void OpenRecentFolder() _environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); if (folders.Count == 0) { - _workbench.StatusBar.SetMessage("No other folders in your history yet."); + _workbench.StatusBar.ShowMessage("No other folders in your history yet."); return; } diff --git a/tests/TuiCode.Tests/BlameHostTests.cs b/tests/TuiCode.Tests/BlameHostTests.cs index ef327426..1a1191ef 100644 --- a/tests/TuiCode.Tests/BlameHostTests.cs +++ b/tests/TuiCode.Tests/BlameHostTests.cs @@ -163,7 +163,7 @@ await HostSteps.Run(host, () => host.App.InjectKey(Key.CursorDown.WithAlt), () => { - status = workbench.StatusBar.DisplayedText; + status = workbench.StatusBar.DisplayedState; revert = commands.IsEnabled(CommandIds.RevertChange); revertAll = commands.IsEnabled(CommandIds.RevertAllChanges); goToLine = commands.IsEnabled(CommandIds.GoToChangeLine); @@ -178,7 +178,7 @@ await HostSteps.Run(host, Assert.Equal(["alpha", "b", "charlie", ""], diff.LeftLines); Assert.Equal(DiffRowKind.Modified, diff.Diff.Rows[1].Kind); Assert.Equal("Change 1 of 1", diff.ChangeStatus); - Assert.EndsWith(" • Change 1 of 1", status); + Assert.EndsWith("Change 1 of 1", status); Assert.False(revert); Assert.False(revertAll); Assert.False(goToLine); diff --git a/tests/TuiCode.Tests/CopyStatusHostTests.cs b/tests/TuiCode.Tests/CopyStatusHostTests.cs index 481fd960..9b5d3663 100644 --- a/tests/TuiCode.Tests/CopyStatusHostTests.cs +++ b/tests/TuiCode.Tests/CopyStatusHostTests.cs @@ -1,3 +1,4 @@ +using MentalDesk.Tui.Chrome; using Terminal.Gui.Drivers; using Terminal.Gui.Input; using TuiCode.Editor; @@ -13,11 +14,12 @@ namespace TuiCode.Tests; public class CopyStatusHostTests : StaticConfigurationTest { [Fact] - public async Task A_copy_says_what_it_copied_and_the_file_path_comes_back_afterwards() + public async Task A_copy_says_what_it_copied_and_the_file_path_comes_back_after_five_seconds() { using var workbench = Workbench(); using var host = Host(workbench); - workbench.CopyMessageDuration = TimeSpan.Zero; + var clock = new FakeClock(); + workbench.StatusBar.Bar.Clock = clock; string said = "", after = ""; var error = true; @@ -28,8 +30,8 @@ await HostSteps.Run(host, Copy(workbench)(); said = workbench.StatusBar.DisplayedText; error = workbench.StatusBar.ShowsError; + clock.Advance(AppStatusBar.Lifetime); }, - () => workbench.StatusBar.DisplayedText != said, () => { after = workbench.StatusBar.DisplayedText; }); Assert.Equal("Copied 2 lines • 1,001 characters", said); @@ -38,11 +40,10 @@ await HostSteps.Run(host, } [Fact] - public async Task A_copy_that_did_not_land_says_so_in_the_error_colours_until_the_next_message() + public async Task A_copy_that_did_not_land_says_so_in_the_error_colours() { using var workbench = Workbench(); using var host = Host(workbench); - workbench.CopyMessageDuration = TimeSpan.Zero; var said = ""; var error = false; diff --git a/tests/TuiCode.Tests/DiffRevertTests.cs b/tests/TuiCode.Tests/DiffRevertTests.cs index 55b59335..eb069d99 100644 --- a/tests/TuiCode.Tests/DiffRevertTests.cs +++ b/tests/TuiCode.Tests/DiffRevertTests.cs @@ -230,7 +230,8 @@ await HostSteps.Run(host, () => host.App.InjectKey(Key.CursorDown.WithAlt), () => host.App.InjectKey(Key.R.WithCtrl)); - Assert.Equal($"Reverted 1 line from saved • +2 −2 • Change 1 of 2", workbench.StatusBar.DisplayedText); + Assert.Equal("Reverted 1 line from saved", workbench.StatusBar.DisplayedText); + Assert.StartsWith("+2 −2 • Change 1 of 2", workbench.StatusBar.DisplayedState); Assert.Equal(40, workbench.Editor.Group.Tabs[0].Lines.Count); Assert.Equal("line 15", workbench.Editor.Group.Tabs[0].Lines[14]); } @@ -290,7 +291,8 @@ await HostSteps.Run(host, () => OpenThreeChanges(workbench, commands), () => host.App.InjectKey(Key.R.WithCtrl)); - Assert.Equal($"Reverted 1 line from saved • +1 −2 • 2 changes", workbench.StatusBar.DisplayedText); + Assert.Equal("Reverted 1 line from saved", workbench.StatusBar.DisplayedText); + Assert.StartsWith("+1 −2 • 2 changes", workbench.StatusBar.DisplayedState); Assert.Equal("line 5", workbench.Editor.Group.Tabs[0].Lines[4]); } @@ -311,7 +313,8 @@ await HostSteps.Run(host, () => host.App.InjectKey(Key.R.WithCtrl), () => host.App.InjectKey(Key.R.WithCtrl)); - Assert.StartsWith("No changes • No changes", workbench.StatusBar.DisplayedText); + Assert.Equal("No changes", workbench.StatusBar.DisplayedText); + Assert.StartsWith("No changes", workbench.StatusBar.DisplayedState); } [Fact] @@ -333,8 +336,8 @@ await HostSteps.Run(host, () => host.App.InjectKey(Key.CursorDown.WithAlt), () => host.App.InjectKey(Key.F9)); - Assert.Equal("Reverted 1 line from saved • +1 −2 • 2 changes", - workbench.StatusBar.DisplayedText); + Assert.Equal("Reverted 1 line from saved", workbench.StatusBar.DisplayedText); + Assert.StartsWith("+1 −2 • 2 changes", workbench.StatusBar.DisplayedState); } // Changes at rows 4 (modified), 14 (line 15 removed) and 30 (modified). @@ -514,7 +517,7 @@ await HostSteps.Run(host, () => workbench.Sidebar.Review.ListHasFocus, () => host.App.InjectKey(Key.Enter), () => group.ActiveDiffTab is { IsFocused: true }, - () => { status = workbench.StatusBar.DisplayedText; }, + () => { status = workbench.StatusBar.DisplayedState; }, () => host.App.InjectKey(Key.F1), () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); diff --git a/tests/TuiCode.Tests/DiffTabTests.cs b/tests/TuiCode.Tests/DiffTabTests.cs index c8f85012..776702b9 100644 --- a/tests/TuiCode.Tests/DiffTabTests.cs +++ b/tests/TuiCode.Tests/DiffTabTests.cs @@ -841,7 +841,8 @@ await HostSteps.Run(host, Assert.Equal("a.txt ↔ saved", diff.Title); Assert.Same(diff, group.ActiveDiffTab); Assert.Equal(1, diff.CurrentRow); - Assert.StartsWith("a.txt ↔ saved • +1 −1 • Change 1 of 1", workbench.StatusBar.DisplayedText); + Assert.Equal("a.txt ↔ saved", workbench.StatusBar.DisplayedText); + Assert.StartsWith("+1 −1 • Change 1 of 1", workbench.StatusBar.DisplayedState); } [Fact] @@ -904,7 +905,7 @@ public async Task Alt_down_and_alt_up_step_through_changes_and_stop_at_the_last( using var workbench = BuildWorkbench(); using var host = BuildHost(workbench, out var commands); var statuses = new List(); - void Record() => statuses.Add(workbench.StatusBar.DisplayedText); + void Record() => statuses.Add($"{workbench.StatusBar.DisplayedText} • {workbench.StatusBar.DisplayedState}"); await HostSteps.Run(host, () => OpenThreeChanges(workbench, commands), @@ -960,9 +961,9 @@ public async Task The_status_shows_no_keys_and_clears_when_the_diff_tab_loses_fo await HostSteps.Run(host, () => OpenThreeChanges(workbench, commands), () => host.App.InjectKey(Key.CursorDown.WithAlt), - () => { focused = workbench.StatusBar.DisplayedText; }, + () => { focused = $"{workbench.StatusBar.DisplayedText} • {workbench.StatusBar.DisplayedState}"; }, () => commands.TryExecute(CommandIds.FocusSidebar), - () => workbench.StatusBar.DisplayedText == "a.txt ↔ saved"); + () => workbench.StatusBar.DisplayedState == ""); Assert.Equal("a.txt ↔ saved • +2 −3 • Change 1 of 3", focused); } @@ -1061,7 +1062,7 @@ await HostSteps.Run(host, () => host.App.InjectKey(Key.F.WithCtrl), () => { foreach (var c in "line") host.App.InjectKey(new Key(c)); }, () => workbench.Editor.Group.ActiveDiffTab!.CurrentMatch is not null, - () => { status = workbench.StatusBar.DisplayedText; host.App.InjectKey(Key.F1); }, + () => { status = workbench.StatusBar.DisplayedState; host.App.InjectKey(Key.F1); }, () => (help = workbench.SubViews.OfType().SingleOrDefault()) is not null, () => host.App.InjectKey(Key.Esc)); diff --git a/tests/TuiCode.Tests/DiskReloadHostTests.cs b/tests/TuiCode.Tests/DiskReloadHostTests.cs index 89c8d7cd..be41285f 100644 --- a/tests/TuiCode.Tests/DiskReloadHostTests.cs +++ b/tests/TuiCode.Tests/DiskReloadHostTests.cs @@ -1,3 +1,4 @@ +using MentalDesk.Tui.Chrome; using Terminal.Gui.Drivers; using TuiCode.Explorer; using TuiCode.Workbench; @@ -21,15 +22,22 @@ public async Task Reloading_the_tab_you_are_looking_at_says_so_once() using var host = BuildHost(workbench); var said = ""; var position = ""; + MessageKind? kind = null; await HostSteps.Run(host, () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), () => workbench.Editor.Group.ActiveTab is not null, () => { workbench.Editor.Group.ActiveTab!.MoveCursor(1, 2); Change("/work/a.txt", "alpha\nbravo\ncharlie\n"); }, () => workbench.StatusBar.DisplayedText.StartsWith(Note, StringComparison.Ordinal), - () => { said = workbench.StatusBar.DisplayedText; position = workbench.StatusBar.DisplayedPosition; }); + () => + { + said = workbench.StatusBar.DisplayedText; + position = workbench.StatusBar.DisplayedPosition; + kind = workbench.StatusBar.Bar.Current?.Kind; + }); Assert.StartsWith(Note, said); + Assert.Equal(MessageKind.News, kind); Assert.Equal("Ln 2, Col 3", position); Assert.Equal("alpha\nbravo\ncharlie\n", workbench.Editor.Group.ActiveTab!.Content.ReplaceLineEndings("\n")); } diff --git a/tests/TuiCode.Tests/EditorColumnSelectTests.cs b/tests/TuiCode.Tests/EditorColumnSelectTests.cs index ff7c677b..b29d9362 100644 --- a/tests/TuiCode.Tests/EditorColumnSelectTests.cs +++ b/tests/TuiCode.Tests/EditorColumnSelectTests.cs @@ -160,7 +160,7 @@ await HostSteps.Run(host, }, () => host.App.InjectKey(Key.T.WithCtrl), () => host.App.InjectKey(Key.C), - () => { whileOn = statusBar.DisplayedText; }, + () => { whileOn = statusBar.DisplayedState; }, () => host.App.InjectKey(Key.CursorRight.WithShift), () => host.App.InjectKey(Key.CursorRight.WithShift), () => host.App.InjectKey(Key.CursorDown.WithShift), diff --git a/tests/TuiCode.Tests/EditorWordWrapTests.cs b/tests/TuiCode.Tests/EditorWordWrapTests.cs index 660ec89b..627dbf22 100644 --- a/tests/TuiCode.Tests/EditorWordWrapTests.cs +++ b/tests/TuiCode.Tests/EditorWordWrapTests.cs @@ -585,17 +585,17 @@ await HostSteps.Run(host, }, () => host.App.InjectKey(Key.T.WithCtrl), () => host.App.InjectKey(Key.W), - () => { whileWrapped = statusBar.DisplayedText; }, + () => { whileWrapped = statusBar.DisplayedState; }, () => { workbench.Editor.Group.Focus(b!.File.FullName); }, - () => { onTheOtherTab = statusBar.DisplayedText; }, + () => { onTheOtherTab = statusBar.DisplayedState; }, () => { workbench.Editor.Group.Focus(a!.File.FullName); }, - () => { backAgain = statusBar.DisplayedText; }); + () => { backAgain = statusBar.DisplayedState; }); Assert.True(a!.WordWrap); Assert.False(b!.WordWrap); - Assert.EndsWith(" • Wrap", whileWrapped); + Assert.Contains("Wrap", whileWrapped); Assert.DoesNotContain("Wrap", onTheOtherTab); - Assert.EndsWith(" • Wrap", backAgain); + Assert.Contains("Wrap", backAgain); } [Fact] @@ -618,7 +618,7 @@ await HostSteps.Run(host, () => host.App.InjectKey(Key.W)); Assert.False(tab!.WordWrap); - Assert.DoesNotContain("Wrap", statusBar.DisplayedText); + Assert.DoesNotContain("Wrap", statusBar.DisplayedState); } [Fact] @@ -640,12 +640,12 @@ await HostSteps.Run(host, () => host.App.InjectKey(Key.W), () => host.App.InjectKey(Key.T.WithCtrl), () => host.App.InjectKey(Key.C), - () => { afterColumnSelect = statusBar.DisplayedText; }, + () => { afterColumnSelect = statusBar.DisplayedState; }, () => host.App.InjectKey(Key.CursorDown.WithCtrl.WithAlt), () => { afterAddCursor = statusBar.DisplayedText; }); Assert.True(workbench.Editor.Group.ColumnSelect); - Assert.EndsWith(" • Column select • Wrap", afterColumnSelect); + Assert.Contains("Column select • Wrap", afterColumnSelect); Assert.True(tab!.HasSecondaryCursors); Assert.DoesNotContain("Not available", afterAddCursor); } @@ -665,13 +665,13 @@ await HostSteps.Run(host, tab = workbench.Editor.Open(fs.FileInfo.New("/work/a.txt")); tab.FocusContent(); }, - () => { opened = statusBar.DisplayedText; }, + () => { opened = statusBar.DisplayedState; }, () => host.App.InjectKey(Key.T.WithCtrl), () => host.App.InjectKey(Key.W)); - Assert.EndsWith(" • Wrap", opened); + Assert.Contains("Wrap", opened); Assert.False(tab!.WordWrap); - Assert.DoesNotContain("Wrap", statusBar.DisplayedText); + Assert.DoesNotContain("Wrap", statusBar.DisplayedState); } [Fact] @@ -682,7 +682,7 @@ public void Wrap_shows_after_the_grammar() statusBar.SetGrammar("Markdown"); statusBar.SetWrap(true); - Assert.EndsWith("Markdown • Wrap", statusBar.DisplayedText); + Assert.Equal("Markdown • Wrap", statusBar.DisplayedState); } private static (MockFileSystem Fs, StatusBarPart StatusBar, Workbench.Workbench Workbench, WorkbenchHost Host) Start( diff --git a/tests/TuiCode.Tests/ExplorerFileCommandsHostTests.cs b/tests/TuiCode.Tests/ExplorerFileCommandsHostTests.cs index 274afc0f..b5dc5650 100644 --- a/tests/TuiCode.Tests/ExplorerFileCommandsHostTests.cs +++ b/tests/TuiCode.Tests/ExplorerFileCommandsHostTests.cs @@ -494,7 +494,7 @@ await HostSteps.Run(host, Assert.Null(workbench.Sidebar.Explorer.PendingCut); Assert.True(workbench.Sidebar.Explorer.HasFocus); - Assert.Equal(workbench.StatusBar.DefaultMessage, workbench.StatusBar.DisplayedText); + Assert.Equal(_fs.Path.GetFullPath("/work/a.txt"), workbench.StatusBar.DisplayedText); } [Fact] @@ -507,7 +507,7 @@ public async Task Cancelling_a_cut_keeps_a_message_that_replaced_the_cut_message await HostSteps.Run(host, () => SelectInExplorer(workbench, "a.txt"), () => host.App.InjectKey(Key.X.WithCtrl), - () => workbench.StatusBar.SetMessage("Saved: /work/b.txt"), + () => { workbench.StatusBar.ShowNews("Saved: /work/b.txt"); }, () => SelectInExplorer(workbench, "a.txt"), () => host.App.InjectKey(Key.Esc), () => { }); diff --git a/tests/TuiCode.Tests/FakeClock.cs b/tests/TuiCode.Tests/FakeClock.cs new file mode 100644 index 00000000..f8722e97 --- /dev/null +++ b/tests/TuiCode.Tests/FakeClock.cs @@ -0,0 +1,50 @@ +namespace TuiCode.Tests; + +// Timers fire synchronously inside Advance. +internal sealed class FakeClock : TimeProvider +{ + private readonly List _timers = []; + private DateTimeOffset _now = new(2026, 10, 23, 10, 42, 7, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => _now; + + public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; + + public void Advance(TimeSpan by) + { + _now += by; + foreach (var timer in _timers.Where(timer => timer.Due <= _now).ToList()) + { + _timers.Remove(timer); + timer.Fire(); + } + } + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new Timer(this, () => callback(state), _now + dueTime); + _timers.Add(timer); + return timer; + } + + private sealed class Timer(FakeClock clock, Action fire, DateTimeOffset due) : ITimer + { + public DateTimeOffset Due { get; private set; } = due; + + public void Fire() => fire(); + + public bool Change(TimeSpan dueTime, TimeSpan period) + { + Due = clock._now + dueTime; + return true; + } + + public void Dispose() => clock._timers.Remove(this); + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/TuiCode.Tests/FileHistoryHostTests.cs b/tests/TuiCode.Tests/FileHistoryHostTests.cs index 7097382b..bc8d73ad 100644 --- a/tests/TuiCode.Tests/FileHistoryHostTests.cs +++ b/tests/TuiCode.Tests/FileHistoryHostTests.cs @@ -216,7 +216,7 @@ await HostSteps.Run(host, () => host.App.InjectKey(Key.CursorDown.WithAlt), () => host.App.InjectKey(Key.CursorDown.WithAlt), () => workbench.Editor.Group.ActiveDiffTab is { History.Index: 2 }, - () => { workbench.ShowCursorPosition(); commitStatus = workbench.StatusBar.DisplayedText; }); + () => { workbench.ShowCursorPosition(); commitStatus = workbench.StatusBar.DisplayedState; }); var diffs = workbench.Editor.Group.TabCollection.OfType().ToList(); var diff = Assert.Single(diffs); diff --git a/tests/TuiCode.Tests/FindUsagesHostTests.cs b/tests/TuiCode.Tests/FindUsagesHostTests.cs index a32f069e..0c859e85 100644 --- a/tests/TuiCode.Tests/FindUsagesHostTests.cs +++ b/tests/TuiCode.Tests/FindUsagesHostTests.cs @@ -155,7 +155,7 @@ public async Task While_the_server_works_the_tab_says_so_and_the_menu_item_is_di await HostSteps.Run(host, () => { OpenFile(workbench, "Caller.cs"); }, - () => workbench.StatusBar.DisplayedText.EndsWith("C# ◌ loading", StringComparison.Ordinal), + () => workbench.StatusBar.DisplayedState.Contains("C# ◌ loading", StringComparison.Ordinal), () => host.App.InjectKey(Key.G.WithAlt), () => workbench.MenuBar.IsOpen(), () => { dimmedWhileLoading = Item(host).Dimmed; host.App.InjectKey(Key.Esc); }, @@ -187,7 +187,7 @@ public async Task Without_the_server_installed_Ctrl_G_U_says_how_to_install_it() await HostSteps.Run(host, () => { OpenFile(workbench, "Caller.cs"); }, - () => { workbench.StatusBar.SetMessage("elsewhere"); Tab(workbench).MoveCursor(4, 26); Execute(host); }, + () => { workbench.StatusBar.ShowMessage("elsewhere"); Tab(workbench).MoveCursor(4, 26); Execute(host); }, () => { shown = workbench.StatusBar.Message; }); Assert.Equal("No C# language server. Install: dotnet tool install -g csharp-ls", shown); @@ -261,7 +261,7 @@ private void Add(string path, string content) } private static bool Ready(Workbench.Workbench workbench) => - workbench.StatusBar.DisplayedText.EndsWith("C# ● ready", StringComparison.Ordinal); + workbench.StatusBar.DisplayedState.Contains("C# ● ready", StringComparison.Ordinal); private static void Execute(WorkbenchHost host) { diff --git a/tests/TuiCode.Tests/GoToDefinitionHostTests.cs b/tests/TuiCode.Tests/GoToDefinitionHostTests.cs index aecf8bc9..26d21da6 100644 --- a/tests/TuiCode.Tests/GoToDefinitionHostTests.cs +++ b/tests/TuiCode.Tests/GoToDefinitionHostTests.cs @@ -52,7 +52,7 @@ public async Task Ctrl_G_D_opens_the_file_a_call_is_defined_in_with_the_caret_on await HostSteps.Run(host, () => { OpenFile(workbench, "Caller.cs"); }, () => Ready(workbench), - () => { languageShown = workbench.StatusBar.DisplayedText; Tab(workbench).MoveCursor(4, 26); }, + () => { languageShown = workbench.StatusBar.DisplayedState; Tab(workbench).MoveCursor(4, 26); }, () => host.App.InjectKey(Key.G.WithCtrl), () => host.App.InjectKey(Key.D), () => Tab(workbench) is { File.Name: "Diff.cs", StickyScan.Done: true }, @@ -63,7 +63,7 @@ await HostSteps.Run(host, () => { forward = Where(workbench); }); host.Dispose(); - Assert.EndsWith("C# ● ready", languageShown, StringComparison.Ordinal); + Assert.Contains("C# ● ready", languageShown, StringComparison.Ordinal); Assert.Equal(("Diff.cs", 42, 8), jumped); // Two rows below `class Diff`, pinned over the top row. Assert.Equal(42 - 1 - 2, topRow); @@ -162,11 +162,12 @@ public async Task Without_the_server_installed_opening_a_file_and_Ctrl_G_D_say_h await HostSteps.Run(host, () => { OpenFile(workbench, "Caller.cs"); }, - () => { opened = workbench.StatusBar.DisplayedText; workbench.StatusBar.SetMessage("elsewhere"); }, + () => workbench.StatusBar.Bar.Current is not null, + () => { opened = workbench.StatusBar.DisplayedText; workbench.StatusBar.ShowMessage("elsewhere"); }, () => { Execute(host); }, () => { pressed = workbench.StatusBar.DisplayedText; }); - Assert.Equal("No C# language server. Install: dotnet tool install -g csharp-ls • C#", opened); + Assert.Equal("No C# language server. Install: dotnet tool install -g csharp-ls", opened); Assert.Equal(opened, pressed); Assert.Equal(("Caller.cs", 0, 0), Where(workbench)); } @@ -182,23 +183,23 @@ public async Task While_loading_the_status_bar_says_so_Ctrl_G_D_waits_and_the_me await HostSteps.Run(host, () => { OpenFile(workbench, "Caller.cs"); }, - () => workbench.StatusBar.DisplayedText.EndsWith("C# ◌ loading", StringComparison.Ordinal), - () => { loading = workbench.StatusBar.DisplayedText; Execute(host); }, + () => workbench.StatusBar.DisplayedState.Contains("C# ◌ loading", StringComparison.Ordinal), + () => { loading = workbench.StatusBar.DisplayedState; Execute(host); }, () => { pressed = workbench.StatusBar.Message; host.App.InjectKey(Key.G.WithAlt); }, () => workbench.MenuBar.IsOpen(), () => { dimmedWhileLoading = Item(host).Dimmed; host.App.InjectKey(Key.Esc); }, () => !workbench.MenuBar.IsOpen(), () => { _server.FinishLoading(); }, () => Ready(workbench), - () => { ready = workbench.StatusBar.DisplayedText; host.App.InjectKey(Key.G.WithAlt); }, + () => { ready = workbench.StatusBar.DisplayedState; host.App.InjectKey(Key.G.WithAlt); }, () => workbench.MenuBar.IsOpen(), () => { dimmedWhenReady = Item(host).Dimmed; host.App.InjectKey(Key.Esc); }, () => !workbench.MenuBar.IsOpen()); - Assert.EndsWith("C# ◌ loading", loading, StringComparison.Ordinal); + Assert.Contains("C# ◌ loading", loading, StringComparison.Ordinal); Assert.Equal("The C# language server is still loading", pressed); Assert.True(dimmedWhileLoading); - Assert.EndsWith("C# ● ready", ready, StringComparison.Ordinal); + Assert.Contains("C# ● ready", ready, StringComparison.Ordinal); Assert.False(dimmedWhenReady); } @@ -213,14 +214,38 @@ await HostSteps.Run(host, () => { OpenFile(workbench, "Caller.cs"); }, () => Ready(workbench), () => { _server.Crash(); }, - () => workbench.StatusBar.DisplayedText.EndsWith("C# stopped", StringComparison.Ordinal), + () => workbench.StatusBar.DisplayedState.Contains("C# stopped", StringComparison.Ordinal), () => { OpenFile(workbench, "Diff.cs"); }, - () => { shown = workbench.StatusBar.DisplayedText; }); + () => { shown = workbench.StatusBar.DisplayedState; }); - Assert.EndsWith("C# stopped", shown, StringComparison.Ordinal); + Assert.Contains("C# stopped", shown, StringComparison.Ordinal); Assert.Single(_server.Launches); } + [Fact] + public async Task A_server_that_fails_says_so_once_and_the_message_comes_off_when_it_restarts() + { + _server.InitializeError = "no solution found"; + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + var said = ""; + + await HostSteps.Run(host, + () => { OpenFile(workbench, "Caller.cs"); }, + () => workbench.StatusBar.ShowsError, + () => { said = workbench.StatusBar.DisplayedText; OpenFile(workbench, "Diff.cs"); }, + () => + { + _server.InitializeError = null; + workbench.Languages!.Configure(new Dictionary { ["csharp"] = new("csharp-ls", ["--loglevel", "error"]) }); + }, + () => Ready(workbench)); + + Assert.StartsWith("C# language server stopped: ", said, StringComparison.Ordinal); + Assert.Single(workbench.StatusBar.Bar.History, message => message.Text == said); + Assert.Null(workbench.StatusBar.Bar.Current); + } + [Fact] public async Task A_file_without_a_server_shows_just_its_language_and_Ctrl_G_D_says_there_is_none() { @@ -230,10 +255,10 @@ public async Task A_file_without_a_server_shows_just_its_language_and_Ctrl_G_D_s await HostSteps.Run(host, () => { OpenFile(workbench, "README.md"); }, - () => { opened = workbench.StatusBar.DisplayedText; Execute(host); }, + () => { opened = workbench.StatusBar.DisplayedState; Execute(host); }, () => { pressed = workbench.StatusBar.Message; }); - Assert.EndsWith(" • Markdown • Wrap", opened, StringComparison.Ordinal); + Assert.StartsWith("Markdown • Wrap", opened, StringComparison.Ordinal); Assert.Equal("No language server for Markdown", pressed); Assert.Empty(_server.Launches); } @@ -262,7 +287,7 @@ private void Add(string path, string content) } private static bool Ready(Workbench.Workbench workbench) => - workbench.StatusBar.DisplayedText.EndsWith("C# ● ready", StringComparison.Ordinal); + workbench.StatusBar.DisplayedState.Contains("C# ● ready", StringComparison.Ordinal); private static void Execute(WorkbenchHost host) { diff --git a/tests/TuiCode.Tests/GrammarSelectionTests.cs b/tests/TuiCode.Tests/GrammarSelectionTests.cs index bf3585da..c9c38c2c 100644 --- a/tests/TuiCode.Tests/GrammarSelectionTests.cs +++ b/tests/TuiCode.Tests/GrammarSelectionTests.cs @@ -171,7 +171,7 @@ await HostSteps.Run(host, () => { tab = workbench.Editor.Group.ActiveTab; - before = workbench.StatusBar.DisplayedText; + before = $"{workbench.StatusBar.DisplayedText} | {workbench.StatusBar.DisplayedState}"; _commands.TryExecute(CommandIds.ChangeGrammar); }, () => workbench.SubViews.OfType().Any(), @@ -179,9 +179,9 @@ await HostSteps.Run(host, () => { host.App.InjectKey(Key.Enter); }, () => !workbench.SubViews.OfType().Any()); - Assert.Equal($"{_fs.Path.GetFullPath("/work/notes.txt")} • Plain Text • Wrap", before); + Assert.StartsWith($"{_fs.Path.GetFullPath("/work/notes.txt")} | Plain Text • Wrap", before); Assert.Equal("json", tab!.Grammar?.Id); - Assert.EndsWith(" • JSON • Wrap", workbench.StatusBar.DisplayedText); + Assert.StartsWith("JSON • Wrap", workbench.StatusBar.DisplayedState); } [Fact] diff --git a/tests/TuiCode.Tests/LogFileTests.cs b/tests/TuiCode.Tests/LogFileTests.cs index e49c7707..f0cfbcef 100644 --- a/tests/TuiCode.Tests/LogFileTests.cs +++ b/tests/TuiCode.Tests/LogFileTests.cs @@ -57,7 +57,7 @@ public void Information_and_debug_are_not_written_or_counted() logger.LogDebug("Detail"); Assert.False(_fs.File.Exists(log.Path)); - Assert.Equal(0, log.Unseen); + Assert.Empty(log.TakeWarnings()); } [Fact] @@ -88,18 +88,17 @@ public void The_first_warning_starts_the_session_once() } [Fact] - public void Unseen_counts_each_warning_clears_when_seen_and_counts_again_after() + public void TakeWarnings_hands_each_warning_over_once_with_its_exception() { var log = Log(); var logger = log.CreateLogger("test"); logger.LogWarning("one"); - logger.LogWarning("two"); - var before = log.Unseen; - log.MarkSeen(); - var seen = log.Unseen; + logger.LogWarning(new IOException("too many open files"), "Not watching /work"); + var first = log.TakeWarnings(); logger.LogWarning("three"); - Assert.Equal((2, 0, 1), (before, seen, log.Unseen)); + Assert.Equal(["one", "Not watching /work: too many open files"], first); + Assert.Equal(["three"], log.TakeWarnings()); } } diff --git a/tests/TuiCode.Tests/LogHostTests.cs b/tests/TuiCode.Tests/LogHostTests.cs index 65c52f40..5ce98eb3 100644 --- a/tests/TuiCode.Tests/LogHostTests.cs +++ b/tests/TuiCode.Tests/LogHostTests.cs @@ -1,3 +1,4 @@ +using MentalDesk.Tui.Chrome; using Microsoft.Extensions.Logging; using TuiCode.Abstractions; using TuiCode.Explorer; @@ -27,22 +28,49 @@ public LogHostTests() } [Fact] - public async Task Show_log_opens_the_log_and_clears_the_count_until_the_next_warning() + public async Task Log_warnings_count_in_the_bar_and_list_in_messages_without_showing() { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench); List shown = []; + var message = ""; + IReadOnlyList listed = []; await HostSteps.Run(host, () => _logger.LogWarning("one"), () => _logger.LogWarning("two"), - () => shown.Add(workbench.StatusBar.DisplayedWarnings), + () => + { + shown.Add(workbench.StatusBar.DisplayedWarnings); + message = workbench.StatusBar.DisplayedText; + }, () => _commands.TryExecute(CommandIds.ShowLog), () => shown.Add(workbench.StatusBar.DisplayedWarnings), + () => _commands.TryExecute(CommandIds.ShowMessages), + () => + { + shown.Add(workbench.StatusBar.DisplayedWarnings); + listed = [.. Messages(workbench).Rows.Select(row => row.Text)]; + }, + () => host.App.InjectKey(Key.Esc), () => _logger.LogWarning("three"), () => shown.Add(workbench.StatusBar.DisplayedWarnings)); - Assert.Equal(["⚠ 2", "", "⚠ 1"], shown); + Assert.Equal(["⚠ 2", "⚠ 2", "", "⚠ 1"], shown); + Assert.Equal(workbench.StatusBar.DefaultMessage, message); + Assert.Equal(["two", "one"], listed); + } + + [Fact] + public async Task Show_log_opens_the_log() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + + await HostSteps.Run(host, + () => _logger.LogWarning("two"), + () => _commands.TryExecute(CommandIds.ShowLog)); + Assert.Equal(_log.File.FullName, workbench.Editor.Group.ActiveTab?.File.FullName); Assert.Contains("WARN two", workbench.Editor.Group.ActiveTab!.Content); } @@ -63,7 +91,7 @@ await HostSteps.Run(host, } [Fact] - public async Task Clicking_the_count_opens_the_log_and_clears_it() + public async Task Clicking_the_count_opens_messages_and_clears_it() { using var workbench = BuildWorkbench(); using var host = BuildHost(workbench); @@ -82,7 +110,7 @@ await HostSteps.Run(host, () => { shown = workbench.StatusBar.DisplayedWarnings; }); Assert.Equal("", shown); - Assert.Equal(_log.File.FullName, workbench.Editor.Group.ActiveTab?.File.FullName); + Assert.NotNull(Messages(workbench)); } [Theory] @@ -111,6 +139,22 @@ public void Show_log_sits_below_show_diagnostics_in_the_help_menu() Assert.Equal(Array.IndexOf(help, CommandIds.ShowDiagnostics) + 1, Array.IndexOf(help, CommandIds.ShowLog)); } + [Fact] + public void Show_messages_sits_below_show_log_in_the_help_menu() + { + var help = CommandMenu.Layout.Single(menu => menu.Title == "_Help").Ids; + + Assert.Equal(Array.IndexOf(help, CommandIds.ShowLog) + 1, Array.IndexOf(help, CommandIds.ShowMessages)); + } + + [Fact] + public void Show_messages_is_sm() + { + Assert.Equal("sm", CommandMnemonics.For(CommandIds.ShowMessages)); + } + + private static MessagesDialog Messages(Workbench.Workbench workbench) => workbench.SubViews.OfType().Single(); + private Workbench.Workbench BuildWorkbench() { var workbench = new Workbench.Workbench(new SidebarPart(new FileExplorerView()), new EditorPart(), new StatusBarPart()); diff --git a/tests/TuiCode.Tests/ReviewHostTests.cs b/tests/TuiCode.Tests/ReviewHostTests.cs index 409231c8..926b90ad 100644 --- a/tests/TuiCode.Tests/ReviewHostTests.cs +++ b/tests/TuiCode.Tests/ReviewHostTests.cs @@ -1,3 +1,5 @@ +using MentalDesk.Tui; +using MentalDesk.Tui.Chrome; using MentalDesk.Tui.Loading; using TuiCode.Abstractions; using TuiCode.Editor; @@ -281,6 +283,34 @@ await HostSteps.Run(host, Assert.False(review.LoadState!.Reloading); } + [Fact] + public async Task A_failed_refresh_is_an_error_that_comes_off_when_a_refresh_works() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench, out var commands); + var error = false; + + await HostSteps.Run(host, + () => commands.TryExecute(CommandIds.FocusReview), + () => workbench.Sidebar.Review.ChangedFiles.Count == 3, + () => + { + _git.Missing = true; + commands.TryExecute(CommandIds.FocusReview); + }, + () => workbench.StatusBar.ShowsError, + () => + { + error = workbench.StatusBar.Bar.Current is { Kind: MessageKind.News, Severity: Severity.Error }; + _git.Missing = false; + commands.TryExecute(CommandIds.FocusReview); + }, + () => workbench.StatusBar.Bar.Current is null); + + Assert.True(error); + Assert.Equal("⚠ 1", workbench.StatusBar.DisplayedWarnings); + } + private Workbench.Workbench BuildWorkbench() { var sidebar = new SidebarPart(new FileExplorerView(), review: new ReviewView(_git, _gitHub)); diff --git a/tests/TuiCode.Tests/ReviewStepHostTests.cs b/tests/TuiCode.Tests/ReviewStepHostTests.cs index cccc9bf3..f68063a2 100644 --- a/tests/TuiCode.Tests/ReviewStepHostTests.cs +++ b/tests/TuiCode.Tests/ReviewStepHostTests.cs @@ -130,7 +130,7 @@ await HostSteps.Run(host, [ .. OpenDiff(host, workbench, commands, rowsDown: 3), () => group.ActiveDiffTab is { IsDeleted: true, IsFocused: true }, - () => workbench.StatusBar.DisplayedText.Contains("File 4 of 4 \u2022 \u22122 \u2022 Change 1 of 1", StringComparison.Ordinal), + () => workbench.StatusBar.DisplayedState.Contains("File 4 of 4 \u2022 \u22122 \u2022 Change 1 of 1", StringComparison.Ordinal), ]); var selected = Assert.IsType(workbench.Sidebar.Review.Files.SelectedObject); @@ -170,7 +170,7 @@ .. OpenDiff(host, workbench, commands, rowsDown: 0), () => commands.TryExecute(CommandIds.NextChange), () => commands.TryExecute(CommandIds.NextChange), () => group.ActiveDiffTab?.File.Name == "c.txt", - () => workbench.StatusBar.DisplayedText.Contains("File 3 of 4 • +1 −1 • Change 1 of 1", StringComparison.Ordinal), + () => workbench.StatusBar.DisplayedState.Contains("File 3 of 4 • +1 −1 • Change 1 of 1", StringComparison.Ordinal), ]); var selected = Assert.IsType(workbench.Sidebar.Review.Files.SelectedObject); diff --git a/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs b/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs index 05edc46a..91db9843 100644 --- a/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs +++ b/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs @@ -375,7 +375,7 @@ public void Every_command_that_keeps_no_scope_is_one_whose_key_belongs_in_every_ CommandIds.New, CommandIds.NextEditor, CommandIds.Open, CommandIds.OpenPullRequest, CommandIds.OpenRecentFolder, CommandIds.OpenFilePath, CommandIds.OpenSettings, CommandIds.OpenWorktree, CommandIds.PreviousEditor, CommandIds.PullRequestOverview, CommandIds.Quit, CommandIds.RefreshExplorer, CommandIds.ReopenClosedEditor, CommandIds.ReplaceGlobally, CommandIds.ReplaceInFile, CommandIds.SaveActiveEditor, CommandIds.SaveAll, - CommandIds.ShowAbout, CommandIds.ShowActions, CommandIds.ShowDiagnostics, CommandIds.ShowLog, + CommandIds.ShowAbout, CommandIds.ShowActions, CommandIds.ShowDiagnostics, CommandIds.ShowLog, CommandIds.ShowMessages, CommandIds.ShowExplorer, CommandIds.ShowHelp, CommandIds.ShowMenu, CommandIds.ShowMnemonics, CommandIds.SubmitReview, CommandIds.SuspendToShell, CommandIds.ToggleFileHistory, CommandIds.ToggleGutter, CommandIds.ToggleSidebar, CommandIds.ToggleViewed, CommandIds.WatchTutorials, CommandIds.WidenSidebar, // Gated on a find box having the keys: the bar's keys are in Editor, the pane's in Find. diff --git a/tests/TuiCode.Tests/StatusBarPartTests.cs b/tests/TuiCode.Tests/StatusBarPartTests.cs index c877bbbb..189a3d4a 100644 --- a/tests/TuiCode.Tests/StatusBarPartTests.cs +++ b/tests/TuiCode.Tests/StatusBarPartTests.cs @@ -1,14 +1,25 @@ +using MentalDesk.Tui; +using Terminal.Gui.ViewBase; using TuiCode.Workbench.Parts; namespace TuiCode.Tests; public class StatusBarPartTests { + private readonly FakeClock _clock = new(); + + private StatusBarPart Bar(int width = 80) + { + var bar = new StatusBarPart { Width = width }; + bar.Bar.Clock = _clock; + return bar; + } + [Fact] public void SetHelpKey_names_the_key_at_the_end_and_leaves_the_message_alone() { - using var bar = new StatusBarPart(); - bar.SetMessage("/work/a.txt"); + using var bar = Bar(); + bar.SetResting("/work/a.txt"); bar.SetHelpKey("F3"); @@ -17,46 +28,107 @@ public void SetHelpKey_names_the_key_at_the_end_and_leaves_the_message_alone() } [Fact] - public void ClearMessage_reverts_to_the_default_only_while_that_message_shows() + public void With_nothing_to_say_the_bar_shows_the_resting_text_or_TuiCode() + { + using var bar = Bar(); + + var idle = bar.DisplayedText; + bar.SetResting("/work/a.txt"); + var file = bar.DisplayedText; + bar.SetResting(null); + + Assert.Equal(("TuiCode", "/work/a.txt", "TuiCode"), (idle, file, bar.DisplayedText)); + } + + [Fact] + public void A_reply_goes_at_the_next_key_and_the_path_comes_back() + { + using var bar = Bar(); + bar.SetResting("/work/a.txt"); + bar.ShowMessage("Saved: /work/a.txt"); + var said = bar.DisplayedText; + + bar.Bar.KeyPressed(); + + Assert.Equal("Saved: /work/a.txt", said); + Assert.Equal("/work/a.txt", bar.DisplayedText); + } + + [Fact] + public void A_reply_goes_after_five_seconds_with_no_key() + { + using var bar = Bar(); + bar.SetResting("/work/a.txt"); + bar.ShowMessage("Copied 3 lines"); + + _clock.Advance(TimeSpan.FromSeconds(5)); + + Assert.Equal("/work/a.txt", bar.DisplayedText); + } + + [Fact] + public void An_error_that_happened_by_itself_outlasts_typing_and_goes_at_the_first_key_after_five_seconds() { - using var bar = new StatusBarPart(); - bar.SetMessage("Cut: /work/a.txt"); + using var bar = Bar(); + bar.ShowNews("Couldn't refresh the review: gh: HTTP 502", Severity.Error); + + bar.Bar.KeyPressed(); + _clock.Advance(TimeSpan.FromSeconds(6)); + var later = (bar.DisplayedText, bar.ShowsError); + bar.Bar.KeyPressed(); - bar.ClearMessage("Cut: /work/b.txt"); - Assert.Equal("Cut: /work/a.txt", bar.DisplayedText); + Assert.Equal(("Couldn't refresh the review: gh: HTTP 502", true), later); + Assert.Equal("TuiCode", bar.DisplayedText); + Assert.Equal("⚠ 1", bar.DisplayedWarnings); + } - bar.ClearMessage("Cut: /work/a.txt"); + [Fact] + public void Withdraw_takes_a_message_off_only_while_it_shows() + { + using var bar = Bar(); + var cut = bar.ShowMessage("Cut: /work/a.txt"); + bar.ShowMessage("Cut: /work/b.txt"); + + bar.Withdraw(cut); + var other = bar.DisplayedText; + bar.Withdraw(bar.Bar.Current); + + Assert.Equal("Cut: /work/b.txt", other); Assert.Equal(bar.DefaultMessage, bar.DisplayedText); } [Fact] - public void The_grammar_follows_the_message_after_a_separator() + public void The_grammar_mode_wrap_diff_and_position_sit_together_at_the_right_not_after_the_message() { - using var bar = new StatusBarPart(); - bar.SetMessage("/work/a.cs"); + using var bar = Bar(); + bar.SetResting("/work/a.cs"); bar.SetGrammar("C#"); - Assert.Equal("/work/a.cs • C#", bar.DisplayedText); + bar.SetMode("Column select"); + bar.SetWrap(true); + bar.SetDiffStatus("File 2 of 7 • Change 1 of 4"); + bar.SetPosition((41, 8)); - bar.SetGrammar(null); Assert.Equal("/work/a.cs", bar.DisplayedText); + Assert.Equal("C# • Column select • Wrap • File 2 of 7 • Change 1 of 4 • Ln 42, Col 9", bar.DisplayedState); } [Fact] - public void An_in_flight_chord_hides_the_grammar() + public void An_in_flight_chord_shows_in_the_message_slot() { - using var bar = new StatusBarPart(); + using var bar = Bar(); bar.SetGrammar("C#"); bar.SetChord("Ctrl+G"); Assert.Equal("Ctrl+G…", bar.DisplayedText); + Assert.Equal("C#", bar.DisplayedState); } [Fact] public void Position_shows_one_based_and_null_hides_it() { - using var bar = new StatusBarPart(); + using var bar = Bar(); bar.SetPosition((41, 6)); Assert.Equal("Ln 42, Col 7", bar.DisplayedPosition); @@ -66,50 +138,36 @@ public void Position_shows_one_based_and_null_hides_it() } [Fact] - public void The_help_hint_ends_the_bar_with_or_without_a_position() + public void The_help_hint_ends_the_bar_with_the_position_before_it() { - using var bar = new StatusBarPart { Width = 60 }; - bar.Layout(new System.Drawing.Size(60, 1)); - var idle = bar.DisplayedHelp; - + using var bar = Bar(60); bar.SetPosition((0, 0)); - bar.Layout(new System.Drawing.Size(60, 1)); - var help = bar.SubViews.Single(v => v.Text == "Press F1 for help"); - var position = bar.SubViews.Single(v => v.Text == "Ln 1, Col 1"); - Assert.Equal("Press F1 for help", idle); - Assert.Equal(60 - 1, help.Frame.Right); - Assert.Equal(help.Frame.X - 3, position.Frame.Right); - } - - [Fact] - public void A_message_never_replaces_the_help_hint() - { - using var bar = new StatusBarPart(); - - bar.SetMessage("Saved a.txt"); + bar.Layout(new System.Drawing.Size(60, 1)); - Assert.Equal("Press F1 for help", bar.DisplayedHelp); + var help = Find(bar, "Press F1 for help"); + var position = Find(bar, "Ln 1, Col 1"); + Assert.Equal(60 - 1, help.FrameToScreen().Right); + Assert.Equal(help.FrameToScreen().X - 5, position.FrameToScreen().Right); } [Fact] public void An_unbound_help_key_leaves_the_position_at_the_right_edge() { - using var bar = new StatusBarPart { Width = 40 }; + using var bar = Bar(40); bar.SetPosition((0, 0)); bar.SetHelpKey(null); bar.Layout(new System.Drawing.Size(40, 1)); - var position = bar.SubViews.Single(v => v.Text == "Ln 1, Col 1"); Assert.Equal("", bar.DisplayedHelp); - Assert.Equal(40 - 1, position.Frame.Right); + Assert.Equal(40 - 1, Find(bar, "Ln 1, Col 1").FrameToScreen().Right); } [Fact] public void A_selection_at_one_caret_shows_its_count_after_the_position() { - using var bar = new StatusBarPart(); + using var bar = Bar(); bar.SetPosition((11, 4)); bar.SetSelection(1, 34); @@ -120,7 +178,7 @@ public void A_selection_at_one_caret_shows_its_count_after_the_position() [Fact] public void Several_carets_show_how_many_in_place_of_the_position() { - using var bar = new StatusBarPart(); + using var bar = Bar(); bar.SetPosition((11, 4)); bar.SetSelection(3, 96); @@ -134,7 +192,7 @@ public void Several_carets_show_how_many_in_place_of_the_position() [Fact] public void Clearing_the_selection_goes_back_to_the_position() { - using var bar = new StatusBarPart(); + using var bar = Bar(); bar.SetPosition((11, 4)); bar.SetSelection(3, 96); @@ -144,19 +202,23 @@ public void Clearing_the_selection_goes_back_to_the_position() } [Fact] - public void Position_sits_before_the_help_hint_and_a_long_message_stops_short_of_it() + public void A_long_message_stops_short_of_the_right_end() { - using var bar = new StatusBarPart { Width = 40 }; - bar.SetMessage(new string('x', 60)); + using var bar = Bar(60); + bar.ShowMessage(new string('x', 80)); + bar.SetGrammar("C#"); bar.SetPosition((0, 0)); - bar.Layout(new System.Drawing.Size(40, 1)); + bar.Layout(new System.Drawing.Size(60, 1)); - var position = bar.SubViews.Single(v => v.Text == "Ln 1, Col 1"); - var message = bar.SubViews.Single(v => v.Text == new string('x', 60)); - var help = bar.SubViews.Single(v => v.Text == "Press F1 for help"); - Assert.Equal(40 - 1, help.Frame.Right); - Assert.Equal(help.Frame.X - 3, position.Frame.Right); - Assert.Equal(position.Frame.X - 2, message.Frame.Right); + var message = Find(bar, new string('x', 80)); + var grammar = Find(bar, "C#"); + Assert.True(message.FrameToScreen().Right < grammar.FrameToScreen().X); + Assert.Equal(60 - 1, Find(bar, "Press F1 for help").FrameToScreen().Right); } + + private static View Find(View root, string text) => TryFind(root, text)!; + + private static View? TryFind(View root, string text) => + root.Text == text ? root : root.SubViews.Select(view => TryFind(view, text)).FirstOrDefault(view => view is not null); } diff --git a/tests/TuiCode.Tests/StatusMessagesHostTests.cs b/tests/TuiCode.Tests/StatusMessagesHostTests.cs new file mode 100644 index 00000000..40b254ea --- /dev/null +++ b/tests/TuiCode.Tests/StatusMessagesHostTests.cs @@ -0,0 +1,95 @@ +using MentalDesk.Tui.Chrome; +using Terminal.Gui.Drivers; +using TuiCode.Explorer; +using TuiCode.Workbench; +using TuiCode.Workbench.Parts; +using TuiCode.Workbench.Services; + +namespace TuiCode.Tests; + +// Replies, news and the resting path on the status bar (#592). Boots a TG Application — serialised (#77). +public class StatusMessagesHostTests : StaticConfigurationTest +{ + private readonly MockFileSystem _fs = new(); + + public StatusMessagesHostTests() + { + _fs.AddFile("/work/a.txt", new MockFileData("alpha\n")); + _fs.AddFile("/work/b.txt", new MockFileData("bravo\n")); + } + + [Fact] + public async Task Ctrl_S_replies_with_the_file_saved_and_the_next_key_puts_the_path_back() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + var path = _fs.Path.GetFullPath("/work/a.txt"); + var said = ""; + MessageKind? kind = null; + + await HostSteps.Run(host, + () => + { + var tab = Open(workbench, "/work/a.txt"); + tab.Content = "alpha!\n"; + tab.FocusContent(); + }, + () => host.App.InjectKey(Key.S.WithCtrl), + () => + { + said = workbench.StatusBar.DisplayedText; + kind = workbench.StatusBar.Bar.Current?.Kind; + host.App.InjectKey(Key.CursorRight); + }, + () => { }); + + Assert.Equal($"Saved: {path}", said); + Assert.Equal(MessageKind.Reply, kind); + Assert.Equal(path, workbench.StatusBar.DisplayedText); + } + + [Fact] + public async Task The_resting_text_follows_the_active_tab_and_is_TuiCode_with_none_open() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + List shown = []; + + await HostSteps.Run(host, + () => { Open(workbench, "/work/a.txt"); }, + () => { Open(workbench, "/work/b.txt"); }, + () => + { + shown.Add(workbench.StatusBar.DisplayedText); + workbench.Editor.Group.Focus(_fs.Path.GetFullPath("/work/a.txt")); + }, + () => + { + shown.Add(workbench.StatusBar.DisplayedText); + workbench.CloseAllTabs(); + }, + () => shown.Add(workbench.StatusBar.DisplayedText)); + + Assert.Equal([_fs.Path.GetFullPath("/work/b.txt"), _fs.Path.GetFullPath("/work/a.txt"), "TuiCode"], shown); + } + + private TuiCode.Editor.EditorTab Open(Workbench.Workbench workbench, string path) + { + workbench.OpenFile(_fs.FileInfo.New(path)); + return workbench.Editor.Group.ActiveTab!; + } + + private Workbench.Workbench BuildWorkbench() + { + var workbench = new Workbench.Workbench(new SidebarPart(new FileExplorerView()), new EditorPart(), new StatusBarPart()); + workbench.Sidebar.Explorer.Open(_fs.DirectoryInfo.New("/work")); + return workbench; + } + + private WorkbenchHost BuildHost(Workbench.Workbench workbench) + { + var commands = new CommandService(); + return new WorkbenchHost(workbench, commands, new KeybindingService(commands), new InputScopeStack(), + new InMemorySettingsService(), driverName: DriverRegistry.Names.ANSI, fileSystem: _fs); + } +} diff --git a/tests/TuiCode.Tests/ViewedDiffHostTests.cs b/tests/TuiCode.Tests/ViewedDiffHostTests.cs index ce09b64f..2e2211bd 100644 --- a/tests/TuiCode.Tests/ViewedDiffHostTests.cs +++ b/tests/TuiCode.Tests/ViewedDiffHostTests.cs @@ -104,10 +104,10 @@ await HostSteps.Run(host, [ .. OpenDiff(host, workbench, commands, rowsDown: 2), () => group.ActiveDiffTab?.File.Name == "c.txt" && group.ActiveDiffTab.IsFocused, - () => workbench.StatusBar.DisplayedText.Contains("File 3 of 4 • Viewed • +1 −1", StringComparison.Ordinal), + () => workbench.StatusBar.DisplayedState.Contains("File 3 of 4 • Viewed • +1 −1", StringComparison.Ordinal), () => commands.TryExecute(CommandIds.ToggleViewed), () => workbench.Sidebar.Review.ViewedText == "Viewed 0 of 4", - () => workbench.StatusBar.DisplayedText.Contains("File 3 of 4 • +1 −1", StringComparison.Ordinal), + () => workbench.StatusBar.DisplayedState.Contains("File 3 of 4 • +1 −1", StringComparison.Ordinal), ]); Assert.Equal(("PR_kw398", "c.txt", false), _gitHub.ViewedChanges.Single()); diff --git a/tests/TuiCode.Tests/WatchTutorialsHostTests.cs b/tests/TuiCode.Tests/WatchTutorialsHostTests.cs index 824618d6..f4668890 100644 --- a/tests/TuiCode.Tests/WatchTutorialsHostTests.cs +++ b/tests/TuiCode.Tests/WatchTutorialsHostTests.cs @@ -45,7 +45,7 @@ public void It_heads_the_help_menus_second_group_with_letter_t() Assert.Equal( [ "Show _keys", "_Show all commands", "Show _mnemonics", - "Watch _tutorials", "Show _diagnostics", "Show _log", "_About TuiCode", + "Watch _tutorials", "Show _diagnostics", "Show _log", "Show m_essages", "_About TuiCode", ], help.Select(i => i.Title)); }