Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 33 additions & 3 deletions src/Meridian.App/MainWindow.Tabs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public sealed partial class MainWindow
{
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Reopens the most recently closed tab in the given pane, restoring
/// the back/forward history it had when it closed.
/// </summary>
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);
Expand Down Expand Up @@ -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);
}
}
1 change: 1 addition & 0 deletions src/Meridian.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
<KeyboardAccelerator Key="W" Modifiers="Control" Invoked="CloseTabAccelerator_Invoked" />
<KeyboardAccelerator Key="Tab" Modifiers="Control" Invoked="NextTabAccelerator_Invoked" />
<KeyboardAccelerator Key="Tab" Modifiers="Control,Shift" Invoked="PreviousTabAccelerator_Invoked" />
<KeyboardAccelerator Key="T" Modifiers="Control,Shift" Invoked="ReopenClosedTabAccelerator_Invoked" />
<KeyboardAccelerator Key="Left" Modifiers="Menu" Invoked="BackShortcut_Invoked" />
<KeyboardAccelerator Key="Right" Modifiers="Menu" Invoked="ForwardShortcut_Invoked" />
<KeyboardAccelerator Key="Up" Modifiers="Menu" Invoked="UpShortcut_Invoked" />
Expand Down
2 changes: 2 additions & 0 deletions src/Meridian.App/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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();
Expand Down
111 changes: 111 additions & 0 deletions src/Meridian.Core/Services/ClosedTabs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.Text.Json;

namespace Meridian.Services;

/// <summary>
/// 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 <see cref="PaneTab"/> carries).
/// </summary>
public sealed record ClosedTab(string Path, string[] Back, string[] Forward, DateTimeOffset ClosedUtc);

/// <summary>
/// The durable history of closed tabs, most recently closed first, capped
/// at 50. Written to a JSON file (temp file + atomic move, following
/// <see cref="SavedSearchStore"/>); a missing or damaged file starts
/// empty, and saving is best-effort — recording a closed tab must never
/// surface as an error.
/// </summary>
public sealed class ClosedTabStore
{
public const int MaximumEntries = 50;

private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };

private readonly string _filePath;
private readonly List<ClosedTab> _entries = []; // most recently closed first

public ClosedTabStore(string filePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
_filePath = Path.GetFullPath(filePath);
}

public IReadOnlyList<ClosedTab> 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<List<ClosedTab>>(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<string>(),
Forward = entry.Forward ?? Array.Empty<string>(),
});
}
TrimToCap();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or NotSupportedException)
{
_entries.Clear(); // a damaged history is disposable — start empty
}
}

/// <summary>Remembers a freshly closed tab at the front of the history. Saves quietly.</summary>
public async Task RecordAsync(ClosedTab closed, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(closed);
ArgumentException.ThrowIfNullOrWhiteSpace(closed.Path);
_entries.Insert(0, closed);
TrimToCap();
await SaveQuietlyAsync(cancellationToken);
}

/// <summary>
/// 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.
/// </summary>
public async Task<ClosedTab?> 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.
}
}
}
88 changes: 88 additions & 0 deletions tests/Meridian.Core.Tests/ClosedTabsTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>(), Array.Empty<string>(), 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<string>(), 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);
}
}
Loading