diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 359da95..29c197d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## 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. + - 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. - The clipboard can now land as a file: each pane's right-click menu gains "Paste as file", which turns clipboard text into "Clipboard text.txt" or a clipboard image into "Clipboard image.png" in the current folder, numbering the name while one is taken. Clipboard files still go through the ordinary Paste; pointing this command at them says so instead of guessing. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5c5cc53..a84d300 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 +- [ ] 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) - [ ] 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 new file mode 100644 index 0000000..9cd47ea --- /dev/null +++ b/src/Meridian.App/MainWindow.Tabs.cs @@ -0,0 +1,160 @@ +using Meridian.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; + +namespace Meridian; + +/// +/// Per-pane folder tabs (Concept B: the tabs live in the path-bar area — +/// 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. +/// +public sealed partial class MainWindow +{ + private PaneTabSet EnsureTabs(PaneState pane) + => pane.Tabs ??= new PaneTabSet(string.IsNullOrWhiteSpace(pane.CurrentPath) ? HomePath : pane.CurrentPath); + + /// Writes the pane's live navigation state into its active tab. + private void SyncActiveTabFromPane(PaneState pane) + { + if (pane.Tabs is null) return; + var tab = pane.Tabs.Active; + tab.Path = pane.CurrentPath; + tab.Back = pane.BackHistory.ToArray(); + tab.Forward = pane.ForwardHistory.ToArray(); + } + + /// + /// Rebuilds the inactive-tab chips for a pane and the state of its + /// close-tab button. Called after every navigation and tab change. + /// + private void RebuildTabStrip(PaneState pane) + { + var chips = pane == _left ? LeftTabChips : RightTabChips; + var scroller = pane == _left ? LeftTabChipsScroll : RightTabChipsScroll; + var closeButton = pane == _left ? LeftCloseTabButton : RightCloseTabButton; + chips.Children.Clear(); + if (pane.Tabs is not { } set) + { + scroller.Visibility = Visibility.Collapsed; + closeButton.IsEnabled = false; + return; + } + for (var i = 0; i < set.Count; i++) + { + if (i == set.ActiveIndex) continue; + var index = i; + var tab = set.Tabs[i]; + var label = PaneTabSet.LabelFor(tab.Path, HomePath); + var chip = new Button { Content = label, Padding = new Thickness(8, 2, 8, 2), FontSize = 12 }; + ToolTipService.SetToolTip(chip, tab.Path + " — open this tab (middle-click closes it)"); + AutomationProperties.SetName(chip, $"Tab {label}, {pane.Name} pane"); + chip.Click += async (_, _) => await ActivateTabAsync(pane, index); + chip.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler((_, e) => + { + if (e.GetCurrentPoint(chip).Properties.IsMiddleButtonPressed) _ = CloseTabAsync(pane, index); + }), handledEventsToo: true); + chips.Children.Add(chip); + } + scroller.Visibility = set.Count > 1 ? Visibility.Visible : Visibility.Collapsed; + closeButton.IsEnabled = set.Count > 1; + } + + private async Task ActivateTabAsync(PaneState pane, int index) + { + var set = EnsureTabs(pane); + if (index == set.ActiveIndex) return; + SyncActiveTabFromPane(pane); + set.Activate(index); + await LoadActiveTabAsync(pane); + } + + /// Points the pane at its active tab: restores the tab's history stacks, then navigates without recording history. + private async Task LoadActiveTabAsync(PaneState pane) + { + var tab = EnsureTabs(pane).Active; + pane.BackHistory.Clear(); + for (var i = tab.Back.Length - 1; i >= 0; i--) pane.BackHistory.Push(tab.Back[i]); + pane.ForwardHistory.Clear(); + for (var i = tab.Forward.Length - 1; i >= 0; i--) pane.ForwardHistory.Push(tab.Forward[i]); + await NavigateAsync(pane, tab.Path, addHistory: false); + RebuildTabStrip(pane); + } + + /// Opens a new tab showing the folder the pane is already on (the file-manager convention) and switches to it. + private async Task NewTabAsync(PaneState pane) + { + SyncActiveTabFromPane(pane); + EnsureTabs(pane).Add(pane.CurrentPath); + await LoadActiveTabAsync(pane); + _ = PersistSessionAsync(); + } + + /// + /// Closes one tab (the active one by default). Closing the pane's last + /// tab is refused with a status note. Returns the closed tab, or null + /// when nothing closed. + /// + private async Task CloseTabAsync(PaneState pane, int? index = null) + { + var set = EnsureTabs(pane); + var target = index ?? set.ActiveIndex; + var wasActive = target == set.ActiveIndex; + if (wasActive) SyncActiveTabFromPane(pane); + var closed = set.Close(target); + if (closed is null) + { + SetStatus("A pane always keeps one tab.", StatusSeverity.Info); + return null; + } + if (wasActive) await LoadActiveTabAsync(pane); + else RebuildTabStrip(pane); + _ = PersistSessionAsync(); + return closed; + } + + private async Task CycleTabAsync(PaneState pane, int delta) + { + var set = EnsureTabs(pane); + if (set.Count < 2) return; + SyncActiveTabFromPane(pane); + set.Cycle(delta); + await LoadActiveTabAsync(pane); + _ = PersistSessionAsync(); + } + + private async void LeftNewTab_Click(object sender, RoutedEventArgs e) => await NewTabAsync(_left); + private async void RightNewTab_Click(object sender, RoutedEventArgs e) => await NewTabAsync(_right); + private async void LeftCloseTab_Click(object sender, RoutedEventArgs e) => await CloseTabAsync(_left); + private async void RightCloseTab_Click(object sender, RoutedEventArgs e) => await CloseTabAsync(_right); + + private async void NewTabAccelerator_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + args.Handled = true; + await NewTabAsync(_activePane); + } + + private async void CloseTabAccelerator_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + args.Handled = true; + await CloseTabAsync(_activePane); + } + + private async void NextTabAccelerator_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + args.Handled = true; + await CycleTabAsync(_activePane, 1); + } + + private async void PreviousTabAccelerator_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + args.Handled = true; + await CycleTabAsync(_activePane, -1); + } +} diff --git a/src/Meridian.App/MainWindow.xaml b/src/Meridian.App/MainWindow.xaml index 5f64527..4fcada8 100644 --- a/src/Meridian.App/MainWindow.xaml +++ b/src/Meridian.App/MainWindow.xaml @@ -130,6 +130,10 @@ + + + + @@ -286,12 +290,19 @@ - - - + + + + + + + + + + @@ -422,12 +433,19 @@ - - - + + + + + + + + + + diff --git a/src/Meridian.App/MainWindow.xaml.cs b/src/Meridian.App/MainWindow.xaml.cs index 731a6fb..35fceae 100644 --- a/src/Meridian.App/MainWindow.xaml.cs +++ b/src/Meridian.App/MainWindow.xaml.cs @@ -252,6 +252,17 @@ private async Task InitializeAsync(string documents, string downloads) var session = saved is null ? null : WorkspaceStore.ResolveSession(saved); var leftPath = session?.LeftPath is { Length: > 0 } savedLeft && Directory.Exists(savedLeft) ? savedLeft : documents; var rightPath = session?.RightPath is { Length: > 0 } savedRight && Directory.Exists(savedRight) ? savedRight : downloads; + // Per-pane tabs restore with the session: the saved tab lists win, + // the validated pane paths are the fallback (and the repair for an + // active tab whose folder has gone away since the save). + _left.Tabs = PaneTabSet.Restore(session?.LeftTabs, session?.LeftActiveTabIndex ?? 0, leftPath); + _right.Tabs = PaneTabSet.Restore(session?.RightTabs, session?.RightActiveTabIndex ?? 0, rightPath); + if (!string.Equals(_left.Tabs.Active.Path, HomePath, StringComparison.OrdinalIgnoreCase) && !Directory.Exists(_left.Tabs.Active.Path)) + _left.Tabs.Active.Path = leftPath; + if (!string.Equals(_right.Tabs.Active.Path, HomePath, StringComparison.OrdinalIgnoreCase) && !Directory.Exists(_right.Tabs.Active.Path)) + _right.Tabs.Active.Path = rightPath; + leftPath = _left.Tabs.Active.Path; + rightPath = _right.Tabs.Active.Path; _left.Layout = ParsePaneLayout(session?.LeftLayout); _right.Layout = ParsePaneLayout(session?.RightLayout); _left.FolderTreeVisible = session?.LeftFolderTreeVisible ?? false; @@ -311,7 +322,9 @@ await _workspaceStore.SaveAsync(new WorkspaceSnapshot( Tabs: null, ActiveTabIndex: 0, _left.Layout.ToString(), _right.Layout.ToString(), _bookmarkGroups.Groups.Select(group => new BookmarkGroupSnapshot(group.Name, group.BookmarkPaths.ToArray(), group.Collapsed)).ToArray(), - _left.FolderTreeVisible, _right.FolderTreeVisible)); + _left.FolderTreeVisible, _right.FolderTreeVisible, + _left.Tabs?.PathsSnapshot(), _right.Tabs?.PathsSnapshot(), + _left.Tabs?.ActiveIndex ?? 0, _right.Tabs?.ActiveIndex ?? 0)); return true; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) @@ -502,6 +515,8 @@ private async Task NavigateAsync(PaneState pane, string requestedPath, boo _ = _recentsStore.RecordAsync(fullPath, isDirectory: true); RefreshPreviewBlock(pane); SetStatus("Ready · Local-only · No telemetry"); + SyncActiveTabFromPane(pane); + RebuildTabStrip(pane); if (!_restoringSession) await PersistSessionAsync(); UpdatePaneWatch(pane); StartupLog.Milestone("nav-done:" + pane.Name); @@ -552,6 +567,8 @@ private async Task NavigateHomeAsync(PaneState pane, bool addHistory = tru SetPaneStatus(pane, "Home · pinned folders, drives and recent items"); RefreshPreviewBlock(pane); SetStatus("Ready · Local-only · No telemetry"); + SyncActiveTabFromPane(pane); + RebuildTabStrip(pane); if (!_restoringSession) await PersistSessionAsync(); UpdatePaneWatch(pane); return true; @@ -3275,6 +3292,9 @@ private sealed class PaneState(string name, TextBox pathBox, TextBox filterBox, public bool Descending { get; set; } public Stack BackHistory { get; } = new(); public Stack ForwardHistory { get; } = new(); + + /// The pane's folder tabs (Core set), built at session restore; null until then. + public PaneTabSet? Tabs { get; set; } public string CurrentPath { get; set; } = string.Empty; public CancellationTokenSource? ReadCancellation { get; set; } diff --git a/src/Meridian.App/Services/WorkspaceStore.cs b/src/Meridian.App/Services/WorkspaceStore.cs index 2294139..68025bc 100644 --- a/src/Meridian.App/Services/WorkspaceStore.cs +++ b/src/Meridian.App/Services/WorkspaceStore.cs @@ -51,11 +51,13 @@ internal static SessionSnapshot ResolveSession(WorkspaceSnapshot snapshot) active.LeftLayout, active.RightLayout, snapshot.LeftFolderTreeVisible, - snapshot.RightFolderTreeVisible); + snapshot.RightFolderTreeVisible, + snapshot.LeftTabs, snapshot.RightTabs, snapshot.LeftActiveTabIndex, snapshot.RightActiveTabIndex); } return new SessionSnapshot( snapshot.LeftPath, snapshot.RightPath, snapshot.LeftLayout, snapshot.RightLayout, - snapshot.LeftFolderTreeVisible, snapshot.RightFolderTreeVisible); + snapshot.LeftFolderTreeVisible, snapshot.RightFolderTreeVisible, + snapshot.LeftTabs, snapshot.RightTabs, snapshot.LeftActiveTabIndex, snapshot.RightActiveTabIndex); } public async Task LoadAsync() @@ -121,6 +123,11 @@ private static bool IsValid(WorkspaceSnapshot snapshot) if (snapshot.Tabs?.Any(tab => tab is null || !ValidLabel(tab.Name, optional: true) || !ValidPath(tab.LeftPath, optional: true) || !ValidPath(tab.RightPath, optional: true)) == true) return false; + if (snapshot.LeftTabs?.Length > MaximumTabs || snapshot.RightTabs?.Length > MaximumTabs) + return false; + if (snapshot.LeftTabs?.Any(path => !ValidTabPath(path)) == true || + snapshot.RightTabs?.Any(path => !ValidTabPath(path)) == true) + return false; if (snapshot.Bookmarks?.Any(bookmark => bookmark is null || !ValidLabel(bookmark.Name, optional: false) || !ValidPath(bookmark.Path, optional: false)) == true) return false; @@ -132,6 +139,14 @@ private static bool IsValid(WorkspaceSnapshot snapshot) group.Paths?.Any(path => !ValidPath(path, optional: false)) == true) != true; } + /// + /// A per-pane tab entry is a real folder path or the Home sentinel + /// (kept in step with MainWindow's HomePath) — a tab can sit on the + /// Home page like any other folder. + /// + private static bool ValidTabPath(string? path) => + string.Equals(path, "meridian://home", StringComparison.OrdinalIgnoreCase) || ValidPath(path, optional: false); + private static bool ValidPath(string? path, bool optional) => string.IsNullOrEmpty(path) ? optional : path.Length <= 32767 && !path.Any(char.IsControl) && Path.IsPathFullyQualified(path); @@ -139,7 +154,7 @@ private static bool ValidLabel(string? label, bool optional) => label is null ? optional : label.Length <= 1024 && !label.Any(char.IsControl) && (optional || !string.IsNullOrWhiteSpace(label)); } -internal sealed record WorkspaceSnapshot(string? LeftPath, string? RightPath, BookmarkSnapshot[]? Bookmarks = null, WorkspaceTabSnapshot[]? Tabs = null, int ActiveTabIndex = 0, string? LeftLayout = null, string? RightLayout = null, BookmarkGroupSnapshot[]? BookmarkGroups = null, bool LeftFolderTreeVisible = false, bool RightFolderTreeVisible = false); +internal sealed record WorkspaceSnapshot(string? LeftPath, string? RightPath, BookmarkSnapshot[]? Bookmarks = null, WorkspaceTabSnapshot[]? Tabs = null, int ActiveTabIndex = 0, string? LeftLayout = null, string? RightLayout = null, BookmarkGroupSnapshot[]? BookmarkGroups = null, bool LeftFolderTreeVisible = false, bool RightFolderTreeVisible = false, string[]? LeftTabs = null, string[]? RightTabs = null, int LeftActiveTabIndex = 0, int RightActiveTabIndex = 0); /// /// The one browsing session the window restores: both pane paths and both @@ -147,7 +162,7 @@ internal sealed record WorkspaceSnapshot(string? LeftPath, string? RightPath, Bo /// folder-tree flags ride along from the top level of the file — tabs /// never carried them, so a migrated multi-tab file reads as trees hidden. /// -internal sealed record SessionSnapshot(string? LeftPath, string? RightPath, string? LeftLayout, string? RightLayout, bool LeftFolderTreeVisible = false, bool RightFolderTreeVisible = false); +internal sealed record SessionSnapshot(string? LeftPath, string? RightPath, string? LeftLayout, string? RightLayout, bool LeftFolderTreeVisible = false, bool RightFolderTreeVisible = false, string[]? LeftTabs = null, string[]? RightTabs = null, int LeftActiveTabIndex = 0, int RightActiveTabIndex = 0); internal sealed record WorkspaceTabSnapshot(string Name, string? LeftPath, string? RightPath, string? LeftLayout = null, string? RightLayout = null); diff --git a/src/Meridian.Core/Services/PaneTabs.cs b/src/Meridian.Core/Services/PaneTabs.cs new file mode 100644 index 0000000..20c8442 --- /dev/null +++ b/src/Meridian.Core/Services/PaneTabs.cs @@ -0,0 +1,127 @@ +namespace Meridian.Services; + +/// +/// One open folder tab in a pane: the folder it shows and the back/forward +/// history it accumulated while it was the active tab. The window swaps +/// these histories in and out of the pane as tabs change; histories are +/// top-first (index 0 is where Back/Forward would go next). +/// +public sealed class PaneTab +{ + public PaneTab(string path) + { + Path = path; + Back = Array.Empty(); + Forward = Array.Empty(); + } + + public string Path { get; set; } + public string[] Back { get; set; } + public string[] Forward { get; set; } +} + +/// +/// The set of folder tabs one pane holds, with the index bookkeeping for +/// adding, closing, activating and cycling tabs. A set always holds at +/// least one tab — closing the last one is refused. The active tab is the +/// one the pane is showing; its path follows the pane as it navigates. +/// +public sealed class PaneTabSet +{ + private readonly List _tabs; + + public PaneTabSet(string initialPath) + { + _tabs = new List { new PaneTab(Normalize(initialPath)) }; + ActiveIndex = 0; + } + + private PaneTabSet(List tabs, int activeIndex) + { + _tabs = tabs; + ActiveIndex = activeIndex; + } + + public IReadOnlyList Tabs => _tabs; + public int Count => _tabs.Count; + public int ActiveIndex { get; private set; } + public PaneTab Active => _tabs[ActiveIndex]; + + /// Opens a tab at the path and makes it the active one. + public int Add(string path) + { + _tabs.Add(new PaneTab(Normalize(path))); + ActiveIndex = _tabs.Count - 1; + return ActiveIndex; + } + + public void Activate(int index) + { + if ((uint)index < (uint)_tabs.Count) ActiveIndex = index; + } + + /// Moves the active marker by delta tabs, wrapping at both ends. + public int Cycle(int delta) + { + var next = (ActiveIndex + delta) % _tabs.Count; + if (next < 0) next += _tabs.Count; + ActiveIndex = next; + return ActiveIndex; + } + + /// + /// Closes the tab at the index and returns it, or null when the close is + /// refused (the last remaining tab, or an index outside the set). When + /// the active tab closes, the tab that slides into its place becomes + /// active — or the new last tab when the closed one was last. + /// + public PaneTab? Close(int index) + { + if (_tabs.Count <= 1 || (uint)index >= (uint)_tabs.Count) return null; + var closed = _tabs[index]; + _tabs.RemoveAt(index); + if (index < ActiveIndex) ActiveIndex--; + else if (index == ActiveIndex && ActiveIndex >= _tabs.Count) ActiveIndex = _tabs.Count - 1; + return closed; + } + + /// The tab paths in order, for session persistence. + public string[] PathsSnapshot() => _tabs.Select(tab => tab.Path).ToArray(); + + /// + /// Rebuilds a set from saved paths: blank entries drop out, the active + /// index clamps into range, and an empty save falls back to one tab at + /// the fallback path. + /// + public static PaneTabSet Restore(IReadOnlyList? paths, int activeIndex, string fallbackPath) + { + var clean = (paths ?? Array.Empty()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(Normalize) + .ToList(); + if (clean.Count == 0) return new PaneTabSet(fallbackPath); + return new PaneTabSet( + clean.Select(path => new PaneTab(path)).ToList(), + Math.Clamp(activeIndex, 0, clean.Count - 1)); + } + + /// + /// The short label a tab shows for a path: "Home" for the Home sentinel, + /// the last folder name for nested paths, and the root itself for drive + /// or share roots. Separators are parsed by hand so Windows paths read + /// correctly on any host. + /// + public static string LabelFor(string path, string homePath) + { + if (string.IsNullOrWhiteSpace(path)) return string.Empty; + if (string.Equals(path.Trim(), homePath, StringComparison.OrdinalIgnoreCase)) return "Home"; + var trimmed = path.Trim().TrimEnd('\\', '/'); + if (trimmed.Length == 0) return path; + var cut = Math.Max(trimmed.LastIndexOf('\\'), trimmed.LastIndexOf('/')); + if (cut < 0) return trimmed.EndsWith(':') ? trimmed + "\\" : trimmed; + var name = trimmed[(cut + 1)..]; + return name.Length == 0 ? trimmed : name; + } + + private static string Normalize(string path) => string.IsNullOrWhiteSpace(path) ? string.Empty : path.Trim(); +} diff --git a/tests/Meridian.Core.Tests/PaneTabsTests.cs b/tests/Meridian.Core.Tests/PaneTabsTests.cs new file mode 100644 index 0000000..56ed686 --- /dev/null +++ b/tests/Meridian.Core.Tests/PaneTabsTests.cs @@ -0,0 +1,140 @@ +using Meridian.Services; +using Xunit; + +namespace Meridian.Core.Tests; + +public sealed class PaneTabsTests +{ + private const string Home = "meridian://home"; + + [Fact] + public void NewSet_HasOneActiveTab() + { + var set = new PaneTabSet(@"C:\Docs"); + Assert.Equal(1, set.Count); + Assert.Equal(0, set.ActiveIndex); + Assert.Equal(@"C:\Docs", set.Active.Path); + } + + [Fact] + public void Add_AppendsAndActivates() + { + var set = new PaneTabSet(@"C:\Docs"); + var index = set.Add(@"D:\Media"); + Assert.Equal(1, index); + Assert.Equal(2, set.Count); + Assert.Equal(@"D:\Media", set.Active.Path); + Assert.Equal(new[] { @"C:\Docs", @"D:\Media" }, set.PathsSnapshot()); + } + + [Fact] + public void Activate_IgnoresOutOfRange() + { + var set = new PaneTabSet(@"C:\A"); + set.Add(@"C:\B"); + set.Activate(0); + Assert.Equal(0, set.ActiveIndex); + set.Activate(5); + Assert.Equal(0, set.ActiveIndex); + set.Activate(-1); + Assert.Equal(0, set.ActiveIndex); + } + + [Fact] + public void Cycle_WrapsBothWays() + { + var set = new PaneTabSet(@"C:\A"); + set.Add(@"C:\B"); + set.Add(@"C:\C"); + Assert.Equal(0, set.Cycle(1)); + Assert.Equal(1, set.Cycle(1)); + Assert.Equal(2, set.Cycle(1)); + Assert.Equal(0, set.Cycle(1)); + Assert.Equal(2, set.Cycle(-1)); + } + + [Fact] + public void Close_LastTab_IsRefused() + { + var set = new PaneTabSet(@"C:\A"); + Assert.Null(set.Close(0)); + Assert.Equal(1, set.Count); + } + + [Fact] + public void Close_ActiveMiddle_NextTabSlidesIn() + { + var set = new PaneTabSet(@"C:\A"); + set.Add(@"C:\B"); + set.Add(@"C:\C"); + set.Activate(1); + var closed = set.Close(1); + Assert.Equal(@"C:\B", closed!.Path); + Assert.Equal(1, set.ActiveIndex); + Assert.Equal(@"C:\C", set.Active.Path); + } + + [Fact] + public void Close_ActiveLast_FallsBackToNewLast() + { + var set = new PaneTabSet(@"C:\A"); + set.Add(@"C:\B"); + var closed = set.Close(1); + Assert.Equal(@"C:\B", closed!.Path); + Assert.Equal(0, set.ActiveIndex); + Assert.Equal(@"C:\A", set.Active.Path); + } + + [Fact] + public void Close_InactiveBeforeActive_ShiftsActiveIndex() + { + var set = new PaneTabSet(@"C:\A"); + set.Add(@"C:\B"); + set.Add(@"C:\C"); + var closed = set.Close(0); + Assert.Equal(@"C:\A", closed!.Path); + Assert.Equal(1, set.ActiveIndex); + Assert.Equal(@"C:\C", set.Active.Path); + } + + [Fact] + public void Close_OutOfRange_ReturnsNull() + { + var set = new PaneTabSet(@"C:\A"); + set.Add(@"C:\B"); + Assert.Null(set.Close(7)); + Assert.Equal(2, set.Count); + } + + [Fact] + public void Restore_NullOrEmpty_UsesFallback() + { + var fromNull = PaneTabSet.Restore(null, 0, @"C:\Home"); + Assert.Equal(1, fromNull.Count); + Assert.Equal(@"C:\Home", fromNull.Active.Path); + var fromEmpty = PaneTabSet.Restore(new[] { " ", "" }, 3, @"C:\Home"); + Assert.Equal(1, fromEmpty.Count); + Assert.Equal(@"C:\Home", fromEmpty.Active.Path); + } + + [Fact] + public void Restore_KeepsPathsAndClampsIndex() + { + var set = PaneTabSet.Restore(new[] { @"C:\A", @"C:\B", @"C:\C" }, 99, @"C:\Home"); + Assert.Equal(3, set.Count); + Assert.Equal(2, set.ActiveIndex); + Assert.Equal(new[] { @"C:\A", @"C:\B", @"C:\C" }, set.PathsSnapshot()); + } + + [Theory] + [InlineData(@"C:\Users\sean\Documents", "Documents")] + [InlineData(@"C:\Users\sean\Documents\", "Documents")] + [InlineData(@"C:\", @"C:\")] + [InlineData(@"\\wsl$\Ubuntu\home\sean", "sean")] + [InlineData(@"\\server\share\folder", "folder")] + [InlineData("meridian://home", "Home")] + public void LabelFor_ReadsWindowsPaths(string path, string expected) + { + Assert.Equal(expected, PaneTabSet.LabelFor(path, Home)); + } +} diff --git a/tests/Meridian.Core.Tests/WorkspaceStoreTests.cs b/tests/Meridian.Core.Tests/WorkspaceStoreTests.cs index b8a9142..06557db 100644 --- a/tests/Meridian.Core.Tests/WorkspaceStoreTests.cs +++ b/tests/Meridian.Core.Tests/WorkspaceStoreTests.cs @@ -48,6 +48,31 @@ public async Task MissingStateFallsBackWithoutCreatingAFile() Assert.Empty(Directory.GetFileSystemEntries(fixture.Root)); } + [Fact] + public async Task PerPaneTabsRoundTripAndResolveIntoTheSession() + { + using var fixture = new Fixture(); + var snapshot = new WorkspaceSnapshot(fixture.Root, fixture.Root, + LeftTabs: new[] { fixture.Root, "meridian://home" }, RightTabs: new[] { fixture.Root }, + LeftActiveTabIndex: 1, RightActiveTabIndex: 0); + await fixture.Store.SaveAsync(snapshot); + var loaded = Assert.IsType(await fixture.Store.LoadAsync()); + Assert.Equal(snapshot.LeftTabs, loaded.LeftTabs); + Assert.Equal(snapshot.RightTabs, loaded.RightTabs); + Assert.Equal(1, loaded.LeftActiveTabIndex); + var session = WorkspaceStore.ResolveSession(loaded); + Assert.Equal(snapshot.LeftTabs, session.LeftTabs); + Assert.Equal(1, session.LeftActiveTabIndex); + } + + [Fact] + public async Task RelativePerPaneTabPathIsNotRestored() + { + using var fixture = new Fixture(); + await File.WriteAllTextAsync(fixture.File, "{\"LeftTabs\":[\"relative/path\"]}"); + Assert.Null(await fixture.Store.LoadAsync()); + } + [Fact] public async Task LegacyStateWithOnlyPanePathsIsStillReadable() {