From 6a9c3bb8fafb015ac8e0d5f2fb005e0ff5b98226 Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:09:57 +1300 Subject: [PATCH 1/3] Add Undo close tab (Ctrl+Shift+T) to reopen closed file tabs Closing a tab by mistake meant finding the file again and losing its undo history. Each file tab that closes is now remembered (up to 20 per session) with its group, strip index, cursor and undo steps, and Ctrl+Shift+T / uct / File > Undo close tab brings the newest back. The strip index is taken as each tab goes, so after Close other or Close all tabs repeated presses rebuild the strip in order. Undo is restored only when the reopened file's lines are exactly those the steps were recorded against, VS Code's rule, so a file changed on disk or closed dirty with Don't save reopens with fresh history. Files open again or deleted are skipped; a folder switch clears the list. Closes #578 Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 1 + src/TuiCode.Abstractions/CommandIds.cs | 1 + src/TuiCode.Abstractions/CommandMnemonics.cs | 1 + src/TuiCode.Editor/ClosedTabs.cs | 36 ++++ src/TuiCode.Editor/CodeEditor.History.cs | 19 +- src/TuiCode.Editor/EditorGroup.cs | 14 +- src/TuiCode.Editor/EditorGroups.cs | 30 ++- src/TuiCode.Editor/EditorTab.cs | 2 + src/TuiCode.Workbench/Menus/CommandMenu.cs | 4 +- src/TuiCode.Workbench/Workbench.cs | 8 + src/TuiCode.Workbench/WorkbenchHost.cs | 4 + tests/TuiCode.Tests/ClosedTabsTests.cs | 60 ++++++ tests/TuiCode.Tests/HelpPlaceHostTests.cs | 17 ++ tests/TuiCode.Tests/MenuBarHostTests.cs | 2 +- .../ScopedKeybindingsHostTests.cs | 2 +- tests/TuiCode.Tests/UndoCloseTabHostTests.cs | 73 +++++++ tests/TuiCode.Tests/UndoCloseTabTests.cs | 202 ++++++++++++++++++ 17 files changed, 467 insertions(+), 9 deletions(-) create mode 100644 src/TuiCode.Editor/ClosedTabs.cs create mode 100644 tests/TuiCode.Tests/ClosedTabsTests.cs create mode 100644 tests/TuiCode.Tests/UndoCloseTabHostTests.cs create mode 100644 tests/TuiCode.Tests/UndoCloseTabTests.cs diff --git a/AGENTS.md b/AGENTS.md index ef28ccb..7665b34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -330,6 +330,7 @@ What follows is how TuiCode implements it today. - **A tab is dirty only while saving would change the file (#550).** `EditorTab` keeps the text it last read or wrote and, after every edit, compares it with what `Save` would write, line endings and final newline included, so undo, redo or retyping back to it clears `●` and every prompt that reads `IsDirty`. `MatchesSaved` compares lengths first, then the lines in place, so typing in a large file builds no string. A file lacking a final newline therefore stays dirty after edit-and-undo while *Insert final newline* is on, since saving would add one. - **Closing a dirty tab asks first (#409).** `Ctrl+W` / `cf` on an `EditorTab` with unsaved edits opens a `ConfirmView`: `[ Save ] [ Don't save ] [< Cancel >]`, Cancel focused. Save goes through the same #267 check as `Ctrl+S`; over a changed file the conflict prompt appears and only **Overwrite** closes the tab. Diff and document tabs close without asking, and the command is enabled on any tab, document tabs included. - **Close other tabs / Close all tabs (#518)** (`cot`/`cat`, File menu, no default key). Other closes the focused group's `EditorGroup.Others`, every tab but the active one and an active diff's file; the other group is untouched. All closes both groups. Both ask once, with *Quit*'s `AskToSave` dialog, about only the dirty tabs they'd close, and `Workbench` saves the workspace state afterwards, since closing tabs other than the active one changes no `ActiveTab`. +- **Undo close tab (#578)** (`Ctrl+Shift+T`, `uct`, File menu; the id is VS Code's `reopenClosedEditor`, so `reopen` finds it in the palette). `EditorGroups` pushes every file tab `EditorGroup.Close` closes onto `ClosedTabs` (newest last, capped at 20): its group, its index in the strip at that moment, its `ViewPosition` and `CodeEditor.History`. Indices taken as each tab goes are what make *Close other* / *Close all* come back in order one press at a time. A reopen skips, and drops, files open again or gone; it goes into the group it left, or last in the first once the second has gone. The undo steps are put back only when the freshly read lines equal those they were recorded against, so a file changed on disk, or closed dirty with *Don't save*, reopens with fresh history. `EditorGroups.CloseAll` (a folder switch) clears the list. TG's `Tabs` draws no close button, so every close already goes through `EditorGroup.Close`. - **Reload from disk on demand (#272)** (`rd`, `Editor` scope, no default key) is `DiskChanges.Reload` with `evenIfUnchanged`, so a dirty tab over a file nobody touched still drops its edits. A clean tab just reloads; a dirty one confirms first through `ConfirmView`, Cancel focused; a `Gone` file is refused, since the buffer is the only copy. - **A watcher hears nothing about its own directory being removed**, so every directory *above* an open file is watched as well, all the way to the root, with `NotifyFilters.DirectoryName` in the filter, and `DiskWatcher` maps a directory event onto every file it follows beneath it. Without both halves, `rm -rf ` around an open file is completely silent on macOS — no file event, no error, the FSEvents stream simply reports the directory and a non-recursive watcher rooted there filters it out. Whichever directory in the chain goes, the watcher one level above it is the one that says so, which is why stopping at the parent wasn't enough. A directory that holds no open file of its own asks for `DirectoryName` and nothing else, so passing through `$HOME` costs no file events. - **An in-app delete leaves no marker**: `Workbench.Delete` closes the tabs through `EditorGroup.CloseUnder` first, so by the time the watcher's event is flushed the path is neither followed nor open, and both the flush filter and the tab lookup drop it. diff --git a/src/TuiCode.Abstractions/CommandIds.cs b/src/TuiCode.Abstractions/CommandIds.cs index c85d5fc..41243b6 100644 --- a/src/TuiCode.Abstractions/CommandIds.cs +++ b/src/TuiCode.Abstractions/CommandIds.cs @@ -9,6 +9,7 @@ public static class CommandIds public const string CloseActiveEditor = "workbench.action.closeActiveEditor"; public const string CloseOtherTabs = "workbench.action.closeOtherEditors"; public const string CloseAllTabs = "workbench.action.closeAllEditors"; + public const string ReopenClosedEditor = "workbench.action.reopenClosedEditor"; public const string NextEditor = "workbench.action.nextEditor"; public const string PreviousEditor = "workbench.action.previousEditor"; public const string MoveToOtherGroup = "workbench.action.moveEditorToOtherGroup"; diff --git a/src/TuiCode.Abstractions/CommandMnemonics.cs b/src/TuiCode.Abstractions/CommandMnemonics.cs index c2f9ece..be893cb 100644 --- a/src/TuiCode.Abstractions/CommandMnemonics.cs +++ b/src/TuiCode.Abstractions/CommandMnemonics.cs @@ -66,6 +66,7 @@ private static IReadOnlyDictionary Build() [CommandIds.CloseActiveEditor] = "cf", [CommandIds.CloseOtherTabs] = "cot", [CommandIds.CloseAllTabs] = "cat", + [CommandIds.ReopenClosedEditor] = "uct", [CommandIds.ChangeGrammar] = "cg", [CommandIds.CompareToSaved] = "cts", [CommandIds.NextChange] = "nc", diff --git a/src/TuiCode.Editor/ClosedTabs.cs b/src/TuiCode.Editor/ClosedTabs.cs new file mode 100644 index 0000000..69319ee --- /dev/null +++ b/src/TuiCode.Editor/ClosedTabs.cs @@ -0,0 +1,36 @@ +namespace TuiCode.Editor; + +/// A file tab as it was when it closed: where it sat, where its cursor was, and its undo history (#578). +internal sealed record ClosedTab(IFileInfo File, int Group, int Index, ViewPosition Position, CodeEditor.UndoHistory History); + +/// The file tabs closed this session, newest first, for Undo close tab (#578). +internal sealed class ClosedTabs +{ + public const int Max = 20; + + private readonly List _newestLast = []; + + public int Count => _newestLast.Count; + + public void Push(ClosedTab tab) + { + _newestLast.Add(tab); + if (_newestLast.Count > Max) _newestLast.RemoveAt(0); + } + + public void Clear() => _newestLast.Clear(); + + public bool Any(Func reopenable) => _newestLast.Any(t => reopenable(t.File)); + + /// Takes the newest tab accepts, dropping the newer ones it doesn't. + public ClosedTab? Take(Func reopenable) + { + while (_newestLast.Count > 0) + { + var tab = _newestLast[^1]; + _newestLast.RemoveAt(_newestLast.Count - 1); + if (reopenable(tab.File)) return tab; + } + return null; + } +} diff --git a/src/TuiCode.Editor/CodeEditor.History.cs b/src/TuiCode.Editor/CodeEditor.History.cs index e4744b2..352c020 100644 --- a/src/TuiCode.Editor/CodeEditor.History.cs +++ b/src/TuiCode.Editor/CodeEditor.History.cs @@ -3,9 +3,9 @@ namespace TuiCode.Editor; // Undo is ours, not Editor's, so a step restores every caret: see "Multiple cursors and undo" in AGENTS.md. internal sealed partial class CodeEditor { - private sealed record EditGroup(Change[] Changes, Caret[] Before, Caret[] After); + internal sealed record EditGroup(Change[] Changes, Caret[] Before, Caret[] After); - private readonly record struct Change(int OldStart, string[] Old, int NewStart, string[] New); + internal readonly record struct Change(int OldStart, string[] Old, int NewStart, string[] New); private readonly List _undo = []; private int _undone; @@ -78,6 +78,21 @@ private void Record(List hunks, Caret[] before) Replay(changes, undo: false, applyToDocument: false); } + /// The undo and redo steps, for a tab reopened later on the same lines (#578). + internal UndoHistory History => new([.. _undo], _undone, [.. _recorded]); + + internal sealed class UndoHistory(EditGroup[] steps, int undone, string[] lines) + { + /// Puts the steps back on , unless its lines aren't the ones they were recorded against. + internal void RestoreTo(CodeEditor editor) + { + if (!lines.SequenceEqual(editor._recorded, StringComparer.Ordinal)) return; + editor._undo.Clear(); + editor._undo.AddRange(steps); + editor._undone = undone; + } + } + public bool Undo() => Step(undo: true); public bool Redo() => Step(undo: false); diff --git a/src/TuiCode.Editor/EditorGroup.cs b/src/TuiCode.Editor/EditorGroup.cs index 74453d3..84832ba 100644 --- a/src/TuiCode.Editor/EditorGroup.cs +++ b/src/TuiCode.Editor/EditorGroup.cs @@ -116,6 +116,15 @@ private EditorTab Opening(EditorTab tab) return tab; } + /// Opens 's file at , or last, with its cursor and, while the file is unchanged, its undo (#578). + internal EditorTab Reopen(ClosedTab closed, int? index) + { + var tab = Opening(new EditorTab(closed.File, _syntax)); + closed.History.RestoreTo(tab.Code); + tab.RestoreViewPosition(closed.Position); + return Track(tab, index); + } + /// Opens a read-only Markdown document that isn't on disk (#185), like a PR's Overview. public DocumentTab OpenDocument(IFileInfo file, string content, string? title = null) { @@ -144,7 +153,7 @@ public DocumentTab OpenDocument(IFileInfo file, string content, string? title = return tab; } - private EditorTab Track(EditorTab tab) + private EditorTab Track(EditorTab tab, int? index = null) { tab.GutterVisible = GutterVisible; tab.ColumnSelect = ColumnSelect; @@ -156,7 +165,8 @@ private EditorTab Track(EditorTab tab) // see — and announce it before that, so the file is being watched by the time anything reacts to it (#269). _byPath[tab.File.FullName] = tab; TabsChanged?.Invoke(this, EventArgs.Empty); - Add(tab); + if (index is { } at) InsertTab(at, tab); + else Add(tab); Value = tab; return tab; } diff --git a/src/TuiCode.Editor/EditorGroups.cs b/src/TuiCode.Editor/EditorGroups.cs index 33aa4b4..909fb66 100644 --- a/src/TuiCode.Editor/EditorGroups.cs +++ b/src/TuiCode.Editor/EditorGroups.cs @@ -10,6 +10,7 @@ namespace TuiCode.Editor; public sealed class EditorGroups { private readonly EditorGroup[] _all; + private readonly ClosedTabs _closed = new(); public event EventHandler? FileSaved; public event EventHandler? Copied; @@ -40,7 +41,11 @@ public EditorGroups(EditorGroup first, EditorGroup second) group.CursorMoved += (_, e) => CursorMoved?.Invoke(this, e); group.PinnedLineClicked += (_, e) => PinnedLineClicked?.Invoke(this, e); group.EditorOpening += (_, tab) => EditorOpening?.Invoke(this, tab); - group.EditorClosing += (_, tab) => EditorClosing?.Invoke(this, tab); + group.EditorClosing += (sender, tab) => + { + Remember((EditorGroup)sender!, tab); + EditorClosing?.Invoke(this, tab); + }; group.ActiveTabChanged += (_, _) => { // A tab opened into the second group lands after its TabsChanged, so the split shows up here. @@ -174,6 +179,29 @@ public void CloseAll() Second.CloseAll(); First.CloseAll(); Focused = First; + _closed.Clear(); + } + + /// Whether a closed file tab can come back: one that isn't open again and still exists. + public bool CanReopen => _closed.Any(Reopenable); + + /// Reopens the newest closed file tab that can come back, in its place, or at the end of the first group once its own has gone; null when none can. + public EditorTab? ReopenClosed() + { + if (_closed.Take(Reopenable) is not { } closed) return null; + var stillThere = closed.Group == 0 || IsSplit; + Focused = stillThere ? _all[closed.Group] : First; + return Focused.Reopen(closed, stillThere ? closed.Index : null); + } + + private bool Reopenable(IFileInfo file) => + Holding(file.FullName) is null && file.FileSystem.File.Exists(file.FullName); + + private void Remember(EditorGroup group, EditorTab tab) + { + var strip = group.TabCollection.ToList(); + var index = strip.Take(strip.IndexOf(tab)).Count(t => t is not DiffTab diff || diff.Source != tab); + _closed.Push(new ClosedTab(tab.File, Array.IndexOf(_all, group), index, tab.ViewPosition, tab.History)); } public void InferGrammars() diff --git a/src/TuiCode.Editor/EditorTab.cs b/src/TuiCode.Editor/EditorTab.cs index 6a7383e..57cd4fd 100644 --- a/src/TuiCode.Editor/EditorTab.cs +++ b/src/TuiCode.Editor/EditorTab.cs @@ -372,6 +372,8 @@ public void MoveCursorToCharacter(int row, int character) internal CodeEditor Code => _text; + internal CodeEditor.UndoHistory History => _text.History; + /// A screen row while wrapped. public int TopRow => _text.Viewport.Y; diff --git a/src/TuiCode.Workbench/Menus/CommandMenu.cs b/src/TuiCode.Workbench/Menus/CommandMenu.cs index e7f66b3..e007934 100644 --- a/src/TuiCode.Workbench/Menus/CommandMenu.cs +++ b/src/TuiCode.Workbench/Menus/CommandMenu.cs @@ -31,7 +31,7 @@ .. Enumerable.Range(1, 9).Select(CommandIds.FocusEditorByIndex), { [CommandIds.New] = 'n', [CommandIds.Open] = 'o', [CommandIds.OpenRecentFolder] = 'r', [CommandIds.OpenWorktree] = 'w', [CommandIds.OpenFilePath] = 'f', [CommandIds.SaveActiveEditor] = 's', [CommandIds.SaveAll] = 'a', - [CommandIds.ReloadFromDisk] = 'l', [CommandIds.CloseActiveEditor] = 'c', [CommandIds.CloseOtherTabs] = 'e', [CommandIds.CloseAllTabs] = 'b', + [CommandIds.ReloadFromDisk] = 'l', [CommandIds.CloseActiveEditor] = 'c', [CommandIds.CloseOtherTabs] = 'e', [CommandIds.CloseAllTabs] = 'b', [CommandIds.ReopenClosedEditor] = 'u', [CommandIds.RenameFile] = 'm', [CommandIds.CutFile] = 't', [CommandIds.PasteFile] = 'p', [CommandIds.DeleteFile] = 'd', [CommandIds.DeletePermanently] = 'y', [CommandIds.ShowDocumentInfo] = 'i', [CommandIds.OpenSettings] = 'g', [CommandIds.SuspendToShell] = 'h', @@ -73,7 +73,7 @@ public static readonly (string Title, string[] Ids)[] Layout = [ ("_File", [ - CommandIds.New, CommandIds.Open, CommandIds.OpenRecentFolder, CommandIds.OpenWorktree, CommandIds.OpenFilePath, CommandIds.SaveActiveEditor, CommandIds.SaveAll, CommandIds.ReloadFromDisk, CommandIds.CloseActiveEditor, CommandIds.CloseOtherTabs, CommandIds.CloseAllTabs, + CommandIds.New, CommandIds.Open, CommandIds.OpenRecentFolder, CommandIds.OpenWorktree, CommandIds.OpenFilePath, CommandIds.SaveActiveEditor, CommandIds.SaveAll, CommandIds.ReloadFromDisk, CommandIds.CloseActiveEditor, CommandIds.CloseOtherTabs, CommandIds.CloseAllTabs, CommandIds.ReopenClosedEditor, Separator, CommandIds.RenameFile, CommandIds.CutFile, CommandIds.PasteFile, CommandIds.DeleteFile, CommandIds.DeletePermanently, Separator, diff --git a/src/TuiCode.Workbench/Workbench.cs b/src/TuiCode.Workbench/Workbench.cs index 53094f7..a21a5ab 100644 --- a/src/TuiCode.Workbench/Workbench.cs +++ b/src/TuiCode.Workbench/Workbench.cs @@ -221,6 +221,14 @@ public void OpenFile(IFileInfo 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)); + FileOpened?.Invoke(this, EventArgs.Empty); + } + /// Open a file with selected — where a search result lands. public void OpenMatch(IFileInfo file, TextMatch match) { diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index 39e7a1b..37814b3 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -599,6 +599,8 @@ private void RegisterDefaultCommands() () => Group.Value is not null); _commands.Register(CommandIds.CloseAllTabs, "Close all tabs", CloseAllTabs, CommandScope.Global, () => Group.Value is not null); + _commands.Register(CommandIds.ReopenClosedEditor, "Undo close tab", _workbench.ReopenClosedTab, CommandScope.Global, + () => _workbench.Editor.Groups.CanReopen); _commands.Register(CommandIds.NextEditor, "Next tab", () => CycleTabs(1)); _commands.Register(CommandIds.PreviousEditor, "Previous tab", () => CycleTabs(-1)); _commands.Register(CommandIds.MoveToOtherGroup, "Move to other group", MoveToOtherGroup, CommandScope.Global, @@ -853,6 +855,7 @@ .. HelpRowOf(CommandIds.PreviousEditor, "Previous tab"), .. HelpRowOf(CommandIds.CloseActiveEditor, "Close tab"), .. HelpRowOf(CommandIds.CloseOtherTabs, "Close other tabs"), .. HelpRowOf(CommandIds.CloseAllTabs, "Close all tabs"), + .. HelpRowOf(CommandIds.ReopenClosedEditor, "Undo close tab"), .. GroupHelp(), ]); @@ -981,6 +984,7 @@ private static void BindDefaults(IKeybindingService keybindings) keybindings.Bind("Ctrl+S", CommandIds.SaveActiveEditor); keybindings.Bind("Ctrl+Shift+S", CommandIds.SaveAll); keybindings.Bind("Ctrl+W", CommandIds.CloseActiveEditor); + keybindings.Bind("Ctrl+Shift+T", CommandIds.ReopenClosedEditor); // Not Ctrl+Tab, which Terminal.app and iTerm2 keep (#254), nor Alt+Tab, Windows' app switcher (#459). keybindings.Bind("Ctrl+PageDown", CommandIds.NextEditor); keybindings.Bind("Ctrl+PageUp", CommandIds.PreviousEditor); diff --git a/tests/TuiCode.Tests/ClosedTabsTests.cs b/tests/TuiCode.Tests/ClosedTabsTests.cs new file mode 100644 index 0000000..e49c086 --- /dev/null +++ b/tests/TuiCode.Tests/ClosedTabsTests.cs @@ -0,0 +1,60 @@ +using TuiCode.Editor; + +namespace TuiCode.Tests; + +// The list behind Undo close tab (#578). +public sealed class ClosedTabsTests +{ + private readonly MockFileSystem _fs = new(); + private readonly ClosedTabs _closed = new(); + + [Fact] + public void Take_gives_the_newest_first() + { + _closed.Push(Closed("a.txt")); + _closed.Push(Closed("b.txt")); + + Assert.Equal("b.txt", _closed.Take(_ => true)!.File.Name); + Assert.Equal("a.txt", _closed.Take(_ => true)!.File.Name); + Assert.Null(_closed.Take(_ => true)); + } + + [Fact] + public void Push_forgets_the_oldest_past_twenty() + { + for (var i = 0; i <= ClosedTabs.Max; i++) + _closed.Push(Closed($"{i}.txt")); + + Assert.Equal(ClosedTabs.Max, _closed.Count); + Assert.False(_closed.Any(f => f.Name == "0.txt")); + Assert.True(_closed.Any(f => f.Name == "1.txt")); + } + + [Fact] + public void Take_skips_and_drops_tabs_that_cant_come_back() + { + _closed.Push(Closed("a.txt")); + _closed.Push(Closed("open.txt")); + _closed.Push(Closed("deleted.txt")); + + Assert.Equal("a.txt", _closed.Take(f => f.Name == "a.txt")!.File.Name); + Assert.Equal(0, _closed.Count); + } + + [Fact] + public void Any_says_whether_one_can_come_back() + { + Assert.False(_closed.Any(_ => true)); + _closed.Push(Closed("a.txt")); + + Assert.True(_closed.Any(_ => true)); + Assert.False(_closed.Any(_ => false)); + } + + private ClosedTab Closed(string name) + { + _fs.AddFile($"/work/{name}", new MockFileData("one\n")); + using var tab = new EditorTab(_fs.FileInfo.New($"/work/{name}")); + return new ClosedTab(tab.File, 0, 0, tab.ViewPosition, tab.History); + } +} diff --git a/tests/TuiCode.Tests/HelpPlaceHostTests.cs b/tests/TuiCode.Tests/HelpPlaceHostTests.cs index 280632b..07b4833 100644 --- a/tests/TuiCode.Tests/HelpPlaceHostTests.cs +++ b/tests/TuiCode.Tests/HelpPlaceHostTests.cs @@ -170,6 +170,23 @@ public async Task F1_on_the_tab_strip_lists_the_tab_keys() ], help.Rows); } + [Fact] + public async Task The_tabs_column_lists_undo_close_tab_once_a_tab_has_closed() + { + _fs.AddFile("/work/a.txt", new MockFileData("one")); + _fs.AddFile("/work/b.txt", new MockFileData("two")); + var help = await HelpFrom(() => + { + Built.OpenFile(_fs.FileInfo.New("/work/a.txt")); + Built.OpenFile(_fs.FileInfo.New("/work/b.txt")); + _commands.TryExecute(CommandIds.CloseActiveEditor); + _commands.TryExecute(CommandIds.FocusEditorTabStrip); + }, "Tabs"); + + var close = help.Rows.ToList().IndexOf(new("Ctrl+W", "Close tab")); + Assert.Equal(new HelpRow("Ctrl+Shift+T", "Undo close tab"), help.Rows[close + 1]); + } + [Fact] public async Task The_tabs_column_follows_a_rebind_of_next_tab() { diff --git a/tests/TuiCode.Tests/MenuBarHostTests.cs b/tests/TuiCode.Tests/MenuBarHostTests.cs index 746964b..f262a58 100644 --- a/tests/TuiCode.Tests/MenuBarHostTests.cs +++ b/tests/TuiCode.Tests/MenuBarHostTests.cs @@ -85,7 +85,7 @@ public void The_File_menus_letters_match_the_pitch() Assert.Equal( [ "_New file or folder", "_Open file or folder", "Open _recent folder", "Open _worktree", "Open _file path", - "_Save active editor", "Save _all", "Re_load from disk", "_Close active editor", "Clos_e other tabs", "Close all ta_bs", + "_Save active editor", "Save _all", "Re_load from disk", "_Close active editor", "Clos_e other tabs", "Close all ta_bs", "_Undo close tab", "_Move or rename file or folder", "Cu_t file or folder", "_Paste file or folder", "_Delete file or folder", "Delete permanentl_y", "Show document _info", "Open settin_gs", "Suspend to s_hell", "_Quit", ], file.Select(i => i.Title)); diff --git a/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs b/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs index d8d534d..48cd549 100644 --- a/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs +++ b/tests/TuiCode.Tests/ScopedKeybindingsHostTests.cs @@ -370,7 +370,7 @@ public void Every_command_that_keeps_no_scope_is_one_whose_key_belongs_in_every_ CommandIds.JoinGroups, CommandIds.MoveToOtherGroup, CommandIds.NarrowSidebar, 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.ReplaceGlobally, CommandIds.ReplaceInFile, CommandIds.SaveActiveEditor, CommandIds.SaveAll, + CommandIds.Quit, CommandIds.RefreshExplorer, CommandIds.ReopenClosedEditor, CommandIds.ReplaceGlobally, CommandIds.ReplaceInFile, CommandIds.SaveActiveEditor, CommandIds.SaveAll, CommandIds.ShowAbout, CommandIds.ShowActions, CommandIds.ShowDiagnostics, CommandIds.ShowLog, CommandIds.ShowExplorer, CommandIds.ShowHelp, CommandIds.ShowMenu, CommandIds.ShowMnemonics, CommandIds.SubmitReview, CommandIds.SuspendToShell, CommandIds.ToggleFileHistory, CommandIds.ToggleGutter, CommandIds.ToggleSidebar, CommandIds.ToggleViewed, CommandIds.WatchTutorials, CommandIds.WidenSidebar, diff --git a/tests/TuiCode.Tests/UndoCloseTabHostTests.cs b/tests/TuiCode.Tests/UndoCloseTabHostTests.cs new file mode 100644 index 0000000..208849c --- /dev/null +++ b/tests/TuiCode.Tests/UndoCloseTabHostTests.cs @@ -0,0 +1,73 @@ +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using TuiCode.Abstractions; +using TuiCode.Explorer; +using TuiCode.Workbench; +using TuiCode.Workbench.Menus; +using TuiCode.Workbench.Parts; +using TuiCode.Workbench.Services; + +namespace TuiCode.Tests; + +// Undo close tab (#578). Boots a TG Application — serialised (#77). +public class UndoCloseTabHostTests : StaticConfigurationTest +{ + private readonly MockFileSystem _fs = new(); + private readonly CommandService _commands = new(); + private readonly KeybindingService _keybindings; + + public UndoCloseTabHostTests() + { + _keybindings = new KeybindingService(_commands); + _fs.AddDirectory("/work"); + foreach (var name in new[] { "a.txt", "b.txt" }) + _fs.AddFile($"/work/{name}", new MockFileData("one\n")); + } + + [Fact] + public void It_follows_close_all_tabs_in_the_file_menu_on_ctrl_shift_t() + { + using var workbench = BuildWorkbench(); + using var _ = BuildHost(workbench); + + var file = CommandMenu.Layout.Single(m => m.Title == "_File").Ids; + Assert.Equal(CommandIds.ReopenClosedEditor, file[Array.IndexOf(file, CommandIds.CloseAllTabs) + 1]); + Assert.Equal('u', CommandMenu.Letters[CommandIds.ReopenClosedEditor]); + Assert.Equal("uct", CommandMnemonics.For(CommandIds.ReopenClosedEditor)); + Assert.Equal("Undo close tab", _commands.Registered.Single(c => c.Id == CommandIds.ReopenClosedEditor).Label); + Assert.Contains(_keybindings.Bindings, b => b.CommandId == CommandIds.ReopenClosedEditor && b.Display == "Ctrl+Shift+T"); + Assert.Contains("reopen", CommandIds.ReopenClosedEditor, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Ctrl_shift_t_after_closing_the_last_tab_brings_it_back_with_the_keys() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + var enabled = new List(); + + await HostSteps.Run(host, + () => workbench.OpenFile(_fs.FileInfo.New("/work/a.txt")), + () => enabled.Add(_commands.IsEnabled(CommandIds.ReopenClosedEditor)), + () => host.App.InjectKey(Key.W.WithCtrl), + () => workbench.Editor.Group.Value is null, + () => enabled.Add(_commands.IsEnabled(CommandIds.ReopenClosedEditor)), + () => host.App.InjectKey(Key.T.WithCtrl.WithShift), + () => workbench.Editor.Group.ActiveTab is not null && workbench.StatusBar.DisplayedFocus == "Editor", + () => enabled.Add(_commands.IsEnabled(CommandIds.ReopenClosedEditor))); + + Assert.Equal([false, true, false], enabled); + Assert.Equal("a.txt", workbench.Editor.Group.ActiveTab!.File.Name); + } + + 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) => + new(workbench, _commands, _keybindings, new InputScopeStack(), + new InMemorySettingsService(), driverName: DriverRegistry.Names.ANSI); +} diff --git a/tests/TuiCode.Tests/UndoCloseTabTests.cs b/tests/TuiCode.Tests/UndoCloseTabTests.cs new file mode 100644 index 0000000..07d2f84 --- /dev/null +++ b/tests/TuiCode.Tests/UndoCloseTabTests.cs @@ -0,0 +1,202 @@ +using TuiCode.Editor; + +namespace TuiCode.Tests; + +// Undo close tab brings the last closed file tab back where it was, with its undo (#578). +public sealed class UndoCloseTabTests : IDisposable +{ + private readonly MockFileSystem _fs = new(); + private readonly EditorGroups _groups = new(new EditorGroup(), new EditorGroup()); + + public void Dispose() + { + _groups.First.Dispose(); + _groups.Second.Dispose(); + } + + [Fact] + public void Reopening_puts_the_tab_back_in_its_place_and_makes_it_active() + { + Open("a.txt"); + var b = Open("b.txt"); + Open("c.txt"); + _groups.First.CloseEditor(b); + + var reopened = _groups.ReopenClosed(); + + Assert.Equal(["a.txt", "b.txt", "c.txt"], Strip(_groups.First)); + Assert.Same(reopened, _groups.First.ActiveTab); + } + + [Fact] + public void Reopening_puts_the_cursor_back() + { + var a = Open("a.txt", "one\ntwo\nthree\n"); + a.MoveCursor(2, 3); + _groups.First.CloseEditor(a); + + var reopened = _groups.ReopenClosed()!; + + Assert.Equal((2, 3), (reopened.CursorRow, reopened.CursorColumn)); + } + + [Fact] + public void Undo_and_redo_carry_on_while_the_file_on_disk_is_what_the_tab_held() + { + var a = Open("a.txt", "one\n"); + a.Code.NewKeyDownEvent(Key.X); + a.Save(); + _groups.First.CloseEditor(a); + + var reopened = _groups.ReopenClosed()!; + reopened.Code.Undo(); + var undone = reopened.Lines; + reopened.Code.Redo(); + + Assert.Equal(["one", ""], undone); + Assert.Equal(["xone", ""], reopened.Lines); + } + + [Fact] + public void A_file_changed_while_closed_comes_back_as_it_is_on_disk_with_nothing_to_undo() + { + var a = Open("a.txt", "one\n"); + a.Code.NewKeyDownEvent(Key.X); + a.Save(); + _groups.First.CloseEditor(a); + _fs.File.WriteAllText("/work/a.txt", "theirs\n"); + + var reopened = _groups.ReopenClosed()!; + reopened.Code.Undo(); + + Assert.Equal(["theirs", ""], reopened.Lines); + } + + [Fact] + public void A_tab_closed_without_saving_comes_back_as_it_is_on_disk_with_nothing_to_undo() + { + var a = Open("a.txt", "one\n"); + a.Code.NewKeyDownEvent(Key.X); + _groups.First.CloseEditor(a); + + var reopened = _groups.ReopenClosed()!; + reopened.Code.Undo(); + + Assert.Equal(["one", ""], reopened.Lines); + Assert.False(reopened.IsDirty); + } + + [Fact] + public void Close_other_tabs_brings_each_back_newest_first_in_its_place() + { + Open("a.txt"); + Open("b.txt"); + Open("c.txt"); + Open("d.txt"); + _groups.First.FocusByIndex(2); + _groups.First.Close(_groups.First.Others); + + var order = new[] { _groups.ReopenClosed()!, _groups.ReopenClosed()!, _groups.ReopenClosed()! }.Select(t => t.File.Name); + + Assert.Equal(["d.txt", "b.txt", "a.txt"], order); + Assert.Equal(["a.txt", "b.txt", "c.txt", "d.txt"], Strip(_groups.First)); + } + + [Fact] + public void Reopening_works_with_no_tab_open() + { + var a = Open("a.txt"); + _groups.First.CloseEditor(a); + Assert.Null(_groups.First.Value); + + Assert.Equal("a.txt", _groups.ReopenClosed()!.File.Name); + } + + [Fact] + public void A_file_open_again_or_deleted_is_skipped_for_the_next() + { + var a = Open("a.txt"); + var b = Open("b.txt"); + var c = Open("c.txt"); + Open("d.txt"); + _groups.First.CloseEditor(a); + _groups.First.CloseEditor(b); + _groups.First.CloseEditor(c); + Open("c.txt"); + _fs.File.Delete("/work/b.txt"); + + Assert.Equal("a.txt", _groups.ReopenClosed()!.File.Name); + Assert.False(_groups.CanReopen); + Assert.Null(_groups.ReopenClosed()); + } + + [Fact] + public void Nothing_is_reopenable_until_a_file_tab_closes() + { + var a = Open("a.txt", "one\n"); + a.Content = "two\n"; + Assert.False(_groups.CanReopen); + + var diff = _groups.First.CompareToSaved(a)!; + _groups.First.CloseDiff(diff); + Assert.False(_groups.CanReopen); + + _groups.First.CloseEditor(a); + Assert.True(_groups.CanReopen); + } + + [Fact] + public void A_tab_reopens_in_the_second_group_while_it_is_there() + { + Open("a.txt"); + Open("b.txt"); + Open("c.txt"); + _groups.MoveActiveToOther(); + _groups.Focused = _groups.First; + _groups.MoveActiveToOther(); + var b = _groups.Second.Tabs.Single(t => t.File.Name == "b.txt"); + _groups.Second.CloseEditor(b); + + _groups.Focused = _groups.First; + _groups.ReopenClosed(); + + Assert.Same(_groups.Second, _groups.Focused); + Assert.Equal(["c.txt", "b.txt"], Strip(_groups.Second)); + } + + [Fact] + public void A_tab_from_a_second_group_that_has_gone_reopens_in_the_first() + { + Open("a.txt"); + var b = Open("b.txt"); + _groups.MoveActiveToOther(); + _groups.Second.CloseEditor(b); + Assert.False(_groups.IsSplit); + + _groups.ReopenClosed(); + + Assert.False(_groups.IsSplit); + Assert.Equal(["a.txt", "b.txt"], Strip(_groups.First)); + } + + [Fact] + public void Switching_folders_forgets_the_closed_tabs() + { + var a = Open("a.txt"); + _groups.First.CloseEditor(a); + + _groups.CloseAll(); + + Assert.False(_groups.CanReopen); + } + + private EditorTab Open(string name, string content = "one\n") + { + var path = $"/work/{name}"; + if (!_fs.File.Exists(path)) _fs.AddFile(path, new MockFileData(content)); + return _groups.Open(_fs.FileInfo.New(path)); + } + + private static string[] Strip(EditorGroup group) => + [.. group.TabCollection.OfType().Select(t => t.File.Name)]; +} From 5a0138da26428421ee2e7415997c0d467bd827a9 Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:58:15 +1300 Subject: [PATCH 2/3] Move UnsavedEditsHostTests onto ConfirmDialog #581 and #582 merged in parallel: #581's test still looked for ConfirmView, which #582 removed, so main no longer builds its tests. Closes #578 Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/TuiCode.Tests/UnsavedEditsHostTests.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/tests/TuiCode.Tests/UnsavedEditsHostTests.cs b/tests/TuiCode.Tests/UnsavedEditsHostTests.cs index da7ddb3..cc5bef9 100644 --- a/tests/TuiCode.Tests/UnsavedEditsHostTests.cs +++ b/tests/TuiCode.Tests/UnsavedEditsHostTests.cs @@ -87,21 +87,12 @@ await HostSteps.RunUntilQuit(host, workbench.UnsavedEdits!.Flush(); }, () => host.App.InjectKey(Key.Q.WithCtrl), - () => Confirm(workbench) is not null, - () => - { - if (Confirm(workbench)?.FocusedChoice == "Don't save") return true; - host.App.InjectKey(Key.Tab); - return false; - }, - () => host.App.InjectKey(Key.Enter)); + () => ConfirmDialogs.Of(workbench) is not null, + ConfirmDialogs.Choose(host, workbench, "Don't save")); Assert.Empty(_fs.Directory.GetFiles(Recovery)); } - private static ConfirmView? Confirm(Workbench.Workbench workbench) => - workbench.SubViews.OfType().SingleOrDefault(); - private Workbench.Workbench BuildWorkbench(RecoveryOwner owner) => new(new SidebarPart(new FileExplorerView()), new EditorPart(), new StatusBarPart(), _workspaces, recovery: new RecoveryStore(_fs, Recovery, owner, _ => false)); From 1ea30a7753c34000109ff9ef7c63ad82f8690a4b Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:12:33 +1300 Subject: [PATCH 3/3] Wait for the next session's recovery writes before counting copies A_renamed_file_takes_its_copy_with_it counted the recovery folder while the next session's background write was still mid-rename, so it occasionally saw a leftover .tmp file. Recovering_takes_over_the_copy already waits; this test now does too. Closes #578 Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/TuiCode.Tests/UnsavedEditsTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/TuiCode.Tests/UnsavedEditsTests.cs b/tests/TuiCode.Tests/UnsavedEditsTests.cs index ea898d5..09b8486 100644 --- a/tests/TuiCode.Tests/UnsavedEditsTests.cs +++ b/tests/TuiCode.Tests/UnsavedEditsTests.cs @@ -273,6 +273,7 @@ public void A_renamed_file_takes_its_copy_with_it() using var next = Session(Next); next.OpenFile(File("/work/renamed.txt")); + next.UnsavedEdits!.Wait(); Assert.Equal(["mine", ""], next.Editor.Group.ActiveTab!.Lines); Assert.Single(Copies()); }