diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 29c197d..098452b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Each pane now keeps its own folder tabs, living in the path-bar area: inactive tabs sit as compact chips above the bar, the active tab is the breadcrumb bar itself, and new-tab and close-tab buttons sit at the bar's right end. Every tab remembers its own back/forward history, the session restores both panes' tabs, and the shortcuts are Ctrl+T (new tab), Ctrl+W (close tab) and Ctrl+Tab / Ctrl+Shift+Tab (cycle); middle-clicking a chip closes that tab. +- Each pane now keeps its own folder tabs, living in the path-bar area: inactive tabs sit as compact chips above the bar, the active tab is the breadcrumb bar itself, and new-tab and close-tab buttons sit at the bar's right end. Every tab remembers its own back/forward history, the session restores both panes' tabs, and the shortcuts are Ctrl+T (new tab), Ctrl+W (close tab) and Ctrl+Tab / Ctrl+Shift+Tab (cycle); middle-clicking a chip closes that tab. Closed tabs are remembered (the last 50, with their history) and Ctrl+Shift+T reopens the most recently closed one. - Files can be created from templates: each pane's right-click menu gains "New from template", listing the files in Meridian's Templates folder (in the local app data folder). Picking one copies it into the current folder, numbered while the name is taken, and "Open templates folder" in the same menu is where you add your own. The first time the folder is needed it is created with four starters (a text file, a Markdown note, a web page, a CSV sheet); a folder that already holds anything is never topped up. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a84d300..359b2d0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -127,7 +127,7 @@ Avoid starting large Phase 2/3 experiments until these release blockers are reso ## Phase 1 — Everyday workflow - [x] Paired workspace tabs with independent left/right locations and session persistence; later: reordering, locking, pinning, close-left/right, and color accents -- [ ] Closed-tab history: remember the last 50 closed tabs and reopen the most recently closed with Ctrl+Shift+T (per-pane tabs, the prerequisite, are delivered — each pane keeps its own tabs in the path-bar area and the session restores them; the history itself is still to come) +- [x] Closed-tab history: remember the last 50 closed tabs and reopen the most recently closed with Ctrl+Shift+T (per-pane tabs are the foundation — each pane keeps its own tabs in the path-bar area and the session restores them; a reopened tab also gets back the back/forward history it had when it closed) - [ ] Flexible one-, two-, and four-pane layouts, including horizontal/vertical splits and adjustable split ratios - [x] Named directory bookmarks and paired tabs with saved paths/tab names and active-tab index; interactive restoration checks remain open - [x] Named workspace collections, alternate pane layouts and complete focus/layout restoration diff --git a/src/Meridian.App/MainWindow.Tabs.cs b/src/Meridian.App/MainWindow.Tabs.cs index 9cd47ea..8c45251 100644 --- a/src/Meridian.App/MainWindow.Tabs.cs +++ b/src/Meridian.App/MainWindow.Tabs.cs @@ -11,9 +11,9 @@ namespace Meridian; /// inactive tabs are compact chips above the bar, the active tab is the /// breadcrumb bar itself, with new-tab and close-tab buttons at its right /// end). Each tab keeps its own back/forward history; the window swaps -/// them in and out of the pane as the active tab changes. Closing records -/// nothing here — the closed-tab history layer hooks CloseTabAsync's -/// result. The set always keeps at least one tab. +/// them in and out of the pane as the active tab changes. Closed tabs are +/// recorded in the closed-tab history and reopen with Ctrl+Shift+T. The +/// set always keeps at least one tab. /// public sealed partial class MainWindow { @@ -113,12 +113,36 @@ private async Task NewTabAsync(PaneState pane) SetStatus("A pane always keeps one tab.", StatusSeverity.Info); return null; } + // The closed tab joins the reopenable history (with the history it + // held, when it was the active one and got synced above). + _ = _closedTabStore.RecordAsync(new ClosedTab(closed.Path, closed.Back, closed.Forward, DateTimeOffset.UtcNow)); if (wasActive) await LoadActiveTabAsync(pane); else RebuildTabStrip(pane); _ = PersistSessionAsync(); return closed; } + /// + /// Reopens the most recently closed tab in the given pane, restoring + /// the back/forward history it had when it closed. + /// + private async Task ReopenClosedTabAsync(PaneState pane) + { + var entry = await _closedTabStore.TakeMostRecentAsync(); + if (entry is null) + { + SetStatus("No closed tabs to reopen.", StatusSeverity.Info); + return; + } + SyncActiveTabFromPane(pane); + var set = EnsureTabs(pane); + set.Add(entry.Path); + set.Active.Back = entry.Back; + set.Active.Forward = entry.Forward; + await LoadActiveTabAsync(pane); + _ = PersistSessionAsync(); + } + private async Task CycleTabAsync(PaneState pane, int delta) { var set = EnsureTabs(pane); @@ -157,4 +181,10 @@ private async void PreviousTabAccelerator_Invoked(KeyboardAccelerator sender, Ke args.Handled = true; await CycleTabAsync(_activePane, -1); } + + private async void ReopenClosedTabAccelerator_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + args.Handled = true; + await ReopenClosedTabAsync(_activePane); + } } diff --git a/src/Meridian.App/MainWindow.xaml b/src/Meridian.App/MainWindow.xaml index 4fcada8..f6cfa9f 100644 --- a/src/Meridian.App/MainWindow.xaml +++ b/src/Meridian.App/MainWindow.xaml @@ -134,6 +134,7 @@ + diff --git a/src/Meridian.App/MainWindow.xaml.cs b/src/Meridian.App/MainWindow.xaml.cs index 35fceae..bd858a5 100644 --- a/src/Meridian.App/MainWindow.xaml.cs +++ b/src/Meridian.App/MainWindow.xaml.cs @@ -47,6 +47,7 @@ public sealed partial class MainWindow : Window private readonly RecentPathsStore _recentsStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "recents.json")); private readonly FolderViewStore _folderViewStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "folder-views.json")); private readonly SavedSearchStore _savedSearchStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "saved-searches.json")); + private readonly ClosedTabStore _closedTabStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "closed-tabs.json")); private readonly NamedWorkspaceStore _namedWorkspaceStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "named-workspaces.json")); private readonly FolderSizeCache _folderSizeCache = new(); private readonly FolderSizeStore _folderSizeStore = new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Meridian", "folder-sizes.json")); @@ -269,6 +270,7 @@ private async Task InitializeAsync(string documents, string downloads) _right.FolderTreeVisible = session?.RightFolderTreeVisible ?? false; await _folderViewStore.LoadAsync(); await _savedSearchStore.LoadAsync(); + await _closedTabStore.LoadAsync(); await _namedWorkspaceStore.LoadAsync(); _folderViewsLoaded = true; await _recentsStore.LoadAsync(); diff --git a/src/Meridian.Core/Services/ClosedTabs.cs b/src/Meridian.Core/Services/ClosedTabs.cs new file mode 100644 index 0000000..e7c45a5 --- /dev/null +++ b/src/Meridian.Core/Services/ClosedTabs.cs @@ -0,0 +1,111 @@ +using System.Text.Json; + +namespace Meridian.Services; + +/// +/// One closed folder tab, remembered so it can be reopened: the folder it +/// showed and the back/forward history it held when it closed (top-first, +/// the same shape carries). +/// +public sealed record ClosedTab(string Path, string[] Back, string[] Forward, DateTimeOffset ClosedUtc); + +/// +/// The durable history of closed tabs, most recently closed first, capped +/// at 50. Written to a JSON file (temp file + atomic move, following +/// ); a missing or damaged file starts +/// empty, and saving is best-effort — recording a closed tab must never +/// surface as an error. +/// +public sealed class ClosedTabStore +{ + public const int MaximumEntries = 50; + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private readonly string _filePath; + private readonly List _entries = []; // most recently closed first + + public ClosedTabStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + _filePath = Path.GetFullPath(filePath); + } + + public IReadOnlyList Entries => _entries; + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + _entries.Clear(); + try + { + if (!File.Exists(_filePath)) + return; + var json = await File.ReadAllTextAsync(_filePath, cancellationToken); + var loaded = JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + foreach (var entry in loaded) + { + if (entry is null || string.IsNullOrWhiteSpace(entry.Path)) + continue; + _entries.Add(entry with + { + Back = entry.Back ?? Array.Empty(), + Forward = entry.Forward ?? Array.Empty(), + }); + } + TrimToCap(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) + { + _entries.Clear(); // a damaged history is disposable — start empty + } + } + + /// Remembers a freshly closed tab at the front of the history. Saves quietly. + public async Task RecordAsync(ClosedTab closed, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(closed); + ArgumentException.ThrowIfNullOrWhiteSpace(closed.Path); + _entries.Insert(0, closed); + TrimToCap(); + await SaveQuietlyAsync(cancellationToken); + } + + /// + /// Removes and returns the most recently closed tab, or null when the + /// history is empty. The removal is saved quietly — a reopened tab + /// leaves the history. + /// + public async Task TakeMostRecentAsync(CancellationToken cancellationToken = default) + { + if (_entries.Count == 0) + return null; + var entry = _entries[0]; + _entries.RemoveAt(0); + await SaveQuietlyAsync(cancellationToken); + return entry; + } + + private void TrimToCap() + { + while (_entries.Count > MaximumEntries) + _entries.RemoveAt(_entries.Count - 1); + } + + private async Task SaveQuietlyAsync(CancellationToken cancellationToken) + { + try + { + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + var tempPath = _filePath + ".tmp"; + var json = JsonSerializer.Serialize(_entries, JsonOptions); + await File.WriteAllTextAsync(tempPath, json, cancellationToken); + File.Move(tempPath, _filePath, overwrite: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + // Saving is a convenience; browsing must continue if it fails. + } + } +} diff --git a/tests/Meridian.Core.Tests/ClosedTabsTests.cs b/tests/Meridian.Core.Tests/ClosedTabsTests.cs new file mode 100644 index 0000000..f27ec69 --- /dev/null +++ b/tests/Meridian.Core.Tests/ClosedTabsTests.cs @@ -0,0 +1,88 @@ +using Meridian.Services; +using Xunit; + +namespace Meridian.Core.Tests; + +public sealed class ClosedTabsTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "MeridianClosedTabs-" + Guid.NewGuid().ToString("N")); + private readonly string _file; + private static readonly DateTimeOffset Now = new(2026, 11, 2, 12, 0, 0, TimeSpan.Zero); + + public ClosedTabsTests() + { + Directory.CreateDirectory(_root); + _file = Path.Combine(_root, "closed-tabs.json"); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch (IOException) { } + } + + private static ClosedTab Tab(string path) => new(path, Array.Empty(), Array.Empty(), Now); + + [Fact] + public async Task Record_OrdersMostRecentFirst_AndPersists() + { + var store = new ClosedTabStore(_file); + await store.RecordAsync(Tab(@"C:\A")); + await store.RecordAsync(Tab(@"C:\B")); + Assert.Equal(new[] { @"C:\B", @"C:\A" }, store.Entries.Select(e => e.Path)); + + var reloaded = new ClosedTabStore(_file); + await reloaded.LoadAsync(); + Assert.Equal(new[] { @"C:\B", @"C:\A" }, reloaded.Entries.Select(e => e.Path)); + } + + [Fact] + public async Task Record_CapsAtFifty() + { + var store = new ClosedTabStore(_file); + for (var i = 0; i < 55; i++) await store.RecordAsync(Tab($@"C:\F{i}")); + Assert.Equal(ClosedTabStore.MaximumEntries, store.Entries.Count); + Assert.Equal(@"C:\F54", store.Entries[0].Path); + Assert.Equal(@"C:\F5", store.Entries[^1].Path); + } + + [Fact] + public async Task TakeMostRecent_PopsInOrder_ThenNull() + { + var store = new ClosedTabStore(_file); + await store.RecordAsync(Tab(@"C:\A")); + await store.RecordAsync(new ClosedTab(@"C:\B", new[] { @"C:\Prev" }, Array.Empty(), Now)); + var first = await store.TakeMostRecentAsync(); + Assert.Equal(@"C:\B", first!.Path); + Assert.Equal(new[] { @"C:\Prev" }, first.Back); + Assert.Equal(@"C:\A", (await store.TakeMostRecentAsync())!.Path); + Assert.Null(await store.TakeMostRecentAsync()); + + var reloaded = new ClosedTabStore(_file); + await reloaded.LoadAsync(); + Assert.Empty(reloaded.Entries); + } + + [Fact] + public async Task Load_MissingOrDamaged_StartsEmpty() + { + var store = new ClosedTabStore(_file); + await store.LoadAsync(); + Assert.Empty(store.Entries); + await File.WriteAllTextAsync(_file, "{ not json"); + await store.LoadAsync(); + Assert.Empty(store.Entries); + } + + [Fact] + public async Task Load_SkipsBlankPaths_AndNullHistories() + { + await File.WriteAllTextAsync(_file, + """[{"Path":" ","Back":[],"Forward":[],"ClosedUtc":"2026-11-02T12:00:00+00:00"},{"Path":"C:\\A","Back":null,"Forward":null,"ClosedUtc":"2026-11-02T12:00:00+00:00"}]"""); + var store = new ClosedTabStore(_file); + await store.LoadAsync(); + var entry = Assert.Single(store.Entries); + Assert.Equal(@"C:\A", entry.Path); + Assert.Empty(entry.Back); + Assert.Empty(entry.Forward); + } +}