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: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
- [ ] 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
Expand Down
160 changes: 160 additions & 0 deletions src/Meridian.App/MainWindow.Tabs.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public sealed partial class MainWindow
{
private PaneTabSet EnsureTabs(PaneState pane)
=> pane.Tabs ??= new PaneTabSet(string.IsNullOrWhiteSpace(pane.CurrentPath) ? HomePath : pane.CurrentPath);

/// <summary>Writes the pane's live navigation state into its active tab.</summary>
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();
}

/// <summary>
/// Rebuilds the inactive-tab chips for a pane and the state of its
/// close-tab button. Called after every navigation and tab change.
/// </summary>
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);
}

/// <summary>Points the pane at its active tab: restores the tab's history stacks, then navigates without recording history.</summary>
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);
}

/// <summary>Opens a new tab showing the folder the pane is already on (the file-manager convention) and switches to it.</summary>
private async Task NewTabAsync(PaneState pane)
{
SyncActiveTabFromPane(pane);
EnsureTabs(pane).Add(pane.CurrentPath);
await LoadActiveTabAsync(pane);
_ = PersistSessionAsync();
}

/// <summary>
/// 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.
/// </summary>
private async Task<PaneTab?> 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);
}
}
30 changes: 24 additions & 6 deletions src/Meridian.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@
<KeyboardAccelerator Key="F" Modifiers="Control" Invoked="FocusActiveFilterShortcut_Invoked" />
<KeyboardAccelerator Key="P" Modifiers="Control" Invoked="JumpPaletteShortcut_Invoked" />
<KeyboardAccelerator Key="F" Modifiers="Control,Shift" Invoked="SearchShortcut_Invoked" />
<KeyboardAccelerator Key="T" Modifiers="Control" Invoked="NewTabAccelerator_Invoked" />
<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="Left" Modifiers="Menu" Invoked="BackShortcut_Invoked" />
<KeyboardAccelerator Key="Right" Modifiers="Menu" Invoked="ForwardShortcut_Invoked" />
<KeyboardAccelerator Key="Up" Modifiers="Menu" Invoked="UpShortcut_Invoked" />
Expand Down Expand Up @@ -286,12 +290,19 @@
<TextBlock x:Name="LeftFolderPath" FontSize="11" Foreground="{ThemeResource MeridianTextSecondaryBrush}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>
<Grid Grid.Row="1" Margin="{StaticResource MeridianPaneRowMargin}" ColumnDefinitions="Auto,Auto,Auto,*" ColumnSpacing="6">
<Button Click="LeftBack_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Back" AutomationProperties.Name="Back, left pane"><SymbolIcon Symbol="Back" /></Button><Button Grid.Column="1" Click="LeftForward_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Forward" AutomationProperties.Name="Forward, left pane"><SymbolIcon Symbol="Forward" /></Button><Button Grid.Column="2" Click="LeftUp_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Up" AutomationProperties.Name="Up, left pane"><SymbolIcon Symbol="Up" /></Button>
<Grid x:Name="LeftBreadcrumbBar" Grid.Column="3" ColumnDefinitions="Auto,*" VerticalAlignment="Center">
<Grid Grid.Row="1" Margin="{StaticResource MeridianPaneRowMargin}" ColumnDefinitions="Auto,Auto,Auto,*" ColumnSpacing="6" RowDefinitions="Auto,Auto" RowSpacing="4">
<ScrollViewer x:Name="LeftTabChipsScroll" Grid.Row="0" Grid.ColumnSpan="4" Visibility="Collapsed" HorizontalScrollBarVisibility="Auto" HorizontalScrollMode="Auto" VerticalScrollBarVisibility="Disabled" VerticalScrollMode="Disabled">
<StackPanel x:Name="LeftTabChips" Orientation="Horizontal" Spacing="4" />
</ScrollViewer>
<Button Grid.Row="1" Click="LeftBack_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Back" AutomationProperties.Name="Back, left pane"><SymbolIcon Symbol="Back" /></Button><Button Grid.Row="1" Grid.Column="1" Click="LeftForward_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Forward" AutomationProperties.Name="Forward, left pane"><SymbolIcon Symbol="Forward" /></Button><Button Grid.Row="1" Grid.Column="2" Click="LeftUp_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Up" AutomationProperties.Name="Up, left pane"><SymbolIcon Symbol="Up" /></Button>
<Grid x:Name="LeftBreadcrumbBar" Grid.Row="1" Grid.Column="3" ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<StackPanel x:Name="LeftBreadcrumbSegments" Orientation="Horizontal" Spacing="2" VerticalAlignment="Center" />
<Border x:Name="LeftBreadcrumbEditArea" Grid.Column="1" Background="Transparent" ToolTipService.ToolTip="Type a folder path (Ctrl+L)" AutomationProperties.Name="Type a folder path, left pane" />
<TextBox x:Name="LeftPath" Grid.ColumnSpan="2" Visibility="Collapsed" PlaceholderText="Enter a folder path…" KeyDown="LeftPath_KeyDown" LostFocus="LeftPath_LostFocus" AutomationProperties.Name="Folder path, left pane" ToolTipService.ToolTip="Type a folder path and press Enter to open it in the left pane. Escape returns to the breadcrumb path" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="2" VerticalAlignment="Center">
<Button x:Name="LeftNewTabButton" Width="26" Height="26" Padding="0" Click="LeftNewTab_Click" ToolTipService.ToolTip="New tab (Ctrl+T)" AutomationProperties.Name="New tab, left pane"><SymbolIcon Symbol="Add" /></Button>
<Button x:Name="LeftCloseTabButton" Width="26" Height="26" Padding="0" Click="LeftCloseTab_Click" ToolTipService.ToolTip="Close this tab (Ctrl+W)" AutomationProperties.Name="Close tab, left pane"><SymbolIcon Symbol="Clear" /></Button>
</StackPanel>
</Grid>
</Grid>
<Grid x:Name="LeftFilterRow" Grid.Row="2" Margin="{StaticResource MeridianPaneRowMargin}" RowDefinitions="Auto,Auto" RowSpacing="6">
Expand Down Expand Up @@ -422,12 +433,19 @@
<TextBlock x:Name="RightFolderPath" FontSize="11" Foreground="{ThemeResource MeridianTextSecondaryBrush}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>
<Grid Grid.Row="1" Margin="{StaticResource MeridianPaneRowMargin}" ColumnDefinitions="Auto,Auto,Auto,*" ColumnSpacing="6">
<Button Click="RightBack_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Back" AutomationProperties.Name="Back, right pane"><SymbolIcon Symbol="Back" /></Button><Button Grid.Column="1" Click="RightForward_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Forward" AutomationProperties.Name="Forward, right pane"><SymbolIcon Symbol="Forward" /></Button><Button Grid.Column="2" Click="RightUp_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Up" AutomationProperties.Name="Up, right pane"><SymbolIcon Symbol="Up" /></Button>
<Grid x:Name="RightBreadcrumbBar" Grid.Column="3" ColumnDefinitions="Auto,*" VerticalAlignment="Center">
<Grid Grid.Row="1" Margin="{StaticResource MeridianPaneRowMargin}" ColumnDefinitions="Auto,Auto,Auto,*" ColumnSpacing="6" RowDefinitions="Auto,Auto" RowSpacing="4">
<ScrollViewer x:Name="RightTabChipsScroll" Grid.Row="0" Grid.ColumnSpan="4" Visibility="Collapsed" HorizontalScrollBarVisibility="Auto" HorizontalScrollMode="Auto" VerticalScrollBarVisibility="Disabled" VerticalScrollMode="Disabled">
<StackPanel x:Name="RightTabChips" Orientation="Horizontal" Spacing="4" />
</ScrollViewer>
<Button Grid.Row="1" Click="RightBack_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Back" AutomationProperties.Name="Back, right pane"><SymbolIcon Symbol="Back" /></Button><Button Grid.Row="1" Grid.Column="1" Click="RightForward_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Forward" AutomationProperties.Name="Forward, right pane"><SymbolIcon Symbol="Forward" /></Button><Button Grid.Row="1" Grid.Column="2" Click="RightUp_Click" Width="32" Height="32" Padding="0" ToolTipService.ToolTip="Up" AutomationProperties.Name="Up, right pane"><SymbolIcon Symbol="Up" /></Button>
<Grid x:Name="RightBreadcrumbBar" Grid.Row="1" Grid.Column="3" ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<StackPanel x:Name="RightBreadcrumbSegments" Orientation="Horizontal" Spacing="2" VerticalAlignment="Center" />
<Border x:Name="RightBreadcrumbEditArea" Grid.Column="1" Background="Transparent" ToolTipService.ToolTip="Type a folder path (Ctrl+L)" AutomationProperties.Name="Type a folder path, right pane" />
<TextBox x:Name="RightPath" Grid.ColumnSpan="2" Visibility="Collapsed" PlaceholderText="Enter a folder path…" KeyDown="RightPath_KeyDown" LostFocus="RightPath_LostFocus" AutomationProperties.Name="Folder path, right pane" ToolTipService.ToolTip="Type a folder path and press Enter to open it in the right pane. Escape returns to the breadcrumb path" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="2" VerticalAlignment="Center">
<Button x:Name="RightNewTabButton" Width="26" Height="26" Padding="0" Click="RightNewTab_Click" ToolTipService.ToolTip="New tab (Ctrl+T)" AutomationProperties.Name="New tab, right pane"><SymbolIcon Symbol="Add" /></Button>
<Button x:Name="RightCloseTabButton" Width="26" Height="26" Padding="0" Click="RightCloseTab_Click" ToolTipService.ToolTip="Close this tab (Ctrl+W)" AutomationProperties.Name="Close tab, right pane"><SymbolIcon Symbol="Clear" /></Button>
</StackPanel>
</Grid>
</Grid>
<Grid x:Name="RightFilterRow" Grid.Row="2" Margin="{StaticResource MeridianPaneRowMargin}" RowDefinitions="Auto,Auto" RowSpacing="6">
Expand Down
Loading
Loading