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 src/ExplorerHelper/Controls/TriageView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@

<DockPanel Grid.Row="3" Margin="16,10,16,12">
<ui:Button x:Name="CommitButton" DockPanel.Dock="Right" Appearance="Primary"
Content="Commit" Click="Commit_Click" />
Content="Commit" Click="Commit_Click" />
<TextBlock Text="Nothing touches the disk until you commit — rejects go to the Recycle Bin, and one Ctrl+Z brings the whole commit back."
VerticalAlignment="Center" FontSize="11" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextFillColorTertiaryBrush}" />
Expand Down
51 changes: 11 additions & 40 deletions src/ExplorerHelper/Controls/TriageView.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,20 @@ public TriageView()
/// (filtered, sorted) file list — folders never get cards. Starts at the first unmarked
/// file so a resumed session picks up where it left off.
/// </summary>
public void Open(MainViewModel vm)
public void Open(MainViewModel vm, bool startInReview = false)
{
_vm = vm;
_deck = vm.Files.Where(f => !f.IsDirectory).ToList();
_history.Clear();
_index = _deck.FindIndex(f => f.Flag == TriageFlag.None);

Visibility = Visibility.Visible;
ShowDeckState();
// Marks made in the list have no deck behind them, so the toolbar opens straight onto the
// piles; "Back to deck" still works because the deck was snapshotted above either way.
if (startInReview)
ShowReviewState();
else
ShowDeckState();
Focus();
}

Expand Down Expand Up @@ -339,7 +344,7 @@ private void RefreshReviewState()
UnmarkedStrip.Visibility = _vm.UnmarkedFileCount > 0 ? Visibility.Visible : Visibility.Collapsed;
UnmarkedText.Text = $"{_vm.UnmarkedFileCount} file(s) not decided yet — they stay untouched unless you keep going.";
CommitButton.IsEnabled = _vm.KeepCount + _vm.RejectCount > 0;
CommitButton.Content = $"Commit (✗ {_vm.RejectCount} reject · ✓ {_vm.KeepCount} keep)";
CommitButton.Content = $"Commit (✗ {_vm.RejectCount} reject · ✓ {_vm.KeepCount} keep)";
}

private static FileEntry? EntryOf(object sender) =>
Expand Down Expand Up @@ -373,42 +378,8 @@ private void PileItem_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)

private void Commit_Click(object sender, RoutedEventArgs e)
{
// Marks can span folders now (issue #43), so the dialog gets the per-folder breakdown and
// a way to recompute its totals when the user narrows the scope.
var dialog = new CommitDialog(
_vm.SummarizeMarksByFolder(),
_vm.FolderPath,
onlyHere =>
{
var rejects = _vm.RejectPile.Where(f => InScope(f, onlyHere)).ToList();
var keeps = _vm.KeepPile.Where(f => InScope(f, onlyHere)).ToList();
return (rejects.Count, rejects.Sum(f => f.SizeBytes),
keeps.Count, keeps.Sum(f => f.SizeBytes));
})
{
Owner = Window.GetWindow(this),
};
if (dialog.ShowDialog() != true)
return;
var destination = dialog.KeepDestination;
var copyKeepers = dialog.CopyKeepers;
var deleteRejects = dialog.DeleteRejects;
var currentFolderOnly = dialog.CurrentFolderOnly;

bool InScope(Models.FileEntry entry, bool onlyHere) =>
!onlyHere || string.Equals(
Services.TriageSession.FolderOf(entry), _vm.FolderPath, StringComparison.OrdinalIgnoreCase);

// Release every preview handle, then let the dispatcher pump the media teardown
// before files start moving — same discipline as delete/rename (issue #1).
CardPreview.Clear();
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, new Action(() =>
{
var error = _vm.CommitTriage(destination, copyKeepers, deleteRejects, currentFolderOnly);
if (error is not null)
MessageBox.Show(Window.GetWindow(this)!, error, "Commit finished with errors",
MessageBoxButton.OK, MessageBoxImage.Warning);
Close();
}));
// The dialog, the handle release and the commit itself are shared with the toolbar's
// pending-marks pill; the overlay only adds closing itself afterwards.
TriageCommitFlow.Run(_vm, Window.GetWindow(this)!, CardPreview.Clear, Close);
}
}
55 changes: 46 additions & 9 deletions src/ExplorerHelper/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -152,23 +152,56 @@
Visibility="{Binding IsUpdateAvailable, Converter={StaticResource BoolToVisibility}}"
ToolTip="A new version is ready — click to update; the app restarts on this folder" />
<!-- Pending marks (issue #43): marks survive navigation now, so this stays put
while you browse. Without it you can commit a reject made six folders ago. -->
<Border Margin="0,0,12,0" Padding="10,4" CornerRadius="12"
while you browse. Without it you can commit a reject made six folders ago.
It also carries the commit action: marks are made from the list as often as
from the deck, so this is where a keyboard triage run has to be able to end. -->
<Border Margin="0,0,12,0" Padding="8,3" CornerRadius="14"
VerticalAlignment="Center"
Background="{DynamicResource ControlFillColorSecondaryBrush}"
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
ToolTip="Uncommitted keep/reject marks. They follow you between folders until you commit or discard them."
Visibility="{Binding HasPendingMarks, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding PendingMarksSummary}" FontSize="12"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<Button Content="Discard" Click="DiscardMarks_Click" Margin="8,0,0,0"
<TextBlock Text="{Binding KeepCount, StringFormat=&#10003; {0}}" FontSize="12"
VerticalAlignment="Center" FontWeight="SemiBold"
Foreground="{StaticResource KeepBrush}" />
<TextBlock Text="{Binding RejectCount, StringFormat=&#10007; {0}}" FontSize="12"
Margin="8,0,0,0" VerticalAlignment="Center" FontWeight="SemiBold"
Foreground="{StaticResource RejectBrush}" />
<TextBlock Text="{Binding PendingScopeSummary}" FontSize="12"
Margin="8,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Visibility="{Binding MarksSpanFolders, Converter={StaticResource BoolToVisibility}}" />
<Button Content="Review" Click="ReviewMarks_Click" Margin="10,0,0,0"
Padding="6,0" Background="Transparent" BorderThickness="0"
FontSize="11" Cursor="Hand"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
ToolTip="Clear every pending mark without touching the disk" />
FontSize="12" Cursor="Hand"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
ToolTip="Open both piles to check them before committing" />
<!-- A pill-shaped button needs horizontal padding wider than its corner
radius, or the text starts where the background has already curved
away and reads as overflowing its own shape. -->
<ui:Button Content="Commit" Appearance="Primary" Margin="8,0,0,0"
Padding="14,4" FontSize="12" CornerRadius="12"
Click="CommitMarks_Click"
ToolTip="Apply the pending marks: recycle rejects, move or copy keepers (Ctrl+Enter)" />
<ToggleButton x:Name="MarksMoreButton" Content="&#8943;" Margin="4,0,0,0"
Padding="6,0" MinWidth="0" Background="Transparent"
BorderThickness="0" FontSize="13" Cursor="Hand"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
ToolTip="More actions for the pending marks" />
<Popup PlacementTarget="{Binding ElementName=MarksMoreButton}" Placement="Bottom"
StaysOpen="False" AllowsTransparency="True"
IsOpen="{Binding IsChecked, ElementName=MarksMoreButton, Mode=TwoWay}">
<Border Background="{DynamicResource ApplicationBackgroundBrush}"
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
BorderThickness="1" CornerRadius="8" Padding="6" Margin="0,6,0,0">
<ui:Button Content="Discard marks" Click="DiscardMarks_Click"
Appearance="Transparent" HorizontalAlignment="Stretch"
Icon="{ui:SymbolIcon Dismiss24}"
ToolTip="Clear every pending mark without touching the disk" />
</Border>
</Popup>
</StackPanel>
</Border>
<ui:Button Appearance="Primary"
Expand Down Expand Up @@ -668,6 +701,10 @@
<TextBlock Style="{StaticResource KbdText}" Text="U" />
</Border>
<TextBlock Style="{StaticResource KbdLabel}" Text="unmark" Margin="5,0,0,0" />
<Border Style="{StaticResource KbdChip}">
<TextBlock Style="{StaticResource KbdText}" Text="Ctrl+Enter" />
</Border>
<TextBlock Style="{StaticResource KbdLabel}" Text="commit" Margin="5,0,0,0" />
</StackPanel>
<TextBlock Text="{Binding StatusText}" FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
Expand Down
34 changes: 34 additions & 0 deletions src/ExplorerHelper/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ public MainWindow()
FileList.Focus();
};

// Ctrl+Enter ends a keyboard triage run without reaching for the mouse. It lives on the
// window, not FileList_KeyDown, so it still works from the filter box or the rename bar.
InputBindings.Add(new KeyBinding(
new CommunityToolkit.Mvvm.Input.RelayCommand(CommitMarks, () => _vm.HasPendingMarks),
Key.Enter,
ModifierKeys.Control));

UpdateSortIndicators();
RefreshDynamicButtons(null); // seed the "today" button label before any file is selected
}
Expand Down Expand Up @@ -194,12 +201,39 @@ private void Navigate(System.Windows.Input.ICommand command)
FocusList();
}

// --- Pending marks (toolbar pill) ------------------------------------------------

/// <summary>
/// Commits from the list, without a detour through the deck. Marking is a list gesture
/// (K/X/U) as much as a deck one, so the commit has to be reachable from here too.
/// </summary>
private void CommitMarks()
{
if (!_vm.HasPendingMarks)
return;
TriageCommitFlow.Run(_vm, this, Preview.Clear, () =>
{
Preview.Show(_vm.SelectedFile); // the commit reloaded the folder; put the pane back
FocusList();
});
}

private void CommitMarks_Click(object sender, RoutedEventArgs e) => CommitMarks();

/// <summary>Opens the overlay straight onto the piles, skipping the card deck.</summary>
private void ReviewMarks_Click(object sender, RoutedEventArgs e)
{
Preview.Clear(); // the review screen owns the (single) live media handle from here
TriageOverlay.Open(_vm, startInReview: true);
}

/// <summary>
/// Throws away every pending mark. Marks span folders now, so confirm with the count: the
/// user may be discarding decisions made somewhere they can't currently see (issue #43).
/// </summary>
private void DiscardMarks_Click(object sender, RoutedEventArgs e)
{
MarksMoreButton.IsChecked = false;
var answer = MessageBox.Show(
this,
$"Discard {_vm.PendingMarksSummary}?\n\nNothing on disk changes — only the pending "
Expand Down
57 changes: 57 additions & 0 deletions src/ExplorerHelper/TriageCommitFlow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Windows;
using System.Windows.Threading;
using ExplorerHelper.ViewModels;

namespace ExplorerHelper;

/// <summary>
/// The single path from pending triage marks to disk. Both entry points use it: the review
/// screen's Commit button and the toolbar's pending-marks pill.
/// <para>
/// Marks live in the session and are made from the list as often as from the deck, so committing
/// can't be something only the deck knows how to do — that left the list able to discard marks
/// but never commit them. <see cref="CommitDialog"/> was already independent of the deck (it takes
/// a per-folder summary and a totals callback, nothing else); only the call site was trapped there.
/// </para>
/// </summary>
public static class TriageCommitFlow
{
/// <summary>
/// Confirms the commit and, if the user goes ahead, applies it. Returns false when the dialog
/// was cancelled. <paramref name="clearPreview"/> releases the caller's preview handles and
/// <paramref name="committed"/> runs after the commit — both on the UI thread.
/// </summary>
public static bool Run(
MainViewModel vm,
Window owner,
Action clearPreview,
Action? committed = null)
{
// Marks can span folders (issue #43), so the dialog gets the per-folder breakdown and a
// way to recompute its totals when the user narrows the scope.
var dialog = new CommitDialog(vm.SummarizeMarksByFolder(), vm.FolderPath, vm.TotalsFor)
{
Owner = owner,
};
if (dialog.ShowDialog() != true)
return false;

var destination = dialog.KeepDestination;
var copyKeepers = dialog.CopyKeepers;
var deleteRejects = dialog.DeleteRejects;
var currentFolderOnly = dialog.CurrentFolderOnly;

// Release every preview handle, then let the dispatcher pump the media teardown before
// files start moving - same discipline as delete/rename (issue #1).
clearPreview();
owner.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() =>
{
var error = vm.CommitTriage(destination, copyKeepers, deleteRejects, currentFolderOnly);
if (error is not null)
MessageBox.Show(owner, error, "Commit finished with errors",
MessageBoxButton.OK, MessageBoxImage.Warning);
committed?.Invoke();
}));
return true;
}
}
23 changes: 22 additions & 1 deletion src/ExplorerHelper/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,10 +328,17 @@ public static string FormatDate(DateTime date, string? format)
/// <summary>Files currently flagged Reject, folder then name.</summary>
public ObservableCollection<FileEntry> RejectPile { get; } = [];

/// <summary>Toolbar text while marks are pending, e.g. "37 marks in 5 folders".</summary>
/// <summary>
/// Plain-language count of everything pending, e.g. "37 marks in 5 folders". The pill shows
/// the keep/reject split instead; this wording is what the discard confirmation reads back.
/// </summary>
[ObservableProperty]
private string _pendingMarksSummary = string.Empty;

/// <summary>Pill suffix naming the spread, e.g. "in 5 folders". Empty while marks are local.</summary>
[ObservableProperty]
private string _pendingScopeSummary = string.Empty;

/// <summary>
/// True while uncommitted marks exist, showing the toolbar pill. Marks now accumulate across
/// folders, so something has to stay visible while you browse or you can commit a reject you
Expand Down Expand Up @@ -360,6 +367,19 @@ public List<TriageFolderSummary> SummarizeMarksByFolder() => _triage.Marked
g.Count(e => e.Flag == TriageFlag.Reject)))
.ToList();

/// <summary>
/// Reject/keep counts and sizes for a commit scope, so the commit dialog can rewrite its
/// cards when the user narrows to the current folder. Reads the session directly rather than
/// re-filtering the piles at the call site, which is how the deck and the toolbar stay in step.
/// </summary>
public (int RejectCount, long RejectBytes, int KeepCount, long KeepBytes) TotalsFor(bool currentFolderOnly)
{
var scope = currentFolderOnly ? FolderPath : null;
var rejects = _triage.Pending(TriageFlag.Reject, scope);
var keeps = _triage.Pending(TriageFlag.Keep, scope);
return (rejects.Count, rejects.Sum(e => e.SizeBytes), keeps.Count, keeps.Sum(e => e.SizeBytes));
}

[ObservableProperty]
private int _keepCount;

Expand Down Expand Up @@ -738,6 +758,7 @@ private void RecomputeTriage()
PendingMarksSummary = folders > 1
? $"{total} mark{(total == 1 ? "" : "s")} in {folders} folders"
: $"{total} mark{(total == 1 ? "" : "s")}";
PendingScopeSummary = folders > 1 ? $"in {folders} folders" : string.Empty;
UpdatePileGrouping();
UpdateStatus();
}
Expand Down