From 08641fb328102cc88b53b02a365e8d5507b8e493 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:26:14 -0400 Subject: [PATCH 01/39] CI: export current source for stop workflow implementation --- .github/workflows/source-export.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/source-export.yml diff --git a/.github/workflows/source-export.yml b/.github/workflows/source-export.yml new file mode 100644 index 00000000..d47318d7 --- /dev/null +++ b/.github/workflows/source-export.yml @@ -0,0 +1,28 @@ +name: Export source for compression stop work + +on: + pull_request: + branches: + - master + +permissions: + contents: read + +jobs: + export: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Upload source + uses: actions/upload-artifact@v7 + with: + name: CompactGUI-current-source + path: | + CompactGUI*/** + !CompactGUI*/bin/** + !CompactGUI*/obj/** + *.sln + *.md + if-no-files-found: error + retention-days: 1 From f9f3c7fa56aa33dfb26ab5e9382d5ef7f33df3da Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:46:28 -0400 Subject: [PATCH 02/39] Stage compression stop payload part 1 --- .github/stop-payload/part00 | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/stop-payload/part00 diff --git a/.github/stop-payload/part00 b/.github/stop-payload/part00 new file mode 100644 index 00000000..72e04282 --- /dev/null +++ b/.github/stop-payload/part00 @@ -0,0 +1 @@ +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 \ No newline at end of file From ba8060eba4c4d42ba34e69cd298e183294d6e674 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:49:46 -0400 Subject: [PATCH 03/39] Stage stop workflow patch part 1 --- .github/stop-patches/part00.patch | 262 ++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 .github/stop-patches/part00.patch diff --git a/.github/stop-patches/part00.patch b/.github/stop-patches/part00.patch new file mode 100644 index 00000000..4e4d167b --- /dev/null +++ b/.github/stop-patches/part00.patch @@ -0,0 +1,262 @@ +diff --git a/CompactGUI/Application.xaml.vb b/CompactGUI/Application.xaml.vb +index 4c17cc6..685951d 100644 +--- a/CompactGUI/Application.xaml.vb ++++ b/CompactGUI/Application.xaml.vb +@@ -74,6 +74,7 @@ Partial Public Class Application + services.AddSingleton(Of INavigationService, NavigationService)() + services.AddSingleton(Of CustomSnackBarService)() + services.AddSingleton(Of IWindowService, WindowService)() ++ services.AddSingleton(Of CompressionResumeService)() + services.AddSingleton(Of IUpdaterService, UpdaterService)() + services.AddSingleton(Of IWikiService, WikiService)() + +diff --git a/CompactGUI/Components/Converters/IValueConverters.vb b/CompactGUI/Components/Converters/IValueConverters.vb +index 01acbaf..135b630 100644 +--- a/CompactGUI/Components/Converters/IValueConverters.vb ++++ b/CompactGUI/Components/Converters/IValueConverters.vb +@@ -281,7 +281,7 @@ Public Class FolderStatusToColorConverter : Implements IValueConverter + Select Case status + Case ActionState.Idle + Return New SolidColorBrush(ColorConverter.ConvertFromString("#92e7f1")) +- Case ActionState.Analysing, ActionState.Working, ActionState.Paused ++ Case ActionState.Analysing, ActionState.Working, ActionState.Paused, ActionState.Undoing + Return New SolidColorBrush(ColorConverter.ConvertFromString("#F1CE92")) + Case ActionState.Results + Return New SolidColorBrush(ColorConverter.ConvertFromString("#92F1AB")) +@@ -305,6 +305,8 @@ Public Class FolderStatusToStringConverter : Implements IValueConverter + Return LanguageHelper.GetString("Status_Analysing") + Case ActionState.Working, ActionState.Paused + Return LanguageHelper.GetString("Status_Working") ++ Case ActionState.Undoing ++ Return LanguageHelper.GetString("Status_Undoing") + Case ActionState.Results + Return LanguageHelper.GetString("Status_Compressed") + Case Else +@@ -406,7 +408,7 @@ Public Class FolderActionStateWorkingToVisibilityConverter + Public Function Convert(value As Object, targetType As Type, parameter As Object, culture As CultureInfo) As Object Implements IValueConverter.Convert + Dim status = CType(value, ActionState) + Select Case status +- Case ActionState.Working ++ Case ActionState.Working, ActionState.Undoing + Return Visibility.Visible + Case Else + Return Visibility.Collapsed +diff --git a/CompactGUI/Components/FolderActionStateTemplateSelector.vb b/CompactGUI/Components/FolderActionStateTemplateSelector.vb +index b028349..2395c54 100644 +--- a/CompactGUI/Components/FolderActionStateTemplateSelector.vb ++++ b/CompactGUI/Components/FolderActionStateTemplateSelector.vb +@@ -15,7 +15,7 @@ + Return IdleTemplate + Case ActionState.Analysing + Return AnalysingTemplate +- Case ActionState.Working, ActionState.Paused ++ Case ActionState.Working, ActionState.Paused, ActionState.Undoing + Return CompressingTemplate + Case ActionState.Results + Return ResultsTemplate +@@ -42,7 +42,7 @@ Public Class HomeViewStateTemplateSelector + Return IdleTemplate + Case ActionState.Analysing + Return AnalysingTemplate +- Case ActionState.Working, ActionState.Paused ++ Case ActionState.Working, ActionState.Paused, ActionState.Undoing + Return CompressingTemplate + Case ActionState.Results + Return ResultsTemplate +diff --git a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb +index 815050f..2b6a841 100644 +--- a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb ++++ b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb +@@ -106,5 +106,6 @@ Public Enum ActionState + Working + Results + Paused ++ Undoing + Waiting + End Enum +\ No newline at end of file +diff --git a/CompactGUI/Models/CompressionStopModels.vb b/CompactGUI/Models/CompressionStopModels.vb +new file mode 100644 +index 0000000..441e584 +--- /dev/null ++++ b/CompactGUI/Models/CompressionStopModels.vb +@@ -0,0 +1,26 @@ ++Public Enum CompressionStopChoice ++ SaveProgress ++ UndoProgress ++ LeaveAsIs ++End Enum ++ ++Public Enum CompressionResumeChoice ++ Resume ++ DiscardSavedProgress ++ Cancel ++End Enum ++ ++ ++Public NotInheritable Class CompressionRunResult ++ Public Property Completed As Boolean ++ Public Property StopChoice As CompressionStopChoice? ++End Class ++ ++Public NotInheritable Class SavedCompressionSession ++ Public Property FolderPath As String = String.Empty ++ Public Property SelectedCompressionMode As Core.CompressionMode ++ Public Property SkipPoorlyCompressedFileTypes As Boolean ++ Public Property SkipUserSubmittedFiletypes As Boolean ++ Public Property WatchFolderForChanges As Boolean ++ Public Property SavedAt As DateTime = DateTime.Now ++End Class +diff --git a/CompactGUI/Services/CompressableFolderService.vb b/CompactGUI/Services/CompressableFolderService.vb +index 594c344..5d487c3 100644 +--- a/CompactGUI/Services/CompressableFolderService.vb ++++ b/CompactGUI/Services/CompressableFolderService.vb +@@ -1,3 +1,4 @@ ++Imports System.Collections.Concurrent + Imports System.Collections.ObjectModel + Imports System.Threading + +@@ -15,56 +16,123 @@ Public Class CompressableFolderService + Private Shared ReadOnly UncompactorLogger As ILogger = Application.GetService(Of ILogger(Of Uncompactor))() + Private Shared ReadOnly AnalyserLogger As ILogger = Application.GetService(Of ILogger(Of Analyser))() + ++ Private ReadOnly _resumeService As CompressionResumeService + Private folderTokens As New Dictionary(Of CompressableFolder, CancellationTokenSource) ++ Private ReadOnly stopChoices As New ConcurrentDictionary(Of CompressableFolder, CompressionStopChoice) ++ ++ Public Sub New(resumeService As CompressionResumeService) ++ _resumeService = resumeService ++ End Sub + + +- Public Async Function CompressFolder(folder As CompressableFolder) As Task(Of Boolean) ++ Public Async Function CompressFolder(folder As CompressableFolder) As Task(Of CompressionRunResult) + folder.Compressor = New Compactor(folder.FolderName, WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode), GetSkipList(folder), folder.Analyser, CompactorLogger) + Return Await RunCompressionAsync(folder, folder.Compressor, Nothing, True) +- +- + End Function + + + Public Async Function UncompressFolder(folder As CompressableFolder) As Task(Of Boolean) + ++ _resumeService.RemoveSession(folder.FolderName) + folder.Compressor = New Uncompactor(UncompactorLogger) + Dim compressedFilesList = folder.AnalysisResults.Where(Function(rs) rs.CompressedSize < rs.UncompressedSize).Select(Of String)(Function(f) f.FileName).ToList +- Return Await RunCompressionAsync(folder, folder.Compressor, compressedFilesList, isCompressing:=False) ++ Dim result = Await RunCompressionAsync(folder, folder.Compressor, compressedFilesList, isCompressing:=False) ++ Await AnalyseFolderAsync(folder) ++ Return result.Completed ++ End Function + + +- End Function ++ Public Sub RequestCompressionStop(folder As CompressableFolder, choice As CompressionStopChoice) ++ If folder Is Nothing OrElse Not TypeOf folder.Compressor Is Compactor Then Return ++ If folder.FolderActionState <> ActionState.Working AndAlso folder.FolderActionState <> ActionState.Paused Then Return ++ ++ If choice = CompressionStopChoice.SaveProgress Then ++ _resumeService.SaveSession(folder) ++ Else ++ _resumeService.RemoveSession(folder.FolderName) ++ End If ++ ++ stopChoices(folder) = choice ++ ++ Try ++ folder.Compressor.Cancel() ++ Catch ex As ObjectDisposedException ++ 'The compression completed while the stop dialog was open. ++ End Try ++ End Sub + +- Private Async Function RunCompressionAsync(folder As CompressableFolder, compressor As ICompressor, filesList As List(Of String), isCompressing As Boolean) As Task(Of Boolean) ++ Private Async Function RunCompressionAsync(folder As CompressableFolder, compressor As ICompressor, filesList As List(Of String), isCompressing As Boolean) As Task(Of CompressionRunResult) + folder.FolderActionState = ActionState.Working + + CancelEstimation(folder) + Dim cts = New CancellationTokenSource() + folderTokens(folder) = cts + Dim progress As IProgress(Of CompressionProgress) = New Progress(Of CompressionProgress)(Sub(x) folder.CompressionProgress = x) ++ Dim runResult As New CompressionRunResult() + + progress.Report(New CompressionProgress(0, "")) + +- Dim res = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) ++ Try ++ runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) ++ ++ If isCompressing Then ++ Dim stopChoice As CompressionStopChoice ++ If Not runResult.Completed AndAlso stopChoices.TryRemove(folder, stopChoice) Then ++ runResult.StopChoice = stopChoice ++ ++ If stopChoice = CompressionStopChoice.UndoProgress AndAlso TypeOf compressor Is Compactor Then ++ folder.FolderActionState = ActionState.Undoing ++ Await RestoreProcessedFilesAsync(folder, DirectCast(compressor, Compactor), progress) ++ End If ++ ElseIf runResult.Completed Then ++ stopChoices.TryRemove(folder, stopChoice) ++ _resumeService.RemoveSession(folder.FolderName) ++ End If ++ ++ folder.FolderActionState = ActionState.Results ++ folder.IsFreshlyCompressed = runResult.Completed ++ Else ++ folder.FolderActionState = ActionState.Idle ++ folder.IsFreshlyCompressed = False ++ End If + +- If isCompressing Then +- folder.FolderActionState = ActionState.Results +- folder.IsFreshlyCompressed = res +- Else +- folder.FolderActionState = ActionState.Idle +- folder.IsFreshlyCompressed = False +- Await AnalyseFolderAsync(folder) +- End If +- compressor.Dispose() ++ Return runResult ++ Finally ++ compressor.Dispose() ++ ++ Dim ownedToken As CancellationTokenSource = Nothing ++ If folderTokens.TryGetValue(folder, ownedToken) AndAlso Object.ReferenceEquals(ownedToken, cts) Then ++ folderTokens.Remove(folder) ++ ownedToken.Dispose() ++ Else ++ cts.Dispose() ++ End If ++ End Try ++ End Function + + +- folderTokens(folder).Dispose() +- folderTokens.Remove(folder) + ++ Private Async Function RestoreProcessedFilesAsync(folder As CompressableFolder, compactor As Compactor, progress As IProgress(Of CompressionProgress)) As Task ++ Dim processedFiles = compactor.ProcessedFiles.ToList() ++ If processedFiles.Count = 0 Then Return + +- Return res +- End Function ++ Dim threadCount = GetThreadCount(folder) + ++ For Each modeGroup In processedFiles.GroupBy(Function(item) item.Value) ++ Dim files = modeGroup.Select(Function(item) item.Key).ToList() ++ ++ Select Case modeGroup.Key ++ Case WOFCompressionAlgorithm.XPRESS4K, WOFCompressionAlgorithm.XPRESS8K, WOFCompressionAlgorithm.XPRESS16K, WOFCompressionAlgorithm.LZX ++ Using restorer As New Compactor(folder.FolderName, modeGroup.Key, Array.Empty(Of String), folder.Analyser, CompactorLogger) ++ Await restorer.RunAsync(files, progress, threadCount) ++ End Using ++ Case Else ++ Using uncompactor As New Uncompactor(UncompactorLogger) ++ Await uncompactor.RunAsync(files, progress, threadCount) ++ End Using ++ End Select ++ Next ++ End Function + + Public Async Function AnalyseFolderAsync(folder As CompressableFolder) As Task(Of Integer) + From 457c2555655f7bef7fc859338c5ca42e28d69beb Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:50:37 -0400 Subject: [PATCH 04/39] Stage stop workflow patch part 2 --- .github/stop-patches/part01.patch | 237 ++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 .github/stop-patches/part01.patch diff --git a/.github/stop-patches/part01.patch b/.github/stop-patches/part01.patch new file mode 100644 index 00000000..f372c426 --- /dev/null +++ b/.github/stop-patches/part01.patch @@ -0,0 +1,237 @@ +diff --git a/CompactGUI/Services/CompressionResumeService.vb b/CompactGUI/Services/CompressionResumeService.vb +new file mode 100644 +index 0000000..f2ba4aa +--- /dev/null ++++ b/CompactGUI/Services/CompressionResumeService.vb +@@ -0,0 +1,113 @@ ++Imports System.Text.Json ++ ++Imports CompactGUI.Core.Settings ++ ++Imports Microsoft.Extensions.Logging ++ ++Public NotInheritable Class CompressionResumeService ++ ++ Private ReadOnly _sessionsFile As IO.FileInfo ++ Private ReadOnly _logger As ILogger(Of CompressionResumeService) ++ Private ReadOnly _syncRoot As New Object ++ Private ReadOnly _jsonOptions As New JsonSerializerOptions With {.WriteIndented = True} ++ Private _sessions As Dictionary(Of String, SavedCompressionSession) ++ ++ Public Sub New(settingsService As ISettingsService, logger As ILogger(Of CompressionResumeService)) ++ _logger = logger ++ _sessionsFile = New IO.FileInfo(IO.Path.Combine(settingsService.DataFolder.FullName, "compression-resume.json")) ++ _sessions = LoadSessions() ++ End Sub ++ ++ Public Function TryGetSession(folderPath As String, ByRef session As SavedCompressionSession) As Boolean ++ Dim key = NormalizePath(folderPath) ++ ++ SyncLock _syncRoot ++ Dim stored As SavedCompressionSession = Nothing ++ If Not _sessions.TryGetValue(key, stored) Then Return False ++ ++ session = CloneSession(stored) ++ Return True ++ End SyncLock ++ End Function ++ ++ Public Sub SaveSession(folder As CompressableFolder) ++ Dim session = New SavedCompressionSession With { ++ .FolderPath = NormalizePath(folder.FolderName), ++ .SelectedCompressionMode = folder.CompressionOptions.SelectedCompressionMode, ++ .SkipPoorlyCompressedFileTypes = folder.CompressionOptions.SkipPoorlyCompressedFileTypes, ++ .SkipUserSubmittedFiletypes = folder.CompressionOptions.SkipUserSubmittedFiletypes, ++ .WatchFolderForChanges = folder.CompressionOptions.WatchFolderForChanges, ++ .SavedAt = DateTime.Now ++ } ++ ++ SyncLock _syncRoot ++ _sessions(session.FolderPath) = session ++ WriteSessions() ++ End SyncLock ++ End Sub ++ ++ Public Sub RemoveSession(folderPath As String) ++ Dim key = NormalizePath(folderPath) ++ ++ SyncLock _syncRoot ++ If _sessions.Remove(key) Then WriteSessions() ++ End SyncLock ++ End Sub ++ ++ Private Function LoadSessions() As Dictionary(Of String, SavedCompressionSession) ++ Dim loaded = New Dictionary(Of String, SavedCompressionSession)(StringComparer.OrdinalIgnoreCase) ++ ++ Try ++ If Not _sessionsFile.Directory.Exists Then _sessionsFile.Directory.Create() ++ If Not _sessionsFile.Exists Then Return loaded ++ ++ Dim json = IO.File.ReadAllText(_sessionsFile.FullName) ++ If String.IsNullOrWhiteSpace(json) Then Return loaded ++ ++ Dim entries = JsonSerializer.Deserialize(Of List(Of SavedCompressionSession))(json, _jsonOptions) ++ If entries Is Nothing Then Return loaded ++ ++ For Each entry In entries ++ If String.IsNullOrWhiteSpace(entry.FolderPath) Then Continue For ++ entry.FolderPath = NormalizePath(entry.FolderPath) ++ loaded(entry.FolderPath) = entry ++ Next ++ Catch ex As Exception ++ _logger.LogWarning(ex, "Could not load saved compression sessions from {SessionFile}", _sessionsFile.FullName) ++ End Try ++ ++ Return loaded ++ End Function ++ ++ Private Sub WriteSessions() ++ Try ++ If Not _sessionsFile.Directory.Exists Then _sessionsFile.Directory.Create() ++ Dim snapshot = _sessions.Values.OrderBy(Function(entry) entry.FolderPath, StringComparer.OrdinalIgnoreCase).ToList() ++ IO.File.WriteAllText(_sessionsFile.FullName, JsonSerializer.Serialize(snapshot, _jsonOptions)) ++ Catch ex As Exception ++ _logger.LogWarning(ex, "Could not save compression sessions to {SessionFile}", _sessionsFile.FullName) ++ End Try ++ End Sub ++ ++ Private Shared Function CloneSession(source As SavedCompressionSession) As SavedCompressionSession ++ Return New SavedCompressionSession With { ++ .FolderPath = source.FolderPath, ++ .SelectedCompressionMode = source.SelectedCompressionMode, ++ .SkipPoorlyCompressedFileTypes = source.SkipPoorlyCompressedFileTypes, ++ .SkipUserSubmittedFiletypes = source.SkipUserSubmittedFiletypes, ++ .WatchFolderForChanges = source.WatchFolderForChanges, ++ .SavedAt = source.SavedAt ++ } ++ End Function ++ ++ Private Shared Function NormalizePath(folderPath As String) As String ++ If String.IsNullOrWhiteSpace(folderPath) Then Return String.Empty ++ ++ Try ++ Return IO.Path.GetFullPath(folderPath).TrimEnd(IO.Path.DirectorySeparatorChar, IO.Path.AltDirectorySeparatorChar) ++ Catch ex As Exception ++ Return folderPath.TrimEnd(IO.Path.DirectorySeparatorChar, IO.Path.AltDirectorySeparatorChar) ++ End Try ++ End Function ++ ++End Class +diff --git a/CompactGUI/Services/WindowService.vb b/CompactGUI/Services/WindowService.vb +index 764aefe..eb6ae39 100644 +--- a/CompactGUI/Services/WindowService.vb ++++ b/CompactGUI/Services/WindowService.vb +@@ -3,6 +3,8 @@ + Sub MinimizeMainWindow() + Sub HideMainWindow() + Function ShowMessageBox(title As String, content As String) As Task(Of Boolean) ++ Function ShowCompressionStopDialog(folderName As String) As Task(Of CompressionStopChoice) ++ Function ShowResumeCompressionDialog(session As SavedCompressionSession) As Task(Of CompressionResumeChoice) + End Interface + + +@@ -39,4 +41,46 @@ Public Class WindowService + Dim result = Await msgBox.ShowDialogAsync() + Return result = Wpf.Ui.Controls.MessageBoxResult.Primary + End Function ++ ++ Public Async Function ShowCompressionStopDialog(folderName As String) As Task(Of CompressionStopChoice) Implements IWindowService.ShowCompressionStopDialog ++ Dim msgBox = New Wpf.Ui.Controls.MessageBox With { ++ .Title = LanguageHelper.GetString("CompressionStop_Title"), ++ .Content = String.Format(LanguageHelper.GetString("CompressionStop_Message"), folderName), ++ .IsPrimaryButtonEnabled = True, ++ .IsSecondaryButtonEnabled = True, ++ .PrimaryButtonText = LanguageHelper.GetString("CompressionStop_SaveProgress"), ++ .SecondaryButtonText = LanguageHelper.GetString("CompressionStop_UndoProgress"), ++ .CloseButtonText = LanguageHelper.GetString("CompressionStop_LeaveAsIs") ++ } ++ ++ Select Case Await msgBox.ShowDialogAsync() ++ Case Wpf.Ui.Controls.MessageBoxResult.Primary ++ Return CompressionStopChoice.SaveProgress ++ Case Wpf.Ui.Controls.MessageBoxResult.Secondary ++ Return CompressionStopChoice.UndoProgress ++ Case Else ++ Return CompressionStopChoice.LeaveAsIs ++ End Select ++ End Function ++ ++ Public Async Function ShowResumeCompressionDialog(session As SavedCompressionSession) As Task(Of CompressionResumeChoice) Implements IWindowService.ShowResumeCompressionDialog ++ Dim msgBox = New Wpf.Ui.Controls.MessageBox With { ++ .Title = LanguageHelper.GetString("CompressionResume_Title"), ++ .Content = String.Format(LanguageHelper.GetString("CompressionResume_Message"), session.FolderPath, session.SelectedCompressionMode), ++ .IsPrimaryButtonEnabled = True, ++ .IsSecondaryButtonEnabled = True, ++ .PrimaryButtonText = LanguageHelper.GetString("CompressionResume_Resume"), ++ .SecondaryButtonText = LanguageHelper.GetString("CompressionResume_Discard"), ++ .CloseButtonText = LanguageHelper.GetString("UniCancel") ++ } ++ ++ Select Case Await msgBox.ShowDialogAsync() ++ Case Wpf.Ui.Controls.MessageBoxResult.Primary ++ Return CompressionResumeChoice.Resume ++ Case Wpf.Ui.Controls.MessageBoxResult.Secondary ++ Return CompressionResumeChoice.DiscardSavedProgress ++ Case Else ++ Return CompressionResumeChoice.Cancel ++ End Select ++ End Function + End Class +diff --git a/CompactGUI/ViewModels/FolderViewModel.vb b/CompactGUI/ViewModels/FolderViewModel.vb +index b4459eb..0fa5632 100644 +--- a/CompactGUI/ViewModels/FolderViewModel.vb ++++ b/CompactGUI/ViewModels/FolderViewModel.vb +@@ -131,6 +131,8 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem + OnPropertyChanged(NameOf(CompressionDisplayLevel)) + OnPropertyChanged(NameOf(DisplayedFolderAfterSize)) + OnPropertyChanged(NameOf(TotalFiles)) ++ PauseCommand.NotifyCanExecuteChanged() ++ CancelCommand.NotifyCanExecuteChanged() + + ElseIf e.PropertyName = NameOf(Folder.CompressionProgress) Then + CompressionProgress = Folder.CompressionProgress.ProgressPercent +@@ -165,7 +167,7 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem + _snackbarService.ShowAppliedToAllFolders() + End Sub + +- ++ + Private Sub Pause() + + If Folder.FolderActionState = ActionState.Working Then +@@ -178,10 +180,31 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem + End If + End Sub + +- +- Private Sub Cancel() +- Folder.Compressor?.Cancel() +- End Sub ++ Private Function CanControlCompression() As Boolean ++ Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) ++ End Function ++ ++ ++ Private Async Function Cancel() As Task ++ If Not TypeOf Folder.Compressor Is Core.Compactor Then ++ Folder.Compressor?.Cancel() ++ Return ++ End If ++ ++ If Folder.FolderActionState = ActionState.Working Then ++ Try ++ Folder.Compressor?.Pause() ++ Folder.FolderActionState = ActionState.Paused ++ Catch ex As OperationCanceledException ++ Return ++ Catch ex As ObjectDisposedException ++ Return ++ End Try ++ End If ++ ++ Dim choice = Await Application.GetService(Of IWindowService)().ShowCompressionStopDialog(Folder.DisplayName) ++ _compressableFolderService.RequestCompressionStop(Folder, choice) ++ End Function + + + Private Async Function SubmitToWiki() As Task From c2c4acccea07508ef8981e18ea5b15f3df1553fe Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:51:40 -0400 Subject: [PATCH 05/39] Stage stop workflow patch part 3 --- .github/stop-patches/part02.patch | 222 ++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 .github/stop-patches/part02.patch diff --git a/.github/stop-patches/part02.patch b/.github/stop-patches/part02.patch new file mode 100644 index 00000000..3d9d45de --- /dev/null +++ b/.github/stop-patches/part02.patch @@ -0,0 +1,222 @@ +diff --git a/CompactGUI/ViewModels/HomeViewModel.vb b/CompactGUI/ViewModels/HomeViewModel.vb +index edc4a56..3e84f69 100644 +--- a/CompactGUI/ViewModels/HomeViewModel.vb ++++ b/CompactGUI/ViewModels/HomeViewModel.vb +@@ -110,61 +110,75 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient + + HomeViewModelLog.AddingFolders(_logger, folderPaths) + +- Dim invalidFolders = GetInvalidFolders(folderPaths.ToArray) ++ Dim invalidFolders = GetInvalidFolders(folderPaths.ToArray()) + Dim validFolders = folderPaths.Except(invalidFolders.InvalidFolders) ++ Dim foldersToResume As New List(Of CompressableFolder)() ++ Dim resumeService = Application.GetService(Of CompressionResumeService)() ++ Dim windowService = Application.GetService(Of IWindowService)() + + If invalidFolders.InvalidFolders.Count > 0 Then +- 'TODO: Move this logger check to the snackbarService class? +- HomeViewModelLog.InvalidFolders(_logger, invalidFolders.InvalidFolders, invalidFolders.InvalidMessages.Select(Function(x) GetFolderVerificationMessage(x))) ++ HomeViewModelLog.InvalidFolders(_logger, invalidFolders.InvalidFolders, invalidFolders.InvalidMessages.Select(Function(result) GetFolderVerificationMessage(result))) + _snackbarService.ShowInvalidFoldersMessage(invalidFolders.InvalidFolders, invalidFolders.InvalidMessages) + End If + + For Each folderName In validFolders ++ If Folders.Any(Function(folder) String.Equals(folder.FolderName, folderName, StringComparison.OrdinalIgnoreCase)) Then Continue For ++ ++ Dim savedSession As SavedCompressionSession = Nothing ++ Dim resumeChoice As CompressionResumeChoice? = Nothing ++ ++ If resumeService.TryGetSession(folderName, savedSession) Then ++ resumeChoice = Await windowService.ShowResumeCompressionDialog(savedSession) ++ If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Cancel Then Continue For ++ If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.DiscardSavedProgress Then ++ resumeService.RemoveSession(folderName) ++ savedSession = Nothing ++ End If ++ End If + + Dim newFolder As CompressableFolder = Await CompressableFolderFactory.CreateCompressableFolder(folderName) ++ If newFolder Is Nothing Then Continue For + + newFolder.CompressionOptions.WatchFolderForChanges = _settingsService.AppSettings.WatchFolderForChanges + newFolder.CompressionOptions.SelectedCompressionMode = _settingsService.AppSettings.SelectedCompressionMode + newFolder.CompressionOptions.SkipPoorlyCompressedFileTypes = _settingsService.AppSettings.SkipNonCompressable + newFolder.CompressionOptions.SkipUserSubmittedFiletypes = _settingsService.AppSettings.SkipUserNonCompressable + +- If Not Folders.Any(Function(f) f.FolderName = newFolder.FolderName) Then +- Folders.Add(newFolder) +- Dim vm As New FolderViewModel(newFolder, _watcher, _snackbarService, _compressableFolderService) +- _folderViewModels.Add(newFolder, vm) +- SelectedFolder = newFolder ++ If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Resume AndAlso savedSession IsNot Nothing Then ++ newFolder.CompressionOptions.SelectedCompressionMode = savedSession.SelectedCompressionMode ++ newFolder.CompressionOptions.SkipPoorlyCompressedFileTypes = savedSession.SkipPoorlyCompressedFileTypes ++ newFolder.CompressionOptions.SkipUserSubmittedFiletypes = savedSession.SkipUserSubmittedFiletypes ++ newFolder.CompressionOptions.WatchFolderForChanges = savedSession.WatchFolderForChanges + End If + +- Dim res = Await _compressableFolderService.AnalyseFolderAsync(newFolder) +- If TypeOf (newFolder) Is SteamFolder Then +- Await CType(newFolder, SteamFolder).GetWikiResults() +- Else +- If _settingsService.AppSettings.EstimateCompressionForNonSteamFolders Then +- HomeViewModelLog.GettingEstimatedCompression(_logger, newFolder.FolderName, newFolder.UncompressedBytes) +- Await _compressableFolderService.GetEstimatedCompression(newFolder) +- End If ++ Folders.Add(newFolder) ++ Dim viewModel As New FolderViewModel(newFolder, _watcher, _snackbarService, _compressableFolderService) ++ _folderViewModels.Add(newFolder, viewModel) ++ SelectedFolder = newFolder + ++ Await _compressableFolderService.AnalyseFolderAsync(newFolder) ++ If TypeOf newFolder Is SteamFolder Then ++ Await CType(newFolder, SteamFolder).GetWikiResults() ++ ElseIf _settingsService.AppSettings.EstimateCompressionForNonSteamFolders Then ++ HomeViewModelLog.GettingEstimatedCompression(_logger, newFolder.FolderName, newFolder.UncompressedBytes) ++ Await _compressableFolderService.GetEstimatedCompression(newFolder) + End If + +- If _watcher.WatchedFolders.Any(Function(w) w.Folder = newFolder.FolderName) Then +- Dim watchedFolder = _watcher.WatchedFolders.First(Function(w) w.Folder = newFolder.FolderName) ++ If _watcher.WatchedFolders.Any(Function(watched) watched.Folder = newFolder.FolderName) Then ++ Dim watchedFolder = _watcher.WatchedFolders.First(Function(watched) watched.Folder = newFolder.FolderName) + newFolder.CompressionOptions.WatchFolderForChanges = True +- If watchedFolder.CompressionLevel <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then ++ If (Not resumeChoice.HasValue OrElse resumeChoice.Value <> CompressionResumeChoice.Resume) AndAlso watchedFolder.CompressionLevel <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then + newFolder.CompressionOptions.SelectedCompressionMode = Core.WOFHelper.CompressionModeFromWOFMode(watchedFolder.CompressionLevel) + End If +- + End If + +- +- ++ If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Resume Then foldersToResume.Add(newFolder) + Next + +- ++ If foldersToResume.Count > 0 Then Await CompressFoldersAsync(foldersToResume) + End Function + + +- +- + + Public Sub RemoveFolder(folder As CompressableFolder) + If Not CanRemoveFolder() Then +@@ -204,7 +218,7 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient + + Dim retState As ActionState + +- If Compressing OrElse Folders.Any(Function(f) f.FolderActionState = ActionState.Working OrElse f.FolderActionState = ActionState.Paused) Then ++ If Compressing OrElse Folders.Any(Function(f) f.FolderActionState = ActionState.Working OrElse f.FolderActionState = ActionState.Paused OrElse f.FolderActionState = ActionState.Undoing) Then + retState = ActionState.Working + ElseIf Folders.Any(Function(f) f.FolderActionState = ActionState.Analysing) Then + retState = ActionState.Analysing +@@ -230,48 +244,59 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient + + + Private Async Function CompressAll() As Task ++ Dim foldersToCompress = Folders.Where(Function(folder) folder.FolderActionState = ActionState.Idle).ToList() ++ Await CompressFoldersAsync(foldersToCompress) ++ End Function + +- Await _watcher.DisableBackgrounding() ++ Private Async Function CompressFoldersAsync(foldersToCompress As IReadOnlyCollection(Of CompressableFolder)) As Task ++ If foldersToCompress Is Nothing OrElse foldersToCompress.Count = 0 Then Return + ++ Await _watcher.DisableBackgrounding() + Compressing = True + Core.SharedMethods.PreventSleep() +- Dim tasks As New List(Of Task)() +- Dim foldersToCompress = Folders.Where(Function(f) f.FolderActionState = ActionState.Idle).ToList + HomeViewModelLog.StartingBatchCompression(_logger, foldersToCompress.Count) +- For Each folder In foldersToCompress +- If folder.FolderActionState = ActionState.Idle Then +- Await Task.Run(Async Function() +- HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) +- Dim ret = Await _compressableFolderService.CompressFolder(folder) +- Dim analysis = Await _compressableFolderService.AnalyseFolderAsync(folder) + +- If _settingsService.AppSettings.ShowNotifications Then ++ Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() + +- Application.GetService(Of TrayNotifierService).Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) ++ Try ++ For Each folder In foldersToCompress ++ If folder.FolderActionState = ActionState.Analysing OrElse folder.FolderActionState = ActionState.Working OrElse folder.FolderActionState = ActionState.Paused OrElse folder.FolderActionState = ActionState.Undoing Then Continue For + +- End If ++ Dim existingWatched = _watcher.WatchedFolders.FirstOrDefault(Function(watched) watched.Folder = folder.FolderName) ++ Dim previousWatcherTarget = If(existingWatched?.CompressionLevel, Core.WOFCompressionAlgorithm.NO_COMPRESSION) + +- Dim targetCompressionLevel = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) +- Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, ret, targetCompressionLevel:=targetCompressionLevel) ++ Await Task.Run(Async Function() ++ HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) ++ Dim runResult = Await _compressableFolderService.CompressFolder(folder) ++ Await _compressableFolderService.AnalyseFolderAsync(folder) + +- 'For Each poorext In folder.PoorlyCompressedFiles +- ' Debug.WriteLine($"{poorext.extension} : {poorext.totalFiles} with ratio of {poorext.cRatio}") +- 'Next ++ If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then ++ Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) ++ End If + ++ Dim watcherTarget = previousWatcherTarget ++ If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then ++ watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) ++ End If ++ ++ watcherTargetOverrides(folder) = watcherTarget ++ Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) + Return True + End Function) +- End If +- Next +- Compressing = False +- +- For Each folder In Folders.Where(Function(f) f.CompressionOptions.WatchFolderForChanges) +- Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed +- AddOrUpdateFolderWatcher(folder, wasCompressedInCurrentBatch) +- Next ++ Next + +- RemoveFolderCommand.NotifyCanExecuteChanged() +- Core.SharedMethods.RestoreSleep() +- Await _watcher.EnableBackgrounding() ++ For Each folder In Folders.Where(Function(item) item.CompressionOptions.WatchFolderForChanges) ++ Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed ++ Dim targetOverride = Core.WOFCompressionAlgorithm.NO_COMPRESSION ++ watcherTargetOverrides.TryGetValue(folder, targetOverride) ++ AddOrUpdateFolderWatcher(folder, wasCompressedInCurrentBatch, targetOverride) ++ Next ++ Finally ++ Compressing = False ++ RemoveFolderCommand.NotifyCanExecuteChanged() ++ Core.SharedMethods.RestoreSleep() ++ Await _watcher.EnableBackgrounding() ++ End Try + End Function + + +@@ -280,7 +305,7 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient + End Function + + +- Public Sub AddOrUpdateFolderWatcher(folder As CompressableFolder, wasCompressedInCurrentBatch As Boolean) ++ Public Sub AddOrUpdateFolderWatcher(folder As CompressableFolder, wasCompressedInCurrentBatch As Boolean, Optional targetOverride As Core.WOFCompressionAlgorithm = Core.WOFCompressionAlgorithm.NO_COMPRESSION) + HomeViewModelLog.AddingFolderToWatcher(_logger, folder.FolderName) + + Dim newWatched = New Watcher.WatchedFolder(folder.FolderName, folder.DisplayName) +@@ -292,7 +317,9 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient + newWatched.LastCheckedSize = folder.CompressedBytes + newWatched.LastSystemModifiedDate = DateTime.Now + Dim existingWatched = _watcher.WatchedFolders.FirstOrDefault(Function(w) w.Folder = folder.FolderName) +- If wasCompressedInCurrentBatch Then ++ If targetOverride <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then ++ newWatched.CompressionLevel = targetOverride ++ ElseIf wasCompressedInCurrentBatch Then + newWatched.CompressionLevel = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) + ElseIf existingWatched IsNot Nothing Then + newWatched.CompressionLevel = existingWatched.CompressionLevel From 5d0ef3cf2a9dd1590d493f802728c313c9ef72f6 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:52:26 -0400 Subject: [PATCH 06/39] Stage stop workflow patch part 4 --- .github/stop-patches/part03.patch | 183 ++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 .github/stop-patches/part03.patch diff --git a/.github/stop-patches/part03.patch b/.github/stop-patches/part03.patch new file mode 100644 index 00000000..49f47a38 --- /dev/null +++ b/.github/stop-patches/part03.patch @@ -0,0 +1,183 @@ +diff --git a/CompactGUI/i18n/i18n.resx b/CompactGUI/i18n/i18n.resx +index f410aaa..3725d7b 100644 +--- a/CompactGUI/i18n/i18n.resx ++++ b/CompactGUI/i18n/i18n.resx +@@ -578,4 +578,34 @@ Are you sure you want to exit? + + Mode + ++ ++ Stop compression? ++ ++ ++ Choose what CompactGUI should do with the work already completed for {0}. Save progress keeps the files and remembers these settings for later. Undo progress restores only files changed during this run. Leave as is keeps the files but does not save a resume session. ++ ++ ++ Save progress ++ ++ ++ Undo progress ++ ++ ++ Leave as is ++ ++ ++ Resume saved compression? ++ ++ ++ CompactGUI saved a {1} compression session for {0}. Resume the remaining files now? ++ ++ ++ Resume ++ ++ ++ Discard saved progress ++ ++ ++ Undoing compression ++ + +\ No newline at end of file +diff --git a/CompactGUI.Core/Compactor.cs b/CompactGUI.Core/Compactor.cs +index f927d37..b32e74e 100644 +--- a/CompactGUI.Core/Compactor.cs ++++ b/CompactGUI.Core/Compactor.cs +@@ -25,11 +25,14 @@ public sealed class Compactor : ICompressor, IDisposable + private long totalProcessedBytes = 0; + private readonly SemaphoreSlim pauseSemaphore = new SemaphoreSlim(1, 2); + private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); ++ private readonly ConcurrentDictionary processedFiles = new(StringComparer.OrdinalIgnoreCase); + + private ILogger _logger; + + private Analyser _analyser; + ++ public IReadOnlyDictionary ProcessedFiles => processedFiles; ++ + public Compactor(string folderPath, WOFCompressionAlgorithm compressionLevel, string[] excludedFileTypes, Analyser analyser, ILogger? logger = null) + { + workingDirectory = folderPath; +@@ -51,15 +54,16 @@ public sealed class Compactor : ICompressor, IDisposable + } + + +- public async Task RunAsync(List filesList, IProgress? progressMonitor = null, int maxParallelism = 1) ++ public async Task RunAsync(List? filesList, IProgress? progressMonitor = null, int maxParallelism = 1) + { + if(cancellationTokenSource.IsCancellationRequested) { return false; } + + CompactorLog.BuildingWorkingFilesList(_logger, workingDirectory); +- var workingFiles = await BuildWorkingFilesList().ConfigureAwait(false); ++ var workingFiles = await BuildWorkingFilesList(filesList).ConfigureAwait(false); + long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize); + + totalProcessedBytes = 0; ++ processedFiles.Clear(); + + var sw = Stopwatch.StartNew(); + +@@ -101,6 +105,10 @@ public sealed class Compactor : ICompressor, IDisposable + pauseSemaphore.Release(); + + var res = WOFCompressFile(file.FileName); ++ if (res == 0) ++ { ++ processedFiles.TryAdd(file.FileName, file.OriginalCompressionMode); ++ } + Interlocked.Add(ref totalProcessedBytes, file.UncompressedSize); + progressMonitor?.Report(new CompressionProgress((int)((double)totalProcessedBytes / totalFilesSize * 100.0), file.FileName)); + +@@ -122,11 +130,19 @@ public sealed class Compactor : ICompressor, IDisposable + } + } + +- public async Task> BuildWorkingFilesList() ++ public async Task> BuildWorkingFilesList(IReadOnlyCollection? filesList = null) + { ++ if (filesList is { Count: > 0 }) ++ { ++ return filesList ++ .Distinct(StringComparer.OrdinalIgnoreCase) ++ .Where(File.Exists) ++ .Select(file => new FileDetails(file, new FileInfo(file).Length, WOFCompressionAlgorithm.NO_COMPRESSION)) ++ .ToList(); ++ } ++ + uint clusterSize = SharedMethods.GetClusterSize(workingDirectory); + +- + var analysedFiles = await _analyser.GetAnalysedFilesAsync(cancellationTokenSource.Token) ?? []; + + return analysedFiles +@@ -135,14 +151,13 @@ public sealed class Compactor : ICompressor, IDisposable + && fl.UncompressedSize > clusterSize + && ((fl.FileInfo != null && !excludedFileExtensions.Contains(fl.FileInfo.Extension)) || excludedFileExtensions.Contains(fl.FileName)) + ) +- .Select(fl => new FileDetails(fl.FileName, fl.UncompressedSize)) ++ .Select(fl => new FileDetails(fl.FileName, fl.UncompressedSize, fl.CompressionMode)) + .ToList(); + } + + + + +- + public void Pause() + { + CompactorLog.CompressionPaused(_logger); +@@ -176,6 +191,6 @@ public sealed class Compactor : ICompressor, IDisposable + } + + +- public readonly record struct FileDetails(string FileName, long UncompressedSize); ++ public readonly record struct FileDetails(string FileName, long UncompressedSize, WOFCompressionAlgorithm OriginalCompressionMode); + + } +\ No newline at end of file +diff --git a/CompactGUI.Core/ICompressor.cs b/CompactGUI.Core/ICompressor.cs +index 04054c9..fc4d444 100644 +--- a/CompactGUI.Core/ICompressor.cs ++++ b/CompactGUI.Core/ICompressor.cs +@@ -2,7 +2,7 @@ + + public interface ICompressor : IDisposable + { +- Task RunAsync(List filesList, ++ Task RunAsync(List? filesList, + IProgress? progressMonitor = null, + int maxParallelism = 1); + +diff --git a/CompactGUI.Core/Uncompactor.cs b/CompactGUI.Core/Uncompactor.cs +index 17f9a52..2849445 100644 +--- a/CompactGUI.Core/Uncompactor.cs ++++ b/CompactGUI.Core/Uncompactor.cs +@@ -23,8 +23,9 @@ public sealed class Uncompactor : ICompressor, IDisposable + _logger = logger ?? NullLogger.Instance; + } + +- public async Task RunAsync(List filesList, IProgress? progressMonitor = null, int maxParallelism = 1) ++ public async Task RunAsync(List? filesList, IProgress? progressMonitor = null, int maxParallelism = 1) + { ++ filesList ??= []; + int totalFiles = filesList.Count; + if (maxParallelism <= 0) maxParallelism = Environment.ProcessorCount; + ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = maxParallelism }; +diff --git a/CompactGUI.Watcher/Watcher.vb b/CompactGUI.Watcher/Watcher.vb +index 6d35061..ac448f5 100644 +--- a/CompactGUI.Watcher/Watcher.vb ++++ b/CompactGUI.Watcher/Watcher.vb +@@ -1,4 +1,4 @@ +-Imports System.Collections.ObjectModel ++Imports System.Collections.ObjectModel + Imports System.Collections.Specialized + Imports System.Runtime + Imports System.Text.Json +@@ -288,7 +288,7 @@ Partial Public Class Watcher : Inherits ObservableRecipient : Implements IRecipi + existingItem.LastCheckedSize = analyser.CompressedBytes + existingItem.LastUncompressedSize = analyser.UncompressedBytes + existingItem.LastSystemModifiedDate = DateTime.Now +- If isFreshlyCompressed AndAlso targetCompressionLevel <> WOFCompressionAlgorithm.NO_COMPRESSION Then ++ If targetCompressionLevel <> WOFCompressionAlgorithm.NO_COMPRESSION Then + existingItem.CompressionLevel = targetCompressionLevel + Else + existingItem.CompressionLevel = WOFHelper.GetDominantCompressionMode(analysedFiles) From 83ac53c3641ec9493bc465dd8d39e94cbb2619bb Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:52:50 -0400 Subject: [PATCH 07/39] CI: apply safe compression stop workflow --- .github/workflows/apply-compression-stop.yml | 41 ++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/apply-compression-stop.yml diff --git a/.github/workflows/apply-compression-stop.yml b/.github/workflows/apply-compression-stop.yml new file mode 100644 index 00000000..2e99d7ba --- /dev/null +++ b/.github/workflows/apply-compression-stop.yml @@ -0,0 +1,41 @@ +name: Apply compression stop workflow + +on: + pull_request: + branches: + - master + +permissions: + contents: write + +jobs: + apply: + if: github.head_ref == 'agent/compression-stop-options' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Check out feature branch + uses: actions/checkout@v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Apply feature patch and remove staging files + shell: bash + env: + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + + cat .github/stop-patches/part*.patch > "$RUNNER_TEMP/compression-stop.patch" + git apply --check "$RUNNER_TEMP/compression-stop.patch" + git apply --whitespace=nowarn "$RUNNER_TEMP/compression-stop.patch" + + rm -rf .github/stop-patches .github/stop-payload + rm -f .github/workflows/source-export.yml + rm -f .github/workflows/apply-compression-stop.yml + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Add safe manual compression stop choices" + git push origin "HEAD:$HEAD_REF" From db4886e477ebc8fd6a28b291103dd0d8123dd4b2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:53:02 +0000 Subject: [PATCH 08/39] Add safe manual compression stop choices --- .github/stop-patches/part00.patch | 262 ------------------ .github/stop-patches/part01.patch | 237 ---------------- .github/stop-patches/part02.patch | 222 --------------- .github/stop-patches/part03.patch | 183 ------------ .github/stop-payload/part00 | 1 - .github/workflows/apply-compression-stop.yml | 41 --- .github/workflows/source-export.yml | 28 -- CompactGUI.Core/Compactor.cs | 29 +- CompactGUI.Core/ICompressor.cs | 2 +- CompactGUI.Core/Uncompactor.cs | 3 +- CompactGUI.Watcher/Watcher.vb | 4 +- CompactGUI/Application.xaml.vb | 1 + .../Components/Converters/IValueConverters.vb | 6 +- .../FolderActionStateTemplateSelector.vb | 4 +- .../CompressableFolders/CompressableFolder.vb | 1 + CompactGUI/Models/CompressionStopModels.vb | 26 ++ .../Services/CompressableFolderService.vb | 108 ++++++-- .../Services/CompressionResumeService.vb | 113 ++++++++ CompactGUI/Services/WindowService.vb | 44 +++ CompactGUI/ViewModels/FolderViewModel.vb | 33 ++- CompactGUI/ViewModels/HomeViewModel.vb | 139 ++++++---- CompactGUI/i18n/i18n.resx | 30 ++ 22 files changed, 447 insertions(+), 1070 deletions(-) delete mode 100644 .github/stop-patches/part00.patch delete mode 100644 .github/stop-patches/part01.patch delete mode 100644 .github/stop-patches/part02.patch delete mode 100644 .github/stop-patches/part03.patch delete mode 100644 .github/stop-payload/part00 delete mode 100644 .github/workflows/apply-compression-stop.yml delete mode 100644 .github/workflows/source-export.yml create mode 100644 CompactGUI/Models/CompressionStopModels.vb create mode 100644 CompactGUI/Services/CompressionResumeService.vb diff --git a/.github/stop-patches/part00.patch b/.github/stop-patches/part00.patch deleted file mode 100644 index 4e4d167b..00000000 --- a/.github/stop-patches/part00.patch +++ /dev/null @@ -1,262 +0,0 @@ -diff --git a/CompactGUI/Application.xaml.vb b/CompactGUI/Application.xaml.vb -index 4c17cc6..685951d 100644 ---- a/CompactGUI/Application.xaml.vb -+++ b/CompactGUI/Application.xaml.vb -@@ -74,6 +74,7 @@ Partial Public Class Application - services.AddSingleton(Of INavigationService, NavigationService)() - services.AddSingleton(Of CustomSnackBarService)() - services.AddSingleton(Of IWindowService, WindowService)() -+ services.AddSingleton(Of CompressionResumeService)() - services.AddSingleton(Of IUpdaterService, UpdaterService)() - services.AddSingleton(Of IWikiService, WikiService)() - -diff --git a/CompactGUI/Components/Converters/IValueConverters.vb b/CompactGUI/Components/Converters/IValueConverters.vb -index 01acbaf..135b630 100644 ---- a/CompactGUI/Components/Converters/IValueConverters.vb -+++ b/CompactGUI/Components/Converters/IValueConverters.vb -@@ -281,7 +281,7 @@ Public Class FolderStatusToColorConverter : Implements IValueConverter - Select Case status - Case ActionState.Idle - Return New SolidColorBrush(ColorConverter.ConvertFromString("#92e7f1")) -- Case ActionState.Analysing, ActionState.Working, ActionState.Paused -+ Case ActionState.Analysing, ActionState.Working, ActionState.Paused, ActionState.Undoing - Return New SolidColorBrush(ColorConverter.ConvertFromString("#F1CE92")) - Case ActionState.Results - Return New SolidColorBrush(ColorConverter.ConvertFromString("#92F1AB")) -@@ -305,6 +305,8 @@ Public Class FolderStatusToStringConverter : Implements IValueConverter - Return LanguageHelper.GetString("Status_Analysing") - Case ActionState.Working, ActionState.Paused - Return LanguageHelper.GetString("Status_Working") -+ Case ActionState.Undoing -+ Return LanguageHelper.GetString("Status_Undoing") - Case ActionState.Results - Return LanguageHelper.GetString("Status_Compressed") - Case Else -@@ -406,7 +408,7 @@ Public Class FolderActionStateWorkingToVisibilityConverter - Public Function Convert(value As Object, targetType As Type, parameter As Object, culture As CultureInfo) As Object Implements IValueConverter.Convert - Dim status = CType(value, ActionState) - Select Case status -- Case ActionState.Working -+ Case ActionState.Working, ActionState.Undoing - Return Visibility.Visible - Case Else - Return Visibility.Collapsed -diff --git a/CompactGUI/Components/FolderActionStateTemplateSelector.vb b/CompactGUI/Components/FolderActionStateTemplateSelector.vb -index b028349..2395c54 100644 ---- a/CompactGUI/Components/FolderActionStateTemplateSelector.vb -+++ b/CompactGUI/Components/FolderActionStateTemplateSelector.vb -@@ -15,7 +15,7 @@ - Return IdleTemplate - Case ActionState.Analysing - Return AnalysingTemplate -- Case ActionState.Working, ActionState.Paused -+ Case ActionState.Working, ActionState.Paused, ActionState.Undoing - Return CompressingTemplate - Case ActionState.Results - Return ResultsTemplate -@@ -42,7 +42,7 @@ Public Class HomeViewStateTemplateSelector - Return IdleTemplate - Case ActionState.Analysing - Return AnalysingTemplate -- Case ActionState.Working, ActionState.Paused -+ Case ActionState.Working, ActionState.Paused, ActionState.Undoing - Return CompressingTemplate - Case ActionState.Results - Return ResultsTemplate -diff --git a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb -index 815050f..2b6a841 100644 ---- a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb -+++ b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb -@@ -106,5 +106,6 @@ Public Enum ActionState - Working - Results - Paused -+ Undoing - Waiting - End Enum -\ No newline at end of file -diff --git a/CompactGUI/Models/CompressionStopModels.vb b/CompactGUI/Models/CompressionStopModels.vb -new file mode 100644 -index 0000000..441e584 ---- /dev/null -+++ b/CompactGUI/Models/CompressionStopModels.vb -@@ -0,0 +1,26 @@ -+Public Enum CompressionStopChoice -+ SaveProgress -+ UndoProgress -+ LeaveAsIs -+End Enum -+ -+Public Enum CompressionResumeChoice -+ Resume -+ DiscardSavedProgress -+ Cancel -+End Enum -+ -+ -+Public NotInheritable Class CompressionRunResult -+ Public Property Completed As Boolean -+ Public Property StopChoice As CompressionStopChoice? -+End Class -+ -+Public NotInheritable Class SavedCompressionSession -+ Public Property FolderPath As String = String.Empty -+ Public Property SelectedCompressionMode As Core.CompressionMode -+ Public Property SkipPoorlyCompressedFileTypes As Boolean -+ Public Property SkipUserSubmittedFiletypes As Boolean -+ Public Property WatchFolderForChanges As Boolean -+ Public Property SavedAt As DateTime = DateTime.Now -+End Class -diff --git a/CompactGUI/Services/CompressableFolderService.vb b/CompactGUI/Services/CompressableFolderService.vb -index 594c344..5d487c3 100644 ---- a/CompactGUI/Services/CompressableFolderService.vb -+++ b/CompactGUI/Services/CompressableFolderService.vb -@@ -1,3 +1,4 @@ -+Imports System.Collections.Concurrent - Imports System.Collections.ObjectModel - Imports System.Threading - -@@ -15,56 +16,123 @@ Public Class CompressableFolderService - Private Shared ReadOnly UncompactorLogger As ILogger = Application.GetService(Of ILogger(Of Uncompactor))() - Private Shared ReadOnly AnalyserLogger As ILogger = Application.GetService(Of ILogger(Of Analyser))() - -+ Private ReadOnly _resumeService As CompressionResumeService - Private folderTokens As New Dictionary(Of CompressableFolder, CancellationTokenSource) -+ Private ReadOnly stopChoices As New ConcurrentDictionary(Of CompressableFolder, CompressionStopChoice) -+ -+ Public Sub New(resumeService As CompressionResumeService) -+ _resumeService = resumeService -+ End Sub - - -- Public Async Function CompressFolder(folder As CompressableFolder) As Task(Of Boolean) -+ Public Async Function CompressFolder(folder As CompressableFolder) As Task(Of CompressionRunResult) - folder.Compressor = New Compactor(folder.FolderName, WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode), GetSkipList(folder), folder.Analyser, CompactorLogger) - Return Await RunCompressionAsync(folder, folder.Compressor, Nothing, True) -- -- - End Function - - - Public Async Function UncompressFolder(folder As CompressableFolder) As Task(Of Boolean) - -+ _resumeService.RemoveSession(folder.FolderName) - folder.Compressor = New Uncompactor(UncompactorLogger) - Dim compressedFilesList = folder.AnalysisResults.Where(Function(rs) rs.CompressedSize < rs.UncompressedSize).Select(Of String)(Function(f) f.FileName).ToList -- Return Await RunCompressionAsync(folder, folder.Compressor, compressedFilesList, isCompressing:=False) -+ Dim result = Await RunCompressionAsync(folder, folder.Compressor, compressedFilesList, isCompressing:=False) -+ Await AnalyseFolderAsync(folder) -+ Return result.Completed -+ End Function - - -- End Function -+ Public Sub RequestCompressionStop(folder As CompressableFolder, choice As CompressionStopChoice) -+ If folder Is Nothing OrElse Not TypeOf folder.Compressor Is Compactor Then Return -+ If folder.FolderActionState <> ActionState.Working AndAlso folder.FolderActionState <> ActionState.Paused Then Return -+ -+ If choice = CompressionStopChoice.SaveProgress Then -+ _resumeService.SaveSession(folder) -+ Else -+ _resumeService.RemoveSession(folder.FolderName) -+ End If -+ -+ stopChoices(folder) = choice -+ -+ Try -+ folder.Compressor.Cancel() -+ Catch ex As ObjectDisposedException -+ 'The compression completed while the stop dialog was open. -+ End Try -+ End Sub - -- Private Async Function RunCompressionAsync(folder As CompressableFolder, compressor As ICompressor, filesList As List(Of String), isCompressing As Boolean) As Task(Of Boolean) -+ Private Async Function RunCompressionAsync(folder As CompressableFolder, compressor As ICompressor, filesList As List(Of String), isCompressing As Boolean) As Task(Of CompressionRunResult) - folder.FolderActionState = ActionState.Working - - CancelEstimation(folder) - Dim cts = New CancellationTokenSource() - folderTokens(folder) = cts - Dim progress As IProgress(Of CompressionProgress) = New Progress(Of CompressionProgress)(Sub(x) folder.CompressionProgress = x) -+ Dim runResult As New CompressionRunResult() - - progress.Report(New CompressionProgress(0, "")) - -- Dim res = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) -+ Try -+ runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) -+ -+ If isCompressing Then -+ Dim stopChoice As CompressionStopChoice -+ If Not runResult.Completed AndAlso stopChoices.TryRemove(folder, stopChoice) Then -+ runResult.StopChoice = stopChoice -+ -+ If stopChoice = CompressionStopChoice.UndoProgress AndAlso TypeOf compressor Is Compactor Then -+ folder.FolderActionState = ActionState.Undoing -+ Await RestoreProcessedFilesAsync(folder, DirectCast(compressor, Compactor), progress) -+ End If -+ ElseIf runResult.Completed Then -+ stopChoices.TryRemove(folder, stopChoice) -+ _resumeService.RemoveSession(folder.FolderName) -+ End If -+ -+ folder.FolderActionState = ActionState.Results -+ folder.IsFreshlyCompressed = runResult.Completed -+ Else -+ folder.FolderActionState = ActionState.Idle -+ folder.IsFreshlyCompressed = False -+ End If - -- If isCompressing Then -- folder.FolderActionState = ActionState.Results -- folder.IsFreshlyCompressed = res -- Else -- folder.FolderActionState = ActionState.Idle -- folder.IsFreshlyCompressed = False -- Await AnalyseFolderAsync(folder) -- End If -- compressor.Dispose() -+ Return runResult -+ Finally -+ compressor.Dispose() -+ -+ Dim ownedToken As CancellationTokenSource = Nothing -+ If folderTokens.TryGetValue(folder, ownedToken) AndAlso Object.ReferenceEquals(ownedToken, cts) Then -+ folderTokens.Remove(folder) -+ ownedToken.Dispose() -+ Else -+ cts.Dispose() -+ End If -+ End Try -+ End Function - - -- folderTokens(folder).Dispose() -- folderTokens.Remove(folder) - -+ Private Async Function RestoreProcessedFilesAsync(folder As CompressableFolder, compactor As Compactor, progress As IProgress(Of CompressionProgress)) As Task -+ Dim processedFiles = compactor.ProcessedFiles.ToList() -+ If processedFiles.Count = 0 Then Return - -- Return res -- End Function -+ Dim threadCount = GetThreadCount(folder) - -+ For Each modeGroup In processedFiles.GroupBy(Function(item) item.Value) -+ Dim files = modeGroup.Select(Function(item) item.Key).ToList() -+ -+ Select Case modeGroup.Key -+ Case WOFCompressionAlgorithm.XPRESS4K, WOFCompressionAlgorithm.XPRESS8K, WOFCompressionAlgorithm.XPRESS16K, WOFCompressionAlgorithm.LZX -+ Using restorer As New Compactor(folder.FolderName, modeGroup.Key, Array.Empty(Of String), folder.Analyser, CompactorLogger) -+ Await restorer.RunAsync(files, progress, threadCount) -+ End Using -+ Case Else -+ Using uncompactor As New Uncompactor(UncompactorLogger) -+ Await uncompactor.RunAsync(files, progress, threadCount) -+ End Using -+ End Select -+ Next -+ End Function - - Public Async Function AnalyseFolderAsync(folder As CompressableFolder) As Task(Of Integer) - diff --git a/.github/stop-patches/part01.patch b/.github/stop-patches/part01.patch deleted file mode 100644 index f372c426..00000000 --- a/.github/stop-patches/part01.patch +++ /dev/null @@ -1,237 +0,0 @@ -diff --git a/CompactGUI/Services/CompressionResumeService.vb b/CompactGUI/Services/CompressionResumeService.vb -new file mode 100644 -index 0000000..f2ba4aa ---- /dev/null -+++ b/CompactGUI/Services/CompressionResumeService.vb -@@ -0,0 +1,113 @@ -+Imports System.Text.Json -+ -+Imports CompactGUI.Core.Settings -+ -+Imports Microsoft.Extensions.Logging -+ -+Public NotInheritable Class CompressionResumeService -+ -+ Private ReadOnly _sessionsFile As IO.FileInfo -+ Private ReadOnly _logger As ILogger(Of CompressionResumeService) -+ Private ReadOnly _syncRoot As New Object -+ Private ReadOnly _jsonOptions As New JsonSerializerOptions With {.WriteIndented = True} -+ Private _sessions As Dictionary(Of String, SavedCompressionSession) -+ -+ Public Sub New(settingsService As ISettingsService, logger As ILogger(Of CompressionResumeService)) -+ _logger = logger -+ _sessionsFile = New IO.FileInfo(IO.Path.Combine(settingsService.DataFolder.FullName, "compression-resume.json")) -+ _sessions = LoadSessions() -+ End Sub -+ -+ Public Function TryGetSession(folderPath As String, ByRef session As SavedCompressionSession) As Boolean -+ Dim key = NormalizePath(folderPath) -+ -+ SyncLock _syncRoot -+ Dim stored As SavedCompressionSession = Nothing -+ If Not _sessions.TryGetValue(key, stored) Then Return False -+ -+ session = CloneSession(stored) -+ Return True -+ End SyncLock -+ End Function -+ -+ Public Sub SaveSession(folder As CompressableFolder) -+ Dim session = New SavedCompressionSession With { -+ .FolderPath = NormalizePath(folder.FolderName), -+ .SelectedCompressionMode = folder.CompressionOptions.SelectedCompressionMode, -+ .SkipPoorlyCompressedFileTypes = folder.CompressionOptions.SkipPoorlyCompressedFileTypes, -+ .SkipUserSubmittedFiletypes = folder.CompressionOptions.SkipUserSubmittedFiletypes, -+ .WatchFolderForChanges = folder.CompressionOptions.WatchFolderForChanges, -+ .SavedAt = DateTime.Now -+ } -+ -+ SyncLock _syncRoot -+ _sessions(session.FolderPath) = session -+ WriteSessions() -+ End SyncLock -+ End Sub -+ -+ Public Sub RemoveSession(folderPath As String) -+ Dim key = NormalizePath(folderPath) -+ -+ SyncLock _syncRoot -+ If _sessions.Remove(key) Then WriteSessions() -+ End SyncLock -+ End Sub -+ -+ Private Function LoadSessions() As Dictionary(Of String, SavedCompressionSession) -+ Dim loaded = New Dictionary(Of String, SavedCompressionSession)(StringComparer.OrdinalIgnoreCase) -+ -+ Try -+ If Not _sessionsFile.Directory.Exists Then _sessionsFile.Directory.Create() -+ If Not _sessionsFile.Exists Then Return loaded -+ -+ Dim json = IO.File.ReadAllText(_sessionsFile.FullName) -+ If String.IsNullOrWhiteSpace(json) Then Return loaded -+ -+ Dim entries = JsonSerializer.Deserialize(Of List(Of SavedCompressionSession))(json, _jsonOptions) -+ If entries Is Nothing Then Return loaded -+ -+ For Each entry In entries -+ If String.IsNullOrWhiteSpace(entry.FolderPath) Then Continue For -+ entry.FolderPath = NormalizePath(entry.FolderPath) -+ loaded(entry.FolderPath) = entry -+ Next -+ Catch ex As Exception -+ _logger.LogWarning(ex, "Could not load saved compression sessions from {SessionFile}", _sessionsFile.FullName) -+ End Try -+ -+ Return loaded -+ End Function -+ -+ Private Sub WriteSessions() -+ Try -+ If Not _sessionsFile.Directory.Exists Then _sessionsFile.Directory.Create() -+ Dim snapshot = _sessions.Values.OrderBy(Function(entry) entry.FolderPath, StringComparer.OrdinalIgnoreCase).ToList() -+ IO.File.WriteAllText(_sessionsFile.FullName, JsonSerializer.Serialize(snapshot, _jsonOptions)) -+ Catch ex As Exception -+ _logger.LogWarning(ex, "Could not save compression sessions to {SessionFile}", _sessionsFile.FullName) -+ End Try -+ End Sub -+ -+ Private Shared Function CloneSession(source As SavedCompressionSession) As SavedCompressionSession -+ Return New SavedCompressionSession With { -+ .FolderPath = source.FolderPath, -+ .SelectedCompressionMode = source.SelectedCompressionMode, -+ .SkipPoorlyCompressedFileTypes = source.SkipPoorlyCompressedFileTypes, -+ .SkipUserSubmittedFiletypes = source.SkipUserSubmittedFiletypes, -+ .WatchFolderForChanges = source.WatchFolderForChanges, -+ .SavedAt = source.SavedAt -+ } -+ End Function -+ -+ Private Shared Function NormalizePath(folderPath As String) As String -+ If String.IsNullOrWhiteSpace(folderPath) Then Return String.Empty -+ -+ Try -+ Return IO.Path.GetFullPath(folderPath).TrimEnd(IO.Path.DirectorySeparatorChar, IO.Path.AltDirectorySeparatorChar) -+ Catch ex As Exception -+ Return folderPath.TrimEnd(IO.Path.DirectorySeparatorChar, IO.Path.AltDirectorySeparatorChar) -+ End Try -+ End Function -+ -+End Class -diff --git a/CompactGUI/Services/WindowService.vb b/CompactGUI/Services/WindowService.vb -index 764aefe..eb6ae39 100644 ---- a/CompactGUI/Services/WindowService.vb -+++ b/CompactGUI/Services/WindowService.vb -@@ -3,6 +3,8 @@ - Sub MinimizeMainWindow() - Sub HideMainWindow() - Function ShowMessageBox(title As String, content As String) As Task(Of Boolean) -+ Function ShowCompressionStopDialog(folderName As String) As Task(Of CompressionStopChoice) -+ Function ShowResumeCompressionDialog(session As SavedCompressionSession) As Task(Of CompressionResumeChoice) - End Interface - - -@@ -39,4 +41,46 @@ Public Class WindowService - Dim result = Await msgBox.ShowDialogAsync() - Return result = Wpf.Ui.Controls.MessageBoxResult.Primary - End Function -+ -+ Public Async Function ShowCompressionStopDialog(folderName As String) As Task(Of CompressionStopChoice) Implements IWindowService.ShowCompressionStopDialog -+ Dim msgBox = New Wpf.Ui.Controls.MessageBox With { -+ .Title = LanguageHelper.GetString("CompressionStop_Title"), -+ .Content = String.Format(LanguageHelper.GetString("CompressionStop_Message"), folderName), -+ .IsPrimaryButtonEnabled = True, -+ .IsSecondaryButtonEnabled = True, -+ .PrimaryButtonText = LanguageHelper.GetString("CompressionStop_SaveProgress"), -+ .SecondaryButtonText = LanguageHelper.GetString("CompressionStop_UndoProgress"), -+ .CloseButtonText = LanguageHelper.GetString("CompressionStop_LeaveAsIs") -+ } -+ -+ Select Case Await msgBox.ShowDialogAsync() -+ Case Wpf.Ui.Controls.MessageBoxResult.Primary -+ Return CompressionStopChoice.SaveProgress -+ Case Wpf.Ui.Controls.MessageBoxResult.Secondary -+ Return CompressionStopChoice.UndoProgress -+ Case Else -+ Return CompressionStopChoice.LeaveAsIs -+ End Select -+ End Function -+ -+ Public Async Function ShowResumeCompressionDialog(session As SavedCompressionSession) As Task(Of CompressionResumeChoice) Implements IWindowService.ShowResumeCompressionDialog -+ Dim msgBox = New Wpf.Ui.Controls.MessageBox With { -+ .Title = LanguageHelper.GetString("CompressionResume_Title"), -+ .Content = String.Format(LanguageHelper.GetString("CompressionResume_Message"), session.FolderPath, session.SelectedCompressionMode), -+ .IsPrimaryButtonEnabled = True, -+ .IsSecondaryButtonEnabled = True, -+ .PrimaryButtonText = LanguageHelper.GetString("CompressionResume_Resume"), -+ .SecondaryButtonText = LanguageHelper.GetString("CompressionResume_Discard"), -+ .CloseButtonText = LanguageHelper.GetString("UniCancel") -+ } -+ -+ Select Case Await msgBox.ShowDialogAsync() -+ Case Wpf.Ui.Controls.MessageBoxResult.Primary -+ Return CompressionResumeChoice.Resume -+ Case Wpf.Ui.Controls.MessageBoxResult.Secondary -+ Return CompressionResumeChoice.DiscardSavedProgress -+ Case Else -+ Return CompressionResumeChoice.Cancel -+ End Select -+ End Function - End Class -diff --git a/CompactGUI/ViewModels/FolderViewModel.vb b/CompactGUI/ViewModels/FolderViewModel.vb -index b4459eb..0fa5632 100644 ---- a/CompactGUI/ViewModels/FolderViewModel.vb -+++ b/CompactGUI/ViewModels/FolderViewModel.vb -@@ -131,6 +131,8 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem - OnPropertyChanged(NameOf(CompressionDisplayLevel)) - OnPropertyChanged(NameOf(DisplayedFolderAfterSize)) - OnPropertyChanged(NameOf(TotalFiles)) -+ PauseCommand.NotifyCanExecuteChanged() -+ CancelCommand.NotifyCanExecuteChanged() - - ElseIf e.PropertyName = NameOf(Folder.CompressionProgress) Then - CompressionProgress = Folder.CompressionProgress.ProgressPercent -@@ -165,7 +167,7 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem - _snackbarService.ShowAppliedToAllFolders() - End Sub - -- -+ - Private Sub Pause() - - If Folder.FolderActionState = ActionState.Working Then -@@ -178,10 +180,31 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem - End If - End Sub - -- -- Private Sub Cancel() -- Folder.Compressor?.Cancel() -- End Sub -+ Private Function CanControlCompression() As Boolean -+ Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) -+ End Function -+ -+ -+ Private Async Function Cancel() As Task -+ If Not TypeOf Folder.Compressor Is Core.Compactor Then -+ Folder.Compressor?.Cancel() -+ Return -+ End If -+ -+ If Folder.FolderActionState = ActionState.Working Then -+ Try -+ Folder.Compressor?.Pause() -+ Folder.FolderActionState = ActionState.Paused -+ Catch ex As OperationCanceledException -+ Return -+ Catch ex As ObjectDisposedException -+ Return -+ End Try -+ End If -+ -+ Dim choice = Await Application.GetService(Of IWindowService)().ShowCompressionStopDialog(Folder.DisplayName) -+ _compressableFolderService.RequestCompressionStop(Folder, choice) -+ End Function - - - Private Async Function SubmitToWiki() As Task diff --git a/.github/stop-patches/part02.patch b/.github/stop-patches/part02.patch deleted file mode 100644 index 3d9d45de..00000000 --- a/.github/stop-patches/part02.patch +++ /dev/null @@ -1,222 +0,0 @@ -diff --git a/CompactGUI/ViewModels/HomeViewModel.vb b/CompactGUI/ViewModels/HomeViewModel.vb -index edc4a56..3e84f69 100644 ---- a/CompactGUI/ViewModels/HomeViewModel.vb -+++ b/CompactGUI/ViewModels/HomeViewModel.vb -@@ -110,61 +110,75 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient - - HomeViewModelLog.AddingFolders(_logger, folderPaths) - -- Dim invalidFolders = GetInvalidFolders(folderPaths.ToArray) -+ Dim invalidFolders = GetInvalidFolders(folderPaths.ToArray()) - Dim validFolders = folderPaths.Except(invalidFolders.InvalidFolders) -+ Dim foldersToResume As New List(Of CompressableFolder)() -+ Dim resumeService = Application.GetService(Of CompressionResumeService)() -+ Dim windowService = Application.GetService(Of IWindowService)() - - If invalidFolders.InvalidFolders.Count > 0 Then -- 'TODO: Move this logger check to the snackbarService class? -- HomeViewModelLog.InvalidFolders(_logger, invalidFolders.InvalidFolders, invalidFolders.InvalidMessages.Select(Function(x) GetFolderVerificationMessage(x))) -+ HomeViewModelLog.InvalidFolders(_logger, invalidFolders.InvalidFolders, invalidFolders.InvalidMessages.Select(Function(result) GetFolderVerificationMessage(result))) - _snackbarService.ShowInvalidFoldersMessage(invalidFolders.InvalidFolders, invalidFolders.InvalidMessages) - End If - - For Each folderName In validFolders -+ If Folders.Any(Function(folder) String.Equals(folder.FolderName, folderName, StringComparison.OrdinalIgnoreCase)) Then Continue For -+ -+ Dim savedSession As SavedCompressionSession = Nothing -+ Dim resumeChoice As CompressionResumeChoice? = Nothing -+ -+ If resumeService.TryGetSession(folderName, savedSession) Then -+ resumeChoice = Await windowService.ShowResumeCompressionDialog(savedSession) -+ If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Cancel Then Continue For -+ If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.DiscardSavedProgress Then -+ resumeService.RemoveSession(folderName) -+ savedSession = Nothing -+ End If -+ End If - - Dim newFolder As CompressableFolder = Await CompressableFolderFactory.CreateCompressableFolder(folderName) -+ If newFolder Is Nothing Then Continue For - - newFolder.CompressionOptions.WatchFolderForChanges = _settingsService.AppSettings.WatchFolderForChanges - newFolder.CompressionOptions.SelectedCompressionMode = _settingsService.AppSettings.SelectedCompressionMode - newFolder.CompressionOptions.SkipPoorlyCompressedFileTypes = _settingsService.AppSettings.SkipNonCompressable - newFolder.CompressionOptions.SkipUserSubmittedFiletypes = _settingsService.AppSettings.SkipUserNonCompressable - -- If Not Folders.Any(Function(f) f.FolderName = newFolder.FolderName) Then -- Folders.Add(newFolder) -- Dim vm As New FolderViewModel(newFolder, _watcher, _snackbarService, _compressableFolderService) -- _folderViewModels.Add(newFolder, vm) -- SelectedFolder = newFolder -+ If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Resume AndAlso savedSession IsNot Nothing Then -+ newFolder.CompressionOptions.SelectedCompressionMode = savedSession.SelectedCompressionMode -+ newFolder.CompressionOptions.SkipPoorlyCompressedFileTypes = savedSession.SkipPoorlyCompressedFileTypes -+ newFolder.CompressionOptions.SkipUserSubmittedFiletypes = savedSession.SkipUserSubmittedFiletypes -+ newFolder.CompressionOptions.WatchFolderForChanges = savedSession.WatchFolderForChanges - End If - -- Dim res = Await _compressableFolderService.AnalyseFolderAsync(newFolder) -- If TypeOf (newFolder) Is SteamFolder Then -- Await CType(newFolder, SteamFolder).GetWikiResults() -- Else -- If _settingsService.AppSettings.EstimateCompressionForNonSteamFolders Then -- HomeViewModelLog.GettingEstimatedCompression(_logger, newFolder.FolderName, newFolder.UncompressedBytes) -- Await _compressableFolderService.GetEstimatedCompression(newFolder) -- End If -+ Folders.Add(newFolder) -+ Dim viewModel As New FolderViewModel(newFolder, _watcher, _snackbarService, _compressableFolderService) -+ _folderViewModels.Add(newFolder, viewModel) -+ SelectedFolder = newFolder - -+ Await _compressableFolderService.AnalyseFolderAsync(newFolder) -+ If TypeOf newFolder Is SteamFolder Then -+ Await CType(newFolder, SteamFolder).GetWikiResults() -+ ElseIf _settingsService.AppSettings.EstimateCompressionForNonSteamFolders Then -+ HomeViewModelLog.GettingEstimatedCompression(_logger, newFolder.FolderName, newFolder.UncompressedBytes) -+ Await _compressableFolderService.GetEstimatedCompression(newFolder) - End If - -- If _watcher.WatchedFolders.Any(Function(w) w.Folder = newFolder.FolderName) Then -- Dim watchedFolder = _watcher.WatchedFolders.First(Function(w) w.Folder = newFolder.FolderName) -+ If _watcher.WatchedFolders.Any(Function(watched) watched.Folder = newFolder.FolderName) Then -+ Dim watchedFolder = _watcher.WatchedFolders.First(Function(watched) watched.Folder = newFolder.FolderName) - newFolder.CompressionOptions.WatchFolderForChanges = True -- If watchedFolder.CompressionLevel <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then -+ If (Not resumeChoice.HasValue OrElse resumeChoice.Value <> CompressionResumeChoice.Resume) AndAlso watchedFolder.CompressionLevel <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then - newFolder.CompressionOptions.SelectedCompressionMode = Core.WOFHelper.CompressionModeFromWOFMode(watchedFolder.CompressionLevel) - End If -- - End If - -- -- -+ If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Resume Then foldersToResume.Add(newFolder) - Next - -- -+ If foldersToResume.Count > 0 Then Await CompressFoldersAsync(foldersToResume) - End Function - - -- -- - - Public Sub RemoveFolder(folder As CompressableFolder) - If Not CanRemoveFolder() Then -@@ -204,7 +218,7 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient - - Dim retState As ActionState - -- If Compressing OrElse Folders.Any(Function(f) f.FolderActionState = ActionState.Working OrElse f.FolderActionState = ActionState.Paused) Then -+ If Compressing OrElse Folders.Any(Function(f) f.FolderActionState = ActionState.Working OrElse f.FolderActionState = ActionState.Paused OrElse f.FolderActionState = ActionState.Undoing) Then - retState = ActionState.Working - ElseIf Folders.Any(Function(f) f.FolderActionState = ActionState.Analysing) Then - retState = ActionState.Analysing -@@ -230,48 +244,59 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient - - - Private Async Function CompressAll() As Task -+ Dim foldersToCompress = Folders.Where(Function(folder) folder.FolderActionState = ActionState.Idle).ToList() -+ Await CompressFoldersAsync(foldersToCompress) -+ End Function - -- Await _watcher.DisableBackgrounding() -+ Private Async Function CompressFoldersAsync(foldersToCompress As IReadOnlyCollection(Of CompressableFolder)) As Task -+ If foldersToCompress Is Nothing OrElse foldersToCompress.Count = 0 Then Return - -+ Await _watcher.DisableBackgrounding() - Compressing = True - Core.SharedMethods.PreventSleep() -- Dim tasks As New List(Of Task)() -- Dim foldersToCompress = Folders.Where(Function(f) f.FolderActionState = ActionState.Idle).ToList - HomeViewModelLog.StartingBatchCompression(_logger, foldersToCompress.Count) -- For Each folder In foldersToCompress -- If folder.FolderActionState = ActionState.Idle Then -- Await Task.Run(Async Function() -- HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) -- Dim ret = Await _compressableFolderService.CompressFolder(folder) -- Dim analysis = Await _compressableFolderService.AnalyseFolderAsync(folder) - -- If _settingsService.AppSettings.ShowNotifications Then -+ Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() - -- Application.GetService(Of TrayNotifierService).Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) -+ Try -+ For Each folder In foldersToCompress -+ If folder.FolderActionState = ActionState.Analysing OrElse folder.FolderActionState = ActionState.Working OrElse folder.FolderActionState = ActionState.Paused OrElse folder.FolderActionState = ActionState.Undoing Then Continue For - -- End If -+ Dim existingWatched = _watcher.WatchedFolders.FirstOrDefault(Function(watched) watched.Folder = folder.FolderName) -+ Dim previousWatcherTarget = If(existingWatched?.CompressionLevel, Core.WOFCompressionAlgorithm.NO_COMPRESSION) - -- Dim targetCompressionLevel = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) -- Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, ret, targetCompressionLevel:=targetCompressionLevel) -+ Await Task.Run(Async Function() -+ HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) -+ Dim runResult = Await _compressableFolderService.CompressFolder(folder) -+ Await _compressableFolderService.AnalyseFolderAsync(folder) - -- 'For Each poorext In folder.PoorlyCompressedFiles -- ' Debug.WriteLine($"{poorext.extension} : {poorext.totalFiles} with ratio of {poorext.cRatio}") -- 'Next -+ If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then -+ Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) -+ End If - -+ Dim watcherTarget = previousWatcherTarget -+ If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then -+ watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) -+ End If -+ -+ watcherTargetOverrides(folder) = watcherTarget -+ Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) - Return True - End Function) -- End If -- Next -- Compressing = False -- -- For Each folder In Folders.Where(Function(f) f.CompressionOptions.WatchFolderForChanges) -- Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed -- AddOrUpdateFolderWatcher(folder, wasCompressedInCurrentBatch) -- Next -+ Next - -- RemoveFolderCommand.NotifyCanExecuteChanged() -- Core.SharedMethods.RestoreSleep() -- Await _watcher.EnableBackgrounding() -+ For Each folder In Folders.Where(Function(item) item.CompressionOptions.WatchFolderForChanges) -+ Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed -+ Dim targetOverride = Core.WOFCompressionAlgorithm.NO_COMPRESSION -+ watcherTargetOverrides.TryGetValue(folder, targetOverride) -+ AddOrUpdateFolderWatcher(folder, wasCompressedInCurrentBatch, targetOverride) -+ Next -+ Finally -+ Compressing = False -+ RemoveFolderCommand.NotifyCanExecuteChanged() -+ Core.SharedMethods.RestoreSleep() -+ Await _watcher.EnableBackgrounding() -+ End Try - End Function - - -@@ -280,7 +305,7 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient - End Function - - -- Public Sub AddOrUpdateFolderWatcher(folder As CompressableFolder, wasCompressedInCurrentBatch As Boolean) -+ Public Sub AddOrUpdateFolderWatcher(folder As CompressableFolder, wasCompressedInCurrentBatch As Boolean, Optional targetOverride As Core.WOFCompressionAlgorithm = Core.WOFCompressionAlgorithm.NO_COMPRESSION) - HomeViewModelLog.AddingFolderToWatcher(_logger, folder.FolderName) - - Dim newWatched = New Watcher.WatchedFolder(folder.FolderName, folder.DisplayName) -@@ -292,7 +317,9 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient - newWatched.LastCheckedSize = folder.CompressedBytes - newWatched.LastSystemModifiedDate = DateTime.Now - Dim existingWatched = _watcher.WatchedFolders.FirstOrDefault(Function(w) w.Folder = folder.FolderName) -- If wasCompressedInCurrentBatch Then -+ If targetOverride <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then -+ newWatched.CompressionLevel = targetOverride -+ ElseIf wasCompressedInCurrentBatch Then - newWatched.CompressionLevel = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) - ElseIf existingWatched IsNot Nothing Then - newWatched.CompressionLevel = existingWatched.CompressionLevel diff --git a/.github/stop-patches/part03.patch b/.github/stop-patches/part03.patch deleted file mode 100644 index 49f47a38..00000000 --- a/.github/stop-patches/part03.patch +++ /dev/null @@ -1,183 +0,0 @@ -diff --git a/CompactGUI/i18n/i18n.resx b/CompactGUI/i18n/i18n.resx -index f410aaa..3725d7b 100644 ---- a/CompactGUI/i18n/i18n.resx -+++ b/CompactGUI/i18n/i18n.resx -@@ -578,4 +578,34 @@ Are you sure you want to exit? - - Mode - -+ -+ Stop compression? -+ -+ -+ Choose what CompactGUI should do with the work already completed for {0}. Save progress keeps the files and remembers these settings for later. Undo progress restores only files changed during this run. Leave as is keeps the files but does not save a resume session. -+ -+ -+ Save progress -+ -+ -+ Undo progress -+ -+ -+ Leave as is -+ -+ -+ Resume saved compression? -+ -+ -+ CompactGUI saved a {1} compression session for {0}. Resume the remaining files now? -+ -+ -+ Resume -+ -+ -+ Discard saved progress -+ -+ -+ Undoing compression -+ - -\ No newline at end of file -diff --git a/CompactGUI.Core/Compactor.cs b/CompactGUI.Core/Compactor.cs -index f927d37..b32e74e 100644 ---- a/CompactGUI.Core/Compactor.cs -+++ b/CompactGUI.Core/Compactor.cs -@@ -25,11 +25,14 @@ public sealed class Compactor : ICompressor, IDisposable - private long totalProcessedBytes = 0; - private readonly SemaphoreSlim pauseSemaphore = new SemaphoreSlim(1, 2); - private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); -+ private readonly ConcurrentDictionary processedFiles = new(StringComparer.OrdinalIgnoreCase); - - private ILogger _logger; - - private Analyser _analyser; - -+ public IReadOnlyDictionary ProcessedFiles => processedFiles; -+ - public Compactor(string folderPath, WOFCompressionAlgorithm compressionLevel, string[] excludedFileTypes, Analyser analyser, ILogger? logger = null) - { - workingDirectory = folderPath; -@@ -51,15 +54,16 @@ public sealed class Compactor : ICompressor, IDisposable - } - - -- public async Task RunAsync(List filesList, IProgress? progressMonitor = null, int maxParallelism = 1) -+ public async Task RunAsync(List? filesList, IProgress? progressMonitor = null, int maxParallelism = 1) - { - if(cancellationTokenSource.IsCancellationRequested) { return false; } - - CompactorLog.BuildingWorkingFilesList(_logger, workingDirectory); -- var workingFiles = await BuildWorkingFilesList().ConfigureAwait(false); -+ var workingFiles = await BuildWorkingFilesList(filesList).ConfigureAwait(false); - long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize); - - totalProcessedBytes = 0; -+ processedFiles.Clear(); - - var sw = Stopwatch.StartNew(); - -@@ -101,6 +105,10 @@ public sealed class Compactor : ICompressor, IDisposable - pauseSemaphore.Release(); - - var res = WOFCompressFile(file.FileName); -+ if (res == 0) -+ { -+ processedFiles.TryAdd(file.FileName, file.OriginalCompressionMode); -+ } - Interlocked.Add(ref totalProcessedBytes, file.UncompressedSize); - progressMonitor?.Report(new CompressionProgress((int)((double)totalProcessedBytes / totalFilesSize * 100.0), file.FileName)); - -@@ -122,11 +130,19 @@ public sealed class Compactor : ICompressor, IDisposable - } - } - -- public async Task> BuildWorkingFilesList() -+ public async Task> BuildWorkingFilesList(IReadOnlyCollection? filesList = null) - { -+ if (filesList is { Count: > 0 }) -+ { -+ return filesList -+ .Distinct(StringComparer.OrdinalIgnoreCase) -+ .Where(File.Exists) -+ .Select(file => new FileDetails(file, new FileInfo(file).Length, WOFCompressionAlgorithm.NO_COMPRESSION)) -+ .ToList(); -+ } -+ - uint clusterSize = SharedMethods.GetClusterSize(workingDirectory); - -- - var analysedFiles = await _analyser.GetAnalysedFilesAsync(cancellationTokenSource.Token) ?? []; - - return analysedFiles -@@ -135,14 +151,13 @@ public sealed class Compactor : ICompressor, IDisposable - && fl.UncompressedSize > clusterSize - && ((fl.FileInfo != null && !excludedFileExtensions.Contains(fl.FileInfo.Extension)) || excludedFileExtensions.Contains(fl.FileName)) - ) -- .Select(fl => new FileDetails(fl.FileName, fl.UncompressedSize)) -+ .Select(fl => new FileDetails(fl.FileName, fl.UncompressedSize, fl.CompressionMode)) - .ToList(); - } - - - - -- - public void Pause() - { - CompactorLog.CompressionPaused(_logger); -@@ -176,6 +191,6 @@ public sealed class Compactor : ICompressor, IDisposable - } - - -- public readonly record struct FileDetails(string FileName, long UncompressedSize); -+ public readonly record struct FileDetails(string FileName, long UncompressedSize, WOFCompressionAlgorithm OriginalCompressionMode); - - } -\ No newline at end of file -diff --git a/CompactGUI.Core/ICompressor.cs b/CompactGUI.Core/ICompressor.cs -index 04054c9..fc4d444 100644 ---- a/CompactGUI.Core/ICompressor.cs -+++ b/CompactGUI.Core/ICompressor.cs -@@ -2,7 +2,7 @@ - - public interface ICompressor : IDisposable - { -- Task RunAsync(List filesList, -+ Task RunAsync(List? filesList, - IProgress? progressMonitor = null, - int maxParallelism = 1); - -diff --git a/CompactGUI.Core/Uncompactor.cs b/CompactGUI.Core/Uncompactor.cs -index 17f9a52..2849445 100644 ---- a/CompactGUI.Core/Uncompactor.cs -+++ b/CompactGUI.Core/Uncompactor.cs -@@ -23,8 +23,9 @@ public sealed class Uncompactor : ICompressor, IDisposable - _logger = logger ?? NullLogger.Instance; - } - -- public async Task RunAsync(List filesList, IProgress? progressMonitor = null, int maxParallelism = 1) -+ public async Task RunAsync(List? filesList, IProgress? progressMonitor = null, int maxParallelism = 1) - { -+ filesList ??= []; - int totalFiles = filesList.Count; - if (maxParallelism <= 0) maxParallelism = Environment.ProcessorCount; - ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = maxParallelism }; -diff --git a/CompactGUI.Watcher/Watcher.vb b/CompactGUI.Watcher/Watcher.vb -index 6d35061..ac448f5 100644 ---- a/CompactGUI.Watcher/Watcher.vb -+++ b/CompactGUI.Watcher/Watcher.vb -@@ -1,4 +1,4 @@ --Imports System.Collections.ObjectModel -+Imports System.Collections.ObjectModel - Imports System.Collections.Specialized - Imports System.Runtime - Imports System.Text.Json -@@ -288,7 +288,7 @@ Partial Public Class Watcher : Inherits ObservableRecipient : Implements IRecipi - existingItem.LastCheckedSize = analyser.CompressedBytes - existingItem.LastUncompressedSize = analyser.UncompressedBytes - existingItem.LastSystemModifiedDate = DateTime.Now -- If isFreshlyCompressed AndAlso targetCompressionLevel <> WOFCompressionAlgorithm.NO_COMPRESSION Then -+ If targetCompressionLevel <> WOFCompressionAlgorithm.NO_COMPRESSION Then - existingItem.CompressionLevel = targetCompressionLevel - Else - existingItem.CompressionLevel = WOFHelper.GetDominantCompressionMode(analysedFiles) diff --git a/.github/stop-payload/part00 b/.github/stop-payload/part00 deleted file mode 100644 index 72e04282..00000000 --- a/.github/stop-payload/part00 +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/.github/workflows/apply-compression-stop.yml b/.github/workflows/apply-compression-stop.yml deleted file mode 100644 index 2e99d7ba..00000000 --- a/.github/workflows/apply-compression-stop.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Apply compression stop workflow - -on: - pull_request: - branches: - - master - -permissions: - contents: write - -jobs: - apply: - if: github.head_ref == 'agent/compression-stop-options' && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - name: Check out feature branch - uses: actions/checkout@v7 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Apply feature patch and remove staging files - shell: bash - env: - HEAD_REF: ${{ github.head_ref }} - run: | - set -euo pipefail - - cat .github/stop-patches/part*.patch > "$RUNNER_TEMP/compression-stop.patch" - git apply --check "$RUNNER_TEMP/compression-stop.patch" - git apply --whitespace=nowarn "$RUNNER_TEMP/compression-stop.patch" - - rm -rf .github/stop-patches .github/stop-payload - rm -f .github/workflows/source-export.yml - rm -f .github/workflows/apply-compression-stop.yml - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Add safe manual compression stop choices" - git push origin "HEAD:$HEAD_REF" diff --git a/.github/workflows/source-export.yml b/.github/workflows/source-export.yml deleted file mode 100644 index d47318d7..00000000 --- a/.github/workflows/source-export.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Export source for compression stop work - -on: - pull_request: - branches: - - master - -permissions: - contents: read - -jobs: - export: - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v7 - - name: Upload source - uses: actions/upload-artifact@v7 - with: - name: CompactGUI-current-source - path: | - CompactGUI*/** - !CompactGUI*/bin/** - !CompactGUI*/obj/** - *.sln - *.md - if-no-files-found: error - retention-days: 1 diff --git a/CompactGUI.Core/Compactor.cs b/CompactGUI.Core/Compactor.cs index f927d378..b32e74ef 100644 --- a/CompactGUI.Core/Compactor.cs +++ b/CompactGUI.Core/Compactor.cs @@ -25,11 +25,14 @@ public sealed class Compactor : ICompressor, IDisposable private long totalProcessedBytes = 0; private readonly SemaphoreSlim pauseSemaphore = new SemaphoreSlim(1, 2); private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); + private readonly ConcurrentDictionary processedFiles = new(StringComparer.OrdinalIgnoreCase); private ILogger _logger; private Analyser _analyser; + public IReadOnlyDictionary ProcessedFiles => processedFiles; + public Compactor(string folderPath, WOFCompressionAlgorithm compressionLevel, string[] excludedFileTypes, Analyser analyser, ILogger? logger = null) { workingDirectory = folderPath; @@ -51,15 +54,16 @@ private void InitializeCompressionInfoPointer() } - public async Task RunAsync(List filesList, IProgress? progressMonitor = null, int maxParallelism = 1) + public async Task RunAsync(List? filesList, IProgress? progressMonitor = null, int maxParallelism = 1) { if(cancellationTokenSource.IsCancellationRequested) { return false; } CompactorLog.BuildingWorkingFilesList(_logger, workingDirectory); - var workingFiles = await BuildWorkingFilesList().ConfigureAwait(false); + var workingFiles = await BuildWorkingFilesList(filesList).ConfigureAwait(false); long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize); totalProcessedBytes = 0; + processedFiles.Clear(); var sw = Stopwatch.StartNew(); @@ -101,6 +105,10 @@ private async Task PauseAndProcessFile(FileDetails file, long totalFilesSize, Ca pauseSemaphore.Release(); var res = WOFCompressFile(file.FileName); + if (res == 0) + { + processedFiles.TryAdd(file.FileName, file.OriginalCompressionMode); + } Interlocked.Add(ref totalProcessedBytes, file.UncompressedSize); progressMonitor?.Report(new CompressionProgress((int)((double)totalProcessedBytes / totalFilesSize * 100.0), file.FileName)); @@ -122,11 +130,19 @@ private async Task PauseAndProcessFile(FileDetails file, long totalFilesSize, Ca } } - public async Task> BuildWorkingFilesList() + public async Task> BuildWorkingFilesList(IReadOnlyCollection? filesList = null) { + if (filesList is { Count: > 0 }) + { + return filesList + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(File.Exists) + .Select(file => new FileDetails(file, new FileInfo(file).Length, WOFCompressionAlgorithm.NO_COMPRESSION)) + .ToList(); + } + uint clusterSize = SharedMethods.GetClusterSize(workingDirectory); - var analysedFiles = await _analyser.GetAnalysedFilesAsync(cancellationTokenSource.Token) ?? []; return analysedFiles @@ -135,14 +151,13 @@ public async Task> BuildWorkingFilesList() && fl.UncompressedSize > clusterSize && ((fl.FileInfo != null && !excludedFileExtensions.Contains(fl.FileInfo.Extension)) || excludedFileExtensions.Contains(fl.FileName)) ) - .Select(fl => new FileDetails(fl.FileName, fl.UncompressedSize)) + .Select(fl => new FileDetails(fl.FileName, fl.UncompressedSize, fl.CompressionMode)) .ToList(); } - public void Pause() { CompactorLog.CompressionPaused(_logger); @@ -176,6 +191,6 @@ public void Dispose() } - public readonly record struct FileDetails(string FileName, long UncompressedSize); + public readonly record struct FileDetails(string FileName, long UncompressedSize, WOFCompressionAlgorithm OriginalCompressionMode); } \ No newline at end of file diff --git a/CompactGUI.Core/ICompressor.cs b/CompactGUI.Core/ICompressor.cs index 04054c96..fc4d4440 100644 --- a/CompactGUI.Core/ICompressor.cs +++ b/CompactGUI.Core/ICompressor.cs @@ -2,7 +2,7 @@ public interface ICompressor : IDisposable { - Task RunAsync(List filesList, + Task RunAsync(List? filesList, IProgress? progressMonitor = null, int maxParallelism = 1); diff --git a/CompactGUI.Core/Uncompactor.cs b/CompactGUI.Core/Uncompactor.cs index 17f9a52a..28494457 100644 --- a/CompactGUI.Core/Uncompactor.cs +++ b/CompactGUI.Core/Uncompactor.cs @@ -23,8 +23,9 @@ public Uncompactor(ILogger? logger = null) _logger = logger ?? NullLogger.Instance; } - public async Task RunAsync(List filesList, IProgress? progressMonitor = null, int maxParallelism = 1) + public async Task RunAsync(List? filesList, IProgress? progressMonitor = null, int maxParallelism = 1) { + filesList ??= []; int totalFiles = filesList.Count; if (maxParallelism <= 0) maxParallelism = Environment.ProcessorCount; ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = maxParallelism }; diff --git a/CompactGUI.Watcher/Watcher.vb b/CompactGUI.Watcher/Watcher.vb index 6d350614..ac448f56 100644 --- a/CompactGUI.Watcher/Watcher.vb +++ b/CompactGUI.Watcher/Watcher.vb @@ -1,4 +1,4 @@ -Imports System.Collections.ObjectModel +Imports System.Collections.ObjectModel Imports System.Collections.Specialized Imports System.Runtime Imports System.Text.Json @@ -288,7 +288,7 @@ Partial Public Class Watcher : Inherits ObservableRecipient : Implements IRecipi existingItem.LastCheckedSize = analyser.CompressedBytes existingItem.LastUncompressedSize = analyser.UncompressedBytes existingItem.LastSystemModifiedDate = DateTime.Now - If isFreshlyCompressed AndAlso targetCompressionLevel <> WOFCompressionAlgorithm.NO_COMPRESSION Then + If targetCompressionLevel <> WOFCompressionAlgorithm.NO_COMPRESSION Then existingItem.CompressionLevel = targetCompressionLevel Else existingItem.CompressionLevel = WOFHelper.GetDominantCompressionMode(analysedFiles) diff --git a/CompactGUI/Application.xaml.vb b/CompactGUI/Application.xaml.vb index 4c17cc66..685951d9 100644 --- a/CompactGUI/Application.xaml.vb +++ b/CompactGUI/Application.xaml.vb @@ -74,6 +74,7 @@ Partial Public Class Application services.AddSingleton(Of INavigationService, NavigationService)() services.AddSingleton(Of CustomSnackBarService)() services.AddSingleton(Of IWindowService, WindowService)() + services.AddSingleton(Of CompressionResumeService)() services.AddSingleton(Of IUpdaterService, UpdaterService)() services.AddSingleton(Of IWikiService, WikiService)() diff --git a/CompactGUI/Components/Converters/IValueConverters.vb b/CompactGUI/Components/Converters/IValueConverters.vb index 01acbafc..135b6307 100644 --- a/CompactGUI/Components/Converters/IValueConverters.vb +++ b/CompactGUI/Components/Converters/IValueConverters.vb @@ -281,7 +281,7 @@ Public Class FolderStatusToColorConverter : Implements IValueConverter Select Case status Case ActionState.Idle Return New SolidColorBrush(ColorConverter.ConvertFromString("#92e7f1")) - Case ActionState.Analysing, ActionState.Working, ActionState.Paused + Case ActionState.Analysing, ActionState.Working, ActionState.Paused, ActionState.Undoing Return New SolidColorBrush(ColorConverter.ConvertFromString("#F1CE92")) Case ActionState.Results Return New SolidColorBrush(ColorConverter.ConvertFromString("#92F1AB")) @@ -305,6 +305,8 @@ Public Class FolderStatusToStringConverter : Implements IValueConverter Return LanguageHelper.GetString("Status_Analysing") Case ActionState.Working, ActionState.Paused Return LanguageHelper.GetString("Status_Working") + Case ActionState.Undoing + Return LanguageHelper.GetString("Status_Undoing") Case ActionState.Results Return LanguageHelper.GetString("Status_Compressed") Case Else @@ -406,7 +408,7 @@ Public Class FolderActionStateWorkingToVisibilityConverter Public Function Convert(value As Object, targetType As Type, parameter As Object, culture As CultureInfo) As Object Implements IValueConverter.Convert Dim status = CType(value, ActionState) Select Case status - Case ActionState.Working + Case ActionState.Working, ActionState.Undoing Return Visibility.Visible Case Else Return Visibility.Collapsed diff --git a/CompactGUI/Components/FolderActionStateTemplateSelector.vb b/CompactGUI/Components/FolderActionStateTemplateSelector.vb index b0283499..2395c54f 100644 --- a/CompactGUI/Components/FolderActionStateTemplateSelector.vb +++ b/CompactGUI/Components/FolderActionStateTemplateSelector.vb @@ -15,7 +15,7 @@ Return IdleTemplate Case ActionState.Analysing Return AnalysingTemplate - Case ActionState.Working, ActionState.Paused + Case ActionState.Working, ActionState.Paused, ActionState.Undoing Return CompressingTemplate Case ActionState.Results Return ResultsTemplate @@ -42,7 +42,7 @@ Public Class HomeViewStateTemplateSelector Return IdleTemplate Case ActionState.Analysing Return AnalysingTemplate - Case ActionState.Working, ActionState.Paused + Case ActionState.Working, ActionState.Paused, ActionState.Undoing Return CompressingTemplate Case ActionState.Results Return ResultsTemplate diff --git a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb index 815050f0..2b6a841a 100644 --- a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb +++ b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb @@ -106,5 +106,6 @@ Public Enum ActionState Working Results Paused + Undoing Waiting End Enum \ No newline at end of file diff --git a/CompactGUI/Models/CompressionStopModels.vb b/CompactGUI/Models/CompressionStopModels.vb new file mode 100644 index 00000000..441e5849 --- /dev/null +++ b/CompactGUI/Models/CompressionStopModels.vb @@ -0,0 +1,26 @@ +Public Enum CompressionStopChoice + SaveProgress + UndoProgress + LeaveAsIs +End Enum + +Public Enum CompressionResumeChoice + Resume + DiscardSavedProgress + Cancel +End Enum + + +Public NotInheritable Class CompressionRunResult + Public Property Completed As Boolean + Public Property StopChoice As CompressionStopChoice? +End Class + +Public NotInheritable Class SavedCompressionSession + Public Property FolderPath As String = String.Empty + Public Property SelectedCompressionMode As Core.CompressionMode + Public Property SkipPoorlyCompressedFileTypes As Boolean + Public Property SkipUserSubmittedFiletypes As Boolean + Public Property WatchFolderForChanges As Boolean + Public Property SavedAt As DateTime = DateTime.Now +End Class diff --git a/CompactGUI/Services/CompressableFolderService.vb b/CompactGUI/Services/CompressableFolderService.vb index 594c344c..5d487c3b 100644 --- a/CompactGUI/Services/CompressableFolderService.vb +++ b/CompactGUI/Services/CompressableFolderService.vb @@ -1,3 +1,4 @@ +Imports System.Collections.Concurrent Imports System.Collections.ObjectModel Imports System.Threading @@ -15,56 +16,123 @@ Public Class CompressableFolderService Private Shared ReadOnly UncompactorLogger As ILogger = Application.GetService(Of ILogger(Of Uncompactor))() Private Shared ReadOnly AnalyserLogger As ILogger = Application.GetService(Of ILogger(Of Analyser))() + Private ReadOnly _resumeService As CompressionResumeService Private folderTokens As New Dictionary(Of CompressableFolder, CancellationTokenSource) + Private ReadOnly stopChoices As New ConcurrentDictionary(Of CompressableFolder, CompressionStopChoice) + + Public Sub New(resumeService As CompressionResumeService) + _resumeService = resumeService + End Sub - Public Async Function CompressFolder(folder As CompressableFolder) As Task(Of Boolean) + Public Async Function CompressFolder(folder As CompressableFolder) As Task(Of CompressionRunResult) folder.Compressor = New Compactor(folder.FolderName, WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode), GetSkipList(folder), folder.Analyser, CompactorLogger) Return Await RunCompressionAsync(folder, folder.Compressor, Nothing, True) - - End Function Public Async Function UncompressFolder(folder As CompressableFolder) As Task(Of Boolean) + _resumeService.RemoveSession(folder.FolderName) folder.Compressor = New Uncompactor(UncompactorLogger) Dim compressedFilesList = folder.AnalysisResults.Where(Function(rs) rs.CompressedSize < rs.UncompressedSize).Select(Of String)(Function(f) f.FileName).ToList - Return Await RunCompressionAsync(folder, folder.Compressor, compressedFilesList, isCompressing:=False) + Dim result = Await RunCompressionAsync(folder, folder.Compressor, compressedFilesList, isCompressing:=False) + Await AnalyseFolderAsync(folder) + Return result.Completed + End Function - End Function + Public Sub RequestCompressionStop(folder As CompressableFolder, choice As CompressionStopChoice) + If folder Is Nothing OrElse Not TypeOf folder.Compressor Is Compactor Then Return + If folder.FolderActionState <> ActionState.Working AndAlso folder.FolderActionState <> ActionState.Paused Then Return + + If choice = CompressionStopChoice.SaveProgress Then + _resumeService.SaveSession(folder) + Else + _resumeService.RemoveSession(folder.FolderName) + End If + + stopChoices(folder) = choice + + Try + folder.Compressor.Cancel() + Catch ex As ObjectDisposedException + 'The compression completed while the stop dialog was open. + End Try + End Sub - Private Async Function RunCompressionAsync(folder As CompressableFolder, compressor As ICompressor, filesList As List(Of String), isCompressing As Boolean) As Task(Of Boolean) + Private Async Function RunCompressionAsync(folder As CompressableFolder, compressor As ICompressor, filesList As List(Of String), isCompressing As Boolean) As Task(Of CompressionRunResult) folder.FolderActionState = ActionState.Working CancelEstimation(folder) Dim cts = New CancellationTokenSource() folderTokens(folder) = cts Dim progress As IProgress(Of CompressionProgress) = New Progress(Of CompressionProgress)(Sub(x) folder.CompressionProgress = x) + Dim runResult As New CompressionRunResult() progress.Report(New CompressionProgress(0, "")) - Dim res = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) + Try + runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) + + If isCompressing Then + Dim stopChoice As CompressionStopChoice + If Not runResult.Completed AndAlso stopChoices.TryRemove(folder, stopChoice) Then + runResult.StopChoice = stopChoice + + If stopChoice = CompressionStopChoice.UndoProgress AndAlso TypeOf compressor Is Compactor Then + folder.FolderActionState = ActionState.Undoing + Await RestoreProcessedFilesAsync(folder, DirectCast(compressor, Compactor), progress) + End If + ElseIf runResult.Completed Then + stopChoices.TryRemove(folder, stopChoice) + _resumeService.RemoveSession(folder.FolderName) + End If + + folder.FolderActionState = ActionState.Results + folder.IsFreshlyCompressed = runResult.Completed + Else + folder.FolderActionState = ActionState.Idle + folder.IsFreshlyCompressed = False + End If - If isCompressing Then - folder.FolderActionState = ActionState.Results - folder.IsFreshlyCompressed = res - Else - folder.FolderActionState = ActionState.Idle - folder.IsFreshlyCompressed = False - Await AnalyseFolderAsync(folder) - End If - compressor.Dispose() + Return runResult + Finally + compressor.Dispose() + + Dim ownedToken As CancellationTokenSource = Nothing + If folderTokens.TryGetValue(folder, ownedToken) AndAlso Object.ReferenceEquals(ownedToken, cts) Then + folderTokens.Remove(folder) + ownedToken.Dispose() + Else + cts.Dispose() + End If + End Try + End Function - folderTokens(folder).Dispose() - folderTokens.Remove(folder) + Private Async Function RestoreProcessedFilesAsync(folder As CompressableFolder, compactor As Compactor, progress As IProgress(Of CompressionProgress)) As Task + Dim processedFiles = compactor.ProcessedFiles.ToList() + If processedFiles.Count = 0 Then Return - Return res - End Function + Dim threadCount = GetThreadCount(folder) + For Each modeGroup In processedFiles.GroupBy(Function(item) item.Value) + Dim files = modeGroup.Select(Function(item) item.Key).ToList() + + Select Case modeGroup.Key + Case WOFCompressionAlgorithm.XPRESS4K, WOFCompressionAlgorithm.XPRESS8K, WOFCompressionAlgorithm.XPRESS16K, WOFCompressionAlgorithm.LZX + Using restorer As New Compactor(folder.FolderName, modeGroup.Key, Array.Empty(Of String), folder.Analyser, CompactorLogger) + Await restorer.RunAsync(files, progress, threadCount) + End Using + Case Else + Using uncompactor As New Uncompactor(UncompactorLogger) + Await uncompactor.RunAsync(files, progress, threadCount) + End Using + End Select + Next + End Function Public Async Function AnalyseFolderAsync(folder As CompressableFolder) As Task(Of Integer) diff --git a/CompactGUI/Services/CompressionResumeService.vb b/CompactGUI/Services/CompressionResumeService.vb new file mode 100644 index 00000000..f2ba4aae --- /dev/null +++ b/CompactGUI/Services/CompressionResumeService.vb @@ -0,0 +1,113 @@ +Imports System.Text.Json + +Imports CompactGUI.Core.Settings + +Imports Microsoft.Extensions.Logging + +Public NotInheritable Class CompressionResumeService + + Private ReadOnly _sessionsFile As IO.FileInfo + Private ReadOnly _logger As ILogger(Of CompressionResumeService) + Private ReadOnly _syncRoot As New Object + Private ReadOnly _jsonOptions As New JsonSerializerOptions With {.WriteIndented = True} + Private _sessions As Dictionary(Of String, SavedCompressionSession) + + Public Sub New(settingsService As ISettingsService, logger As ILogger(Of CompressionResumeService)) + _logger = logger + _sessionsFile = New IO.FileInfo(IO.Path.Combine(settingsService.DataFolder.FullName, "compression-resume.json")) + _sessions = LoadSessions() + End Sub + + Public Function TryGetSession(folderPath As String, ByRef session As SavedCompressionSession) As Boolean + Dim key = NormalizePath(folderPath) + + SyncLock _syncRoot + Dim stored As SavedCompressionSession = Nothing + If Not _sessions.TryGetValue(key, stored) Then Return False + + session = CloneSession(stored) + Return True + End SyncLock + End Function + + Public Sub SaveSession(folder As CompressableFolder) + Dim session = New SavedCompressionSession With { + .FolderPath = NormalizePath(folder.FolderName), + .SelectedCompressionMode = folder.CompressionOptions.SelectedCompressionMode, + .SkipPoorlyCompressedFileTypes = folder.CompressionOptions.SkipPoorlyCompressedFileTypes, + .SkipUserSubmittedFiletypes = folder.CompressionOptions.SkipUserSubmittedFiletypes, + .WatchFolderForChanges = folder.CompressionOptions.WatchFolderForChanges, + .SavedAt = DateTime.Now + } + + SyncLock _syncRoot + _sessions(session.FolderPath) = session + WriteSessions() + End SyncLock + End Sub + + Public Sub RemoveSession(folderPath As String) + Dim key = NormalizePath(folderPath) + + SyncLock _syncRoot + If _sessions.Remove(key) Then WriteSessions() + End SyncLock + End Sub + + Private Function LoadSessions() As Dictionary(Of String, SavedCompressionSession) + Dim loaded = New Dictionary(Of String, SavedCompressionSession)(StringComparer.OrdinalIgnoreCase) + + Try + If Not _sessionsFile.Directory.Exists Then _sessionsFile.Directory.Create() + If Not _sessionsFile.Exists Then Return loaded + + Dim json = IO.File.ReadAllText(_sessionsFile.FullName) + If String.IsNullOrWhiteSpace(json) Then Return loaded + + Dim entries = JsonSerializer.Deserialize(Of List(Of SavedCompressionSession))(json, _jsonOptions) + If entries Is Nothing Then Return loaded + + For Each entry In entries + If String.IsNullOrWhiteSpace(entry.FolderPath) Then Continue For + entry.FolderPath = NormalizePath(entry.FolderPath) + loaded(entry.FolderPath) = entry + Next + Catch ex As Exception + _logger.LogWarning(ex, "Could not load saved compression sessions from {SessionFile}", _sessionsFile.FullName) + End Try + + Return loaded + End Function + + Private Sub WriteSessions() + Try + If Not _sessionsFile.Directory.Exists Then _sessionsFile.Directory.Create() + Dim snapshot = _sessions.Values.OrderBy(Function(entry) entry.FolderPath, StringComparer.OrdinalIgnoreCase).ToList() + IO.File.WriteAllText(_sessionsFile.FullName, JsonSerializer.Serialize(snapshot, _jsonOptions)) + Catch ex As Exception + _logger.LogWarning(ex, "Could not save compression sessions to {SessionFile}", _sessionsFile.FullName) + End Try + End Sub + + Private Shared Function CloneSession(source As SavedCompressionSession) As SavedCompressionSession + Return New SavedCompressionSession With { + .FolderPath = source.FolderPath, + .SelectedCompressionMode = source.SelectedCompressionMode, + .SkipPoorlyCompressedFileTypes = source.SkipPoorlyCompressedFileTypes, + .SkipUserSubmittedFiletypes = source.SkipUserSubmittedFiletypes, + .WatchFolderForChanges = source.WatchFolderForChanges, + .SavedAt = source.SavedAt + } + End Function + + Private Shared Function NormalizePath(folderPath As String) As String + If String.IsNullOrWhiteSpace(folderPath) Then Return String.Empty + + Try + Return IO.Path.GetFullPath(folderPath).TrimEnd(IO.Path.DirectorySeparatorChar, IO.Path.AltDirectorySeparatorChar) + Catch ex As Exception + Return folderPath.TrimEnd(IO.Path.DirectorySeparatorChar, IO.Path.AltDirectorySeparatorChar) + End Try + End Function + +End Class diff --git a/CompactGUI/Services/WindowService.vb b/CompactGUI/Services/WindowService.vb index 764aefe4..eb6ae39d 100644 --- a/CompactGUI/Services/WindowService.vb +++ b/CompactGUI/Services/WindowService.vb @@ -3,6 +3,8 @@ Sub MinimizeMainWindow() Sub HideMainWindow() Function ShowMessageBox(title As String, content As String) As Task(Of Boolean) + Function ShowCompressionStopDialog(folderName As String) As Task(Of CompressionStopChoice) + Function ShowResumeCompressionDialog(session As SavedCompressionSession) As Task(Of CompressionResumeChoice) End Interface @@ -39,4 +41,46 @@ Public Class WindowService Dim result = Await msgBox.ShowDialogAsync() Return result = Wpf.Ui.Controls.MessageBoxResult.Primary End Function + + Public Async Function ShowCompressionStopDialog(folderName As String) As Task(Of CompressionStopChoice) Implements IWindowService.ShowCompressionStopDialog + Dim msgBox = New Wpf.Ui.Controls.MessageBox With { + .Title = LanguageHelper.GetString("CompressionStop_Title"), + .Content = String.Format(LanguageHelper.GetString("CompressionStop_Message"), folderName), + .IsPrimaryButtonEnabled = True, + .IsSecondaryButtonEnabled = True, + .PrimaryButtonText = LanguageHelper.GetString("CompressionStop_SaveProgress"), + .SecondaryButtonText = LanguageHelper.GetString("CompressionStop_UndoProgress"), + .CloseButtonText = LanguageHelper.GetString("CompressionStop_LeaveAsIs") + } + + Select Case Await msgBox.ShowDialogAsync() + Case Wpf.Ui.Controls.MessageBoxResult.Primary + Return CompressionStopChoice.SaveProgress + Case Wpf.Ui.Controls.MessageBoxResult.Secondary + Return CompressionStopChoice.UndoProgress + Case Else + Return CompressionStopChoice.LeaveAsIs + End Select + End Function + + Public Async Function ShowResumeCompressionDialog(session As SavedCompressionSession) As Task(Of CompressionResumeChoice) Implements IWindowService.ShowResumeCompressionDialog + Dim msgBox = New Wpf.Ui.Controls.MessageBox With { + .Title = LanguageHelper.GetString("CompressionResume_Title"), + .Content = String.Format(LanguageHelper.GetString("CompressionResume_Message"), session.FolderPath, session.SelectedCompressionMode), + .IsPrimaryButtonEnabled = True, + .IsSecondaryButtonEnabled = True, + .PrimaryButtonText = LanguageHelper.GetString("CompressionResume_Resume"), + .SecondaryButtonText = LanguageHelper.GetString("CompressionResume_Discard"), + .CloseButtonText = LanguageHelper.GetString("UniCancel") + } + + Select Case Await msgBox.ShowDialogAsync() + Case Wpf.Ui.Controls.MessageBoxResult.Primary + Return CompressionResumeChoice.Resume + Case Wpf.Ui.Controls.MessageBoxResult.Secondary + Return CompressionResumeChoice.DiscardSavedProgress + Case Else + Return CompressionResumeChoice.Cancel + End Select + End Function End Class diff --git a/CompactGUI/ViewModels/FolderViewModel.vb b/CompactGUI/ViewModels/FolderViewModel.vb index b4459eb8..0fa56325 100644 --- a/CompactGUI/ViewModels/FolderViewModel.vb +++ b/CompactGUI/ViewModels/FolderViewModel.vb @@ -131,6 +131,8 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem OnPropertyChanged(NameOf(CompressionDisplayLevel)) OnPropertyChanged(NameOf(DisplayedFolderAfterSize)) OnPropertyChanged(NameOf(TotalFiles)) + PauseCommand.NotifyCanExecuteChanged() + CancelCommand.NotifyCanExecuteChanged() ElseIf e.PropertyName = NameOf(Folder.CompressionProgress) Then CompressionProgress = Folder.CompressionProgress.ProgressPercent @@ -165,7 +167,7 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem _snackbarService.ShowAppliedToAllFolders() End Sub - + Private Sub Pause() If Folder.FolderActionState = ActionState.Working Then @@ -178,10 +180,31 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem End If End Sub - - Private Sub Cancel() - Folder.Compressor?.Cancel() - End Sub + Private Function CanControlCompression() As Boolean + Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) + End Function + + + Private Async Function Cancel() As Task + If Not TypeOf Folder.Compressor Is Core.Compactor Then + Folder.Compressor?.Cancel() + Return + End If + + If Folder.FolderActionState = ActionState.Working Then + Try + Folder.Compressor?.Pause() + Folder.FolderActionState = ActionState.Paused + Catch ex As OperationCanceledException + Return + Catch ex As ObjectDisposedException + Return + End Try + End If + + Dim choice = Await Application.GetService(Of IWindowService)().ShowCompressionStopDialog(Folder.DisplayName) + _compressableFolderService.RequestCompressionStop(Folder, choice) + End Function Private Async Function SubmitToWiki() As Task diff --git a/CompactGUI/ViewModels/HomeViewModel.vb b/CompactGUI/ViewModels/HomeViewModel.vb index edc4a567..3e84f695 100644 --- a/CompactGUI/ViewModels/HomeViewModel.vb +++ b/CompactGUI/ViewModels/HomeViewModel.vb @@ -110,61 +110,75 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient HomeViewModelLog.AddingFolders(_logger, folderPaths) - Dim invalidFolders = GetInvalidFolders(folderPaths.ToArray) + Dim invalidFolders = GetInvalidFolders(folderPaths.ToArray()) Dim validFolders = folderPaths.Except(invalidFolders.InvalidFolders) + Dim foldersToResume As New List(Of CompressableFolder)() + Dim resumeService = Application.GetService(Of CompressionResumeService)() + Dim windowService = Application.GetService(Of IWindowService)() If invalidFolders.InvalidFolders.Count > 0 Then - 'TODO: Move this logger check to the snackbarService class? - HomeViewModelLog.InvalidFolders(_logger, invalidFolders.InvalidFolders, invalidFolders.InvalidMessages.Select(Function(x) GetFolderVerificationMessage(x))) + HomeViewModelLog.InvalidFolders(_logger, invalidFolders.InvalidFolders, invalidFolders.InvalidMessages.Select(Function(result) GetFolderVerificationMessage(result))) _snackbarService.ShowInvalidFoldersMessage(invalidFolders.InvalidFolders, invalidFolders.InvalidMessages) End If For Each folderName In validFolders + If Folders.Any(Function(folder) String.Equals(folder.FolderName, folderName, StringComparison.OrdinalIgnoreCase)) Then Continue For + + Dim savedSession As SavedCompressionSession = Nothing + Dim resumeChoice As CompressionResumeChoice? = Nothing + + If resumeService.TryGetSession(folderName, savedSession) Then + resumeChoice = Await windowService.ShowResumeCompressionDialog(savedSession) + If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Cancel Then Continue For + If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.DiscardSavedProgress Then + resumeService.RemoveSession(folderName) + savedSession = Nothing + End If + End If Dim newFolder As CompressableFolder = Await CompressableFolderFactory.CreateCompressableFolder(folderName) + If newFolder Is Nothing Then Continue For newFolder.CompressionOptions.WatchFolderForChanges = _settingsService.AppSettings.WatchFolderForChanges newFolder.CompressionOptions.SelectedCompressionMode = _settingsService.AppSettings.SelectedCompressionMode newFolder.CompressionOptions.SkipPoorlyCompressedFileTypes = _settingsService.AppSettings.SkipNonCompressable newFolder.CompressionOptions.SkipUserSubmittedFiletypes = _settingsService.AppSettings.SkipUserNonCompressable - If Not Folders.Any(Function(f) f.FolderName = newFolder.FolderName) Then - Folders.Add(newFolder) - Dim vm As New FolderViewModel(newFolder, _watcher, _snackbarService, _compressableFolderService) - _folderViewModels.Add(newFolder, vm) - SelectedFolder = newFolder + If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Resume AndAlso savedSession IsNot Nothing Then + newFolder.CompressionOptions.SelectedCompressionMode = savedSession.SelectedCompressionMode + newFolder.CompressionOptions.SkipPoorlyCompressedFileTypes = savedSession.SkipPoorlyCompressedFileTypes + newFolder.CompressionOptions.SkipUserSubmittedFiletypes = savedSession.SkipUserSubmittedFiletypes + newFolder.CompressionOptions.WatchFolderForChanges = savedSession.WatchFolderForChanges End If - Dim res = Await _compressableFolderService.AnalyseFolderAsync(newFolder) - If TypeOf (newFolder) Is SteamFolder Then - Await CType(newFolder, SteamFolder).GetWikiResults() - Else - If _settingsService.AppSettings.EstimateCompressionForNonSteamFolders Then - HomeViewModelLog.GettingEstimatedCompression(_logger, newFolder.FolderName, newFolder.UncompressedBytes) - Await _compressableFolderService.GetEstimatedCompression(newFolder) - End If + Folders.Add(newFolder) + Dim viewModel As New FolderViewModel(newFolder, _watcher, _snackbarService, _compressableFolderService) + _folderViewModels.Add(newFolder, viewModel) + SelectedFolder = newFolder + Await _compressableFolderService.AnalyseFolderAsync(newFolder) + If TypeOf newFolder Is SteamFolder Then + Await CType(newFolder, SteamFolder).GetWikiResults() + ElseIf _settingsService.AppSettings.EstimateCompressionForNonSteamFolders Then + HomeViewModelLog.GettingEstimatedCompression(_logger, newFolder.FolderName, newFolder.UncompressedBytes) + Await _compressableFolderService.GetEstimatedCompression(newFolder) End If - If _watcher.WatchedFolders.Any(Function(w) w.Folder = newFolder.FolderName) Then - Dim watchedFolder = _watcher.WatchedFolders.First(Function(w) w.Folder = newFolder.FolderName) + If _watcher.WatchedFolders.Any(Function(watched) watched.Folder = newFolder.FolderName) Then + Dim watchedFolder = _watcher.WatchedFolders.First(Function(watched) watched.Folder = newFolder.FolderName) newFolder.CompressionOptions.WatchFolderForChanges = True - If watchedFolder.CompressionLevel <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then + If (Not resumeChoice.HasValue OrElse resumeChoice.Value <> CompressionResumeChoice.Resume) AndAlso watchedFolder.CompressionLevel <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then newFolder.CompressionOptions.SelectedCompressionMode = Core.WOFHelper.CompressionModeFromWOFMode(watchedFolder.CompressionLevel) End If - End If - - + If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Resume Then foldersToResume.Add(newFolder) Next - + If foldersToResume.Count > 0 Then Await CompressFoldersAsync(foldersToResume) End Function - - Public Sub RemoveFolder(folder As CompressableFolder) If Not CanRemoveFolder() Then @@ -204,7 +218,7 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Dim retState As ActionState - If Compressing OrElse Folders.Any(Function(f) f.FolderActionState = ActionState.Working OrElse f.FolderActionState = ActionState.Paused) Then + If Compressing OrElse Folders.Any(Function(f) f.FolderActionState = ActionState.Working OrElse f.FolderActionState = ActionState.Paused OrElse f.FolderActionState = ActionState.Undoing) Then retState = ActionState.Working ElseIf Folders.Any(Function(f) f.FolderActionState = ActionState.Analysing) Then retState = ActionState.Analysing @@ -230,48 +244,59 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Private Async Function CompressAll() As Task + Dim foldersToCompress = Folders.Where(Function(folder) folder.FolderActionState = ActionState.Idle).ToList() + Await CompressFoldersAsync(foldersToCompress) + End Function - Await _watcher.DisableBackgrounding() + Private Async Function CompressFoldersAsync(foldersToCompress As IReadOnlyCollection(Of CompressableFolder)) As Task + If foldersToCompress Is Nothing OrElse foldersToCompress.Count = 0 Then Return + Await _watcher.DisableBackgrounding() Compressing = True Core.SharedMethods.PreventSleep() - Dim tasks As New List(Of Task)() - Dim foldersToCompress = Folders.Where(Function(f) f.FolderActionState = ActionState.Idle).ToList HomeViewModelLog.StartingBatchCompression(_logger, foldersToCompress.Count) - For Each folder In foldersToCompress - If folder.FolderActionState = ActionState.Idle Then - Await Task.Run(Async Function() - HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) - Dim ret = Await _compressableFolderService.CompressFolder(folder) - Dim analysis = Await _compressableFolderService.AnalyseFolderAsync(folder) - If _settingsService.AppSettings.ShowNotifications Then + Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() - Application.GetService(Of TrayNotifierService).Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) + Try + For Each folder In foldersToCompress + If folder.FolderActionState = ActionState.Analysing OrElse folder.FolderActionState = ActionState.Working OrElse folder.FolderActionState = ActionState.Paused OrElse folder.FolderActionState = ActionState.Undoing Then Continue For - End If + Dim existingWatched = _watcher.WatchedFolders.FirstOrDefault(Function(watched) watched.Folder = folder.FolderName) + Dim previousWatcherTarget = If(existingWatched?.CompressionLevel, Core.WOFCompressionAlgorithm.NO_COMPRESSION) - Dim targetCompressionLevel = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) - Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, ret, targetCompressionLevel:=targetCompressionLevel) + Await Task.Run(Async Function() + HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) + Dim runResult = Await _compressableFolderService.CompressFolder(folder) + Await _compressableFolderService.AnalyseFolderAsync(folder) - 'For Each poorext In folder.PoorlyCompressedFiles - ' Debug.WriteLine($"{poorext.extension} : {poorext.totalFiles} with ratio of {poorext.cRatio}") - 'Next + If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then + Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) + End If + Dim watcherTarget = previousWatcherTarget + If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then + watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) + End If + + watcherTargetOverrides(folder) = watcherTarget + Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) Return True End Function) - End If - Next - Compressing = False - - For Each folder In Folders.Where(Function(f) f.CompressionOptions.WatchFolderForChanges) - Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed - AddOrUpdateFolderWatcher(folder, wasCompressedInCurrentBatch) - Next + Next - RemoveFolderCommand.NotifyCanExecuteChanged() - Core.SharedMethods.RestoreSleep() - Await _watcher.EnableBackgrounding() + For Each folder In Folders.Where(Function(item) item.CompressionOptions.WatchFolderForChanges) + Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed + Dim targetOverride = Core.WOFCompressionAlgorithm.NO_COMPRESSION + watcherTargetOverrides.TryGetValue(folder, targetOverride) + AddOrUpdateFolderWatcher(folder, wasCompressedInCurrentBatch, targetOverride) + Next + Finally + Compressing = False + RemoveFolderCommand.NotifyCanExecuteChanged() + Core.SharedMethods.RestoreSleep() + Await _watcher.EnableBackgrounding() + End Try End Function @@ -280,7 +305,7 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient End Function - Public Sub AddOrUpdateFolderWatcher(folder As CompressableFolder, wasCompressedInCurrentBatch As Boolean) + Public Sub AddOrUpdateFolderWatcher(folder As CompressableFolder, wasCompressedInCurrentBatch As Boolean, Optional targetOverride As Core.WOFCompressionAlgorithm = Core.WOFCompressionAlgorithm.NO_COMPRESSION) HomeViewModelLog.AddingFolderToWatcher(_logger, folder.FolderName) Dim newWatched = New Watcher.WatchedFolder(folder.FolderName, folder.DisplayName) @@ -292,7 +317,9 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient newWatched.LastCheckedSize = folder.CompressedBytes newWatched.LastSystemModifiedDate = DateTime.Now Dim existingWatched = _watcher.WatchedFolders.FirstOrDefault(Function(w) w.Folder = folder.FolderName) - If wasCompressedInCurrentBatch Then + If targetOverride <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then + newWatched.CompressionLevel = targetOverride + ElseIf wasCompressedInCurrentBatch Then newWatched.CompressionLevel = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) ElseIf existingWatched IsNot Nothing Then newWatched.CompressionLevel = existingWatched.CompressionLevel diff --git a/CompactGUI/i18n/i18n.resx b/CompactGUI/i18n/i18n.resx index f410aaa2..3725d7be 100644 --- a/CompactGUI/i18n/i18n.resx +++ b/CompactGUI/i18n/i18n.resx @@ -578,4 +578,34 @@ Are you sure you want to exit? Mode + + Stop compression? + + + Choose what CompactGUI should do with the work already completed for {0}. Save progress keeps the files and remembers these settings for later. Undo progress restores only files changed during this run. Leave as is keeps the files but does not save a resume session. + + + Save progress + + + Undo progress + + + Leave as is + + + Resume saved compression? + + + CompactGUI saved a {1} compression session for {0}. Resume the remaining files now? + + + Resume + + + Discard saved progress + + + Undoing compression + \ No newline at end of file From cd49c78be5059ae39e056d44f43e0242b44cb8bd Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:53:34 -0400 Subject: [PATCH 09/39] Polish compression stop result models --- CompactGUI/Models/CompressionStopModels.vb | 1 - 1 file changed, 1 deletion(-) diff --git a/CompactGUI/Models/CompressionStopModels.vb b/CompactGUI/Models/CompressionStopModels.vb index 441e5849..5bd320fb 100644 --- a/CompactGUI/Models/CompressionStopModels.vb +++ b/CompactGUI/Models/CompressionStopModels.vb @@ -10,7 +10,6 @@ Public Enum CompressionResumeChoice Cancel End Enum - Public NotInheritable Class CompressionRunResult Public Property Completed As Boolean Public Property StopChoice As CompressionStopChoice? From 086abd7b1d79aa802fe1dce0a7c51e9e9f87f06d Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:56:23 -0400 Subject: [PATCH 10/39] CI: capture compression stop build diagnostics --- .github/workflows/diagnose-stop-build.yml | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/diagnose-stop-build.yml diff --git a/.github/workflows/diagnose-stop-build.yml b/.github/workflows/diagnose-stop-build.yml new file mode 100644 index 00000000..c8ae1aa1 --- /dev/null +++ b/.github/workflows/diagnose-stop-build.yml @@ -0,0 +1,45 @@ +name: Diagnose compression stop build + +on: + pull_request: + branches: + - master + +permissions: + contents: read + +jobs: + diagnose: + if: github.head_ref == 'agent/compression-stop-options' + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 9.0.x + - name: Restore + shell: pwsh + run: dotnet restore CompactGUI/CompactGUI.vbproj + - name: Capture strict publish output + shell: pwsh + continue-on-error: true + run: | + dotnet publish CompactGUI/CompactGUI.vbproj ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + --no-restore ` + --output "$env:RUNNER_TEMP/diagnostic-publish" ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:TreatWarningsAsErrors=true 2>&1 | Tee-Object "$env:RUNNER_TEMP/compression-stop-build.log" + - name: Upload diagnostic log + if: always() + uses: actions/upload-artifact@v7 + with: + name: compression-stop-build-log + path: ${{ runner.temp }}/compression-stop-build.log + if-no-files-found: error + retention-days: 1 From 4e26332c7f1b750a4fd1da64b3fbd23841365c09 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:59:50 -0400 Subject: [PATCH 11/39] CI: fix compression stop compile error --- .github/workflows/fix-stop-build.yml | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/fix-stop-build.yml diff --git a/.github/workflows/fix-stop-build.yml b/.github/workflows/fix-stop-build.yml new file mode 100644 index 00000000..39d3d9d4 --- /dev/null +++ b/.github/workflows/fix-stop-build.yml @@ -0,0 +1,60 @@ +name: Fix compression stop build + +on: + pull_request: + branches: + - master + +permissions: + contents: write + +jobs: + fix: + if: github.head_ref == 'agent/compression-stop-options' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Check out feature branch + uses: actions/checkout@v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Fix reserved enum name and clean diagnostics + shell: bash + env: + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + def replace(path: str, old: str, new: str) -> None: + p = Path(path) + raw = p.read_bytes() + bom = raw.startswith(b'\xef\xbb\xbf') + text = raw.decode('utf-8-sig') + if old not in text: + raise RuntimeError(f'Expected text not found in {path}: {old!r}') + text = text.replace(old, new) + p.write_bytes((b'\xef\xbb\xbf' if bom else b'') + text.encode('utf-8')) + + replace('CompactGUI/Models/CompressionStopModels.vb', + 'Public Enum CompressionResumeChoice\n Resume\n', + 'Public Enum CompressionResumeChoice\n ResumeProgress\n') + + for path in ('CompactGUI/Services/WindowService.vb', 'CompactGUI/ViewModels/HomeViewModel.vb'): + replace(path, 'CompressionResumeChoice.Resume', 'CompressionResumeChoice.ResumeProgress') + + replace('CompactGUI/Models/CompressableFolders/CompressableFolder.vb', + ' Paused\n Undoing\n Waiting\n', + ' Paused\n Waiting\n Undoing\n') + PY + + rm -f .github/workflows/diagnose-stop-build.yml + rm -f .github/workflows/fix-stop-build.yml + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Fix compression stop enum and clean diagnostics" + git push origin "HEAD:$HEAD_REF" From 839629d8297102e665e663001064ab5fa22832a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:00:01 +0000 Subject: [PATCH 12/39] Fix compression stop enum and clean diagnostics --- .github/workflows/diagnose-stop-build.yml | 45 -------------- .github/workflows/fix-stop-build.yml | 60 ------------------- .../CompressableFolders/CompressableFolder.vb | 2 +- CompactGUI/Models/CompressionStopModels.vb | 2 +- CompactGUI/Services/WindowService.vb | 2 +- CompactGUI/ViewModels/HomeViewModel.vb | 6 +- 6 files changed, 6 insertions(+), 111 deletions(-) delete mode 100644 .github/workflows/diagnose-stop-build.yml delete mode 100644 .github/workflows/fix-stop-build.yml diff --git a/.github/workflows/diagnose-stop-build.yml b/.github/workflows/diagnose-stop-build.yml deleted file mode 100644 index c8ae1aa1..00000000 --- a/.github/workflows/diagnose-stop-build.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Diagnose compression stop build - -on: - pull_request: - branches: - - master - -permissions: - contents: read - -jobs: - diagnose: - if: github.head_ref == 'agent/compression-stop-options' - runs-on: windows-latest - steps: - - name: Check out repository - uses: actions/checkout@v7 - - name: Set up .NET - uses: actions/setup-dotnet@v6 - with: - dotnet-version: 9.0.x - - name: Restore - shell: pwsh - run: dotnet restore CompactGUI/CompactGUI.vbproj - - name: Capture strict publish output - shell: pwsh - continue-on-error: true - run: | - dotnet publish CompactGUI/CompactGUI.vbproj ` - --configuration Release ` - --runtime win-x64 ` - --self-contained true ` - --no-restore ` - --output "$env:RUNNER_TEMP/diagnostic-publish" ` - -p:PublishSingleFile=true ` - -p:IncludeNativeLibrariesForSelfExtract=true ` - -p:TreatWarningsAsErrors=true 2>&1 | Tee-Object "$env:RUNNER_TEMP/compression-stop-build.log" - - name: Upload diagnostic log - if: always() - uses: actions/upload-artifact@v7 - with: - name: compression-stop-build-log - path: ${{ runner.temp }}/compression-stop-build.log - if-no-files-found: error - retention-days: 1 diff --git a/.github/workflows/fix-stop-build.yml b/.github/workflows/fix-stop-build.yml deleted file mode 100644 index 39d3d9d4..00000000 --- a/.github/workflows/fix-stop-build.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Fix compression stop build - -on: - pull_request: - branches: - - master - -permissions: - contents: write - -jobs: - fix: - if: github.head_ref == 'agent/compression-stop-options' && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - name: Check out feature branch - uses: actions/checkout@v7 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Fix reserved enum name and clean diagnostics - shell: bash - env: - HEAD_REF: ${{ github.head_ref }} - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - def replace(path: str, old: str, new: str) -> None: - p = Path(path) - raw = p.read_bytes() - bom = raw.startswith(b'\xef\xbb\xbf') - text = raw.decode('utf-8-sig') - if old not in text: - raise RuntimeError(f'Expected text not found in {path}: {old!r}') - text = text.replace(old, new) - p.write_bytes((b'\xef\xbb\xbf' if bom else b'') + text.encode('utf-8')) - - replace('CompactGUI/Models/CompressionStopModels.vb', - 'Public Enum CompressionResumeChoice\n Resume\n', - 'Public Enum CompressionResumeChoice\n ResumeProgress\n') - - for path in ('CompactGUI/Services/WindowService.vb', 'CompactGUI/ViewModels/HomeViewModel.vb'): - replace(path, 'CompressionResumeChoice.Resume', 'CompressionResumeChoice.ResumeProgress') - - replace('CompactGUI/Models/CompressableFolders/CompressableFolder.vb', - ' Paused\n Undoing\n Waiting\n', - ' Paused\n Waiting\n Undoing\n') - PY - - rm -f .github/workflows/diagnose-stop-build.yml - rm -f .github/workflows/fix-stop-build.yml - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Fix compression stop enum and clean diagnostics" - git push origin "HEAD:$HEAD_REF" diff --git a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb index 2b6a841a..4e715792 100644 --- a/CompactGUI/Models/CompressableFolders/CompressableFolder.vb +++ b/CompactGUI/Models/CompressableFolders/CompressableFolder.vb @@ -106,6 +106,6 @@ Public Enum ActionState Working Results Paused - Undoing Waiting + Undoing End Enum \ No newline at end of file diff --git a/CompactGUI/Models/CompressionStopModels.vb b/CompactGUI/Models/CompressionStopModels.vb index 5bd320fb..78f4cd6a 100644 --- a/CompactGUI/Models/CompressionStopModels.vb +++ b/CompactGUI/Models/CompressionStopModels.vb @@ -5,7 +5,7 @@ End Enum Public Enum CompressionResumeChoice - Resume + ResumeProgress DiscardSavedProgress Cancel End Enum diff --git a/CompactGUI/Services/WindowService.vb b/CompactGUI/Services/WindowService.vb index eb6ae39d..bf098531 100644 --- a/CompactGUI/Services/WindowService.vb +++ b/CompactGUI/Services/WindowService.vb @@ -76,7 +76,7 @@ Public Class WindowService Select Case Await msgBox.ShowDialogAsync() Case Wpf.Ui.Controls.MessageBoxResult.Primary - Return CompressionResumeChoice.Resume + Return CompressionResumeChoice.ResumeProgress Case Wpf.Ui.Controls.MessageBoxResult.Secondary Return CompressionResumeChoice.DiscardSavedProgress Case Else diff --git a/CompactGUI/ViewModels/HomeViewModel.vb b/CompactGUI/ViewModels/HomeViewModel.vb index 3e84f695..8face382 100644 --- a/CompactGUI/ViewModels/HomeViewModel.vb +++ b/CompactGUI/ViewModels/HomeViewModel.vb @@ -144,7 +144,7 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient newFolder.CompressionOptions.SkipPoorlyCompressedFileTypes = _settingsService.AppSettings.SkipNonCompressable newFolder.CompressionOptions.SkipUserSubmittedFiletypes = _settingsService.AppSettings.SkipUserNonCompressable - If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Resume AndAlso savedSession IsNot Nothing Then + If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.ResumeProgress AndAlso savedSession IsNot Nothing Then newFolder.CompressionOptions.SelectedCompressionMode = savedSession.SelectedCompressionMode newFolder.CompressionOptions.SkipPoorlyCompressedFileTypes = savedSession.SkipPoorlyCompressedFileTypes newFolder.CompressionOptions.SkipUserSubmittedFiletypes = savedSession.SkipUserSubmittedFiletypes @@ -167,12 +167,12 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient If _watcher.WatchedFolders.Any(Function(watched) watched.Folder = newFolder.FolderName) Then Dim watchedFolder = _watcher.WatchedFolders.First(Function(watched) watched.Folder = newFolder.FolderName) newFolder.CompressionOptions.WatchFolderForChanges = True - If (Not resumeChoice.HasValue OrElse resumeChoice.Value <> CompressionResumeChoice.Resume) AndAlso watchedFolder.CompressionLevel <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then + If (Not resumeChoice.HasValue OrElse resumeChoice.Value <> CompressionResumeChoice.ResumeProgress) AndAlso watchedFolder.CompressionLevel <> Core.WOFCompressionAlgorithm.NO_COMPRESSION Then newFolder.CompressionOptions.SelectedCompressionMode = Core.WOFHelper.CompressionModeFromWOFMode(watchedFolder.CompressionLevel) End If End If - If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.Resume Then foldersToResume.Add(newFolder) + If resumeChoice.HasValue AndAlso resumeChoice.Value = CompressionResumeChoice.ResumeProgress Then foldersToResume.Add(newFolder) Next If foldersToResume.Count > 0 Then Await CompressFoldersAsync(foldersToResume) From cf9725b0b0ceea9cb19be966f0284263670864fa Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:00:54 -0400 Subject: [PATCH 13/39] Document compression stop decisions --- CompactGUI/Models/CompressionStopModels.vb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CompactGUI/Models/CompressionStopModels.vb b/CompactGUI/Models/CompressionStopModels.vb index 78f4cd6a..69af38df 100644 --- a/CompactGUI/Models/CompressionStopModels.vb +++ b/CompactGUI/Models/CompressionStopModels.vb @@ -1,9 +1,11 @@ -Public Enum CompressionStopChoice +' Decisions shown when stopping an active manual compression run. +Public Enum CompressionStopChoice SaveProgress UndoProgress LeaveAsIs End Enum +' Decisions shown when a folder has a saved resumable compression session. Public Enum CompressionResumeChoice ResumeProgress DiscardSavedProgress From 4ddb19e73dea252cef8fa6d2bb3a530b360ea9e2 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:03:58 -0400 Subject: [PATCH 14/39] CI: capture remaining compression stop diagnostics --- .github/workflows/diagnose-stop-build.yml | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/diagnose-stop-build.yml diff --git a/.github/workflows/diagnose-stop-build.yml b/.github/workflows/diagnose-stop-build.yml new file mode 100644 index 00000000..c8ae1aa1 --- /dev/null +++ b/.github/workflows/diagnose-stop-build.yml @@ -0,0 +1,45 @@ +name: Diagnose compression stop build + +on: + pull_request: + branches: + - master + +permissions: + contents: read + +jobs: + diagnose: + if: github.head_ref == 'agent/compression-stop-options' + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 9.0.x + - name: Restore + shell: pwsh + run: dotnet restore CompactGUI/CompactGUI.vbproj + - name: Capture strict publish output + shell: pwsh + continue-on-error: true + run: | + dotnet publish CompactGUI/CompactGUI.vbproj ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + --no-restore ` + --output "$env:RUNNER_TEMP/diagnostic-publish" ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:TreatWarningsAsErrors=true 2>&1 | Tee-Object "$env:RUNNER_TEMP/compression-stop-build.log" + - name: Upload diagnostic log + if: always() + uses: actions/upload-artifact@v7 + with: + name: compression-stop-build-log + path: ${{ runner.temp }}/compression-stop-build.log + if-no-files-found: error + retention-days: 1 From 9e1d66e76d028647bf2d7db26a4ffbafea0081f4 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:08:45 -0400 Subject: [PATCH 15/39] CI: fix compression stop generator errors --- .github/workflows/fix-stop-build.yml | 111 +++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/workflows/fix-stop-build.yml diff --git a/.github/workflows/fix-stop-build.yml b/.github/workflows/fix-stop-build.yml new file mode 100644 index 00000000..ac4863b0 --- /dev/null +++ b/.github/workflows/fix-stop-build.yml @@ -0,0 +1,111 @@ +name: Fix compression stop build + +on: + pull_request: + branches: + - master + +permissions: + contents: write + +jobs: + fix: + if: github.head_ref == 'agent/compression-stop-options' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Check out feature branch + uses: actions/checkout@v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Fix command generation and async cleanup + shell: bash + env: + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + def replace(path: str, old: str, new: str) -> None: + p = Path(path) + raw = p.read_bytes() + bom = raw.startswith(b'\xef\xbb\xbf') + text = raw.decode('utf-8-sig') + if old not in text: + raise RuntimeError(f'Expected block not found in {path}') + text = text.replace(old, new, 1) + p.write_bytes((b'\xef\xbb\xbf' if bom else b'') + text.encode('utf-8')) + + folder_vm = 'CompactGUI/ViewModels/FolderViewModel.vb' + replace(folder_vm, + ' \n Private Sub Pause()', + ' \n Private Sub Pause()') + replace(folder_vm, + ''' Private Function CanControlCompression() As Boolean + Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) + End Function + + + Private Async Function Cancel() As Task''', + ''' Private Function CanControlCompression() As Boolean + Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) + End Function + + Private Function CanPause() As Boolean + Return CanControlCompression() + End Function + + Private Function CanCancel() As Boolean + Return CanControlCompression() + End Function + + + Private Async Function Cancel() As Task''') + + home_vm = 'CompactGUI/ViewModels/HomeViewModel.vb' + replace(home_vm, + 'Imports System.ComponentModel\n', + 'Imports System.ComponentModel\nImports System.Runtime.ExceptionServices\n') + replace(home_vm, + ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() + + Try''', + ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() + Dim capturedException As Exception = Nothing + + Try''') + replace(home_vm, + ''' Next + Finally + Compressing = False + RemoveFolderCommand.NotifyCanExecuteChanged() + Core.SharedMethods.RestoreSleep() + Await _watcher.EnableBackgrounding() + End Try + End Function''', + ''' Next + Catch ex As Exception + capturedException = ex + Finally + Compressing = False + RemoveFolderCommand.NotifyCanExecuteChanged() + Core.SharedMethods.RestoreSleep() + End Try + + Await _watcher.EnableBackgrounding() + If capturedException IsNot Nothing Then + ExceptionDispatchInfo.Capture(capturedException).Throw() + End If + End Function''') + PY + + rm -f .github/workflows/diagnose-stop-build.yml + rm -f .github/workflows/fix-stop-build.yml + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Fix compression stop command and cleanup flow" + git push origin "HEAD:$HEAD_REF" From 001559a9a0dcbaa7db17bf48b7fda8de1af824f2 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:10:06 -0400 Subject: [PATCH 16/39] CI: route compression stop fixes through trusted workflow --- .github/workflows/build-release.yml | 268 ++++++++++------------------ 1 file changed, 95 insertions(+), 173 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 7f64645e..e37fe250 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -1,192 +1,114 @@ -name: Build and Release +name: Apply compression stop compile fixes on: pull_request: branches: - master - push: - branches: - - master - tags: - - "v*" workflow_dispatch: - inputs: - release_tag: - description: "Optional tag to create a release from the selected ref (for example: v4.0.0-beta.7-marc.1)" - required: false - type: string - prerelease: - description: "Mark a manually created release as a prerelease" - required: false - default: true - type: boolean permissions: - contents: read - -env: - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - PROJECT_PATH: CompactGUI/CompactGUI.vbproj - RUNTIME_IDENTIFIER: win-x64 + contents: write jobs: - build: - name: Build Windows x64 - runs-on: windows-latest - - steps: - - name: Check out repository - uses: actions/checkout@v7 - - - name: Set up .NET - uses: actions/setup-dotnet@v6 - with: - dotnet-version: 9.0.x - - - name: Restore - run: dotnet restore $env:PROJECT_PATH - - - name: Publish self-contained executable - shell: pwsh - run: | - $publishDir = Join-Path $env:RUNNER_TEMP "compactgui-publish" - Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue - - dotnet publish $env:PROJECT_PATH ` - --configuration Release ` - --runtime $env:RUNTIME_IDENTIFIER ` - --self-contained true ` - --no-restore ` - --output $publishDir ` - -p:PublishSingleFile=true ` - -p:IncludeNativeLibrariesForSelfExtract=true ` - -p:DebugType=None ` - -p:DebugSymbols=false ` - -p:TreatWarningsAsErrors=true - - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - - $exe = Join-Path $publishDir "CompactGUI.exe" - if (-not (Test-Path $exe)) { - throw "Publish completed without producing CompactGUI.exe" - } - - - name: Package artifacts - shell: pwsh - run: | - $publishDir = Join-Path $env:RUNNER_TEMP "compactgui-publish" - $packageDir = Join-Path $env:RUNNER_TEMP "compactgui-package" - $artifactDir = Join-Path $env:RUNNER_TEMP "compactgui-artifacts" - - Remove-Item $packageDir, $artifactDir -Recurse -Force -ErrorAction SilentlyContinue - New-Item $packageDir, $artifactDir -ItemType Directory -Force | Out-Null - - Copy-Item (Join-Path $publishDir "CompactGUI.exe") $packageDir - Copy-Item "LICENSE" $packageDir - - @( - "Repository: $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY" - "Commit: $env:GITHUB_SHA" - "Ref: $env:GITHUB_REF" - "Runtime: $env:RUNTIME_IDENTIFIER" - ) | Set-Content (Join-Path $packageDir "BUILD-INFO.txt") -Encoding utf8 - - $zipPath = Join-Path $artifactDir "CompactGUI-win-x64.zip" - Compress-Archive -Path (Join-Path $packageDir "*") -DestinationPath $zipPath -CompressionLevel Optimal - Copy-Item (Join-Path $publishDir "CompactGUI.exe") (Join-Path $artifactDir "CompactGUI.exe") - - Get-ChildItem $artifactDir -File | - Sort-Object Name | - ForEach-Object { - $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash *$($_.Name)" - } | Set-Content (Join-Path $artifactDir "SHA256SUMS.txt") -Encoding ascii - - - name: Upload build artifacts - uses: actions/upload-artifact@v7 - with: - name: CompactGUI-win-x64 - path: ${{ runner.temp }}/compactgui-artifacts/* - if-no-files-found: error - retention-days: 14 - - release: - name: Publish GitHub release - needs: build - if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + apply-fix: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Check out repository + - name: Check out feature branch uses: actions/checkout@v7 with: + ref: ${{ github.head_ref }} fetch-depth: 0 - - name: Download release assets - uses: actions/download-artifact@v8 - with: - name: CompactGUI-win-x64 - path: release-assets - - - name: Resolve release tag - id: release - shell: bash - env: - MANUAL_TAG: ${{ inputs.release_tag }} - MANUAL_PRERELEASE: ${{ inputs.prerelease }} - run: | - if [[ "$GITHUB_REF" == refs/tags/* ]]; then - tag="${GITHUB_REF#refs/tags/}" - else - tag="$MANUAL_TAG" - fi - - if [[ -z "$tag" || "$tag" != v* ]]; then - echo "Release tags must start with v (for example: v4.0.0-beta.7-marc.1)." >&2 - exit 1 - fi - - prerelease=false - if [[ "$tag" == *-* ]] || [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$MANUAL_PRERELEASE" == "true" ]]; then - prerelease=true - fi - - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" - - - name: Create or update release + - name: Apply compile fixes and restore normal workflow shell: bash env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.release.outputs.tag }} - PRERELEASE: ${{ steps.release.outputs.prerelease }} + HEAD_REF: ${{ github.head_ref }} run: | - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release upload "$TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" - exit 0 - fi - - release_args=( - release create "$TAG" - release-assets/* - --repo "$GITHUB_REPOSITORY" - --title "CompactGUI $TAG" - --generate-notes - ) - - if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - release_args+=(--verify-tag) - else - release_args+=(--target "$GITHUB_SHA") - fi - - if [[ "$PRERELEASE" == "true" ]]; then - release_args+=(--prerelease) - fi - - gh "${release_args[@]}" \ No newline at end of file + set -euo pipefail + python - <<'PY' + from pathlib import Path + + def replace(path: str, old: str, new: str) -> None: + p = Path(path) + raw = p.read_bytes() + bom = raw.startswith(b'\xef\xbb\xbf') + text = raw.decode('utf-8-sig') + if old not in text: + raise RuntimeError(f'Expected block not found in {path}') + text = text.replace(old, new, 1) + p.write_bytes((b'\xef\xbb\xbf' if bom else b'') + text.encode('utf-8')) + + folder_vm = 'CompactGUI/ViewModels/FolderViewModel.vb' + replace(folder_vm, + ' \n Private Sub Pause()', + ' \n Private Sub Pause()') + replace(folder_vm, + ''' Private Function CanControlCompression() As Boolean + Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) + End Function + + + Private Async Function Cancel() As Task''', + ''' Private Function CanControlCompression() As Boolean + Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) + End Function + + Private Function CanPause() As Boolean + Return CanControlCompression() + End Function + + Private Function CanCancel() As Boolean + Return CanControlCompression() + End Function + + + Private Async Function Cancel() As Task''') + + home_vm = 'CompactGUI/ViewModels/HomeViewModel.vb' + replace(home_vm, + 'Imports System.ComponentModel\n', + 'Imports System.ComponentModel\nImports System.Runtime.ExceptionServices\n') + replace(home_vm, + ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() + + Try''', + ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() + Dim capturedException As Exception = Nothing + + Try''') + replace(home_vm, + ''' Next + Finally + Compressing = False + RemoveFolderCommand.NotifyCanExecuteChanged() + Core.SharedMethods.RestoreSleep() + Await _watcher.EnableBackgrounding() + End Try + End Function''', + ''' Next + Catch ex As Exception + capturedException = ex + Finally + Compressing = False + RemoveFolderCommand.NotifyCanExecuteChanged() + Core.SharedMethods.RestoreSleep() + End Try + + Await _watcher.EnableBackgrounding() + If capturedException IsNot Nothing Then + ExceptionDispatchInfo.Capture(capturedException).Throw() + End If + End Function''') + PY + + git fetch origin master --depth=1 + git checkout origin/master -- .github/workflows/build-release.yml + rm -f .github/workflows/diagnose-stop-build.yml + rm -f .github/workflows/fix-stop-build.yml + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Fix compression stop command and cleanup flow" + git push origin "HEAD:$HEAD_REF" From 8e8b5605512a4803c7156bc95b189ea0625bc0d9 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:12:58 -0400 Subject: [PATCH 17/39] Fix compression stop command generation --- CompactGUI/ViewModels/FolderViewModel.vb | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/CompactGUI/ViewModels/FolderViewModel.vb b/CompactGUI/ViewModels/FolderViewModel.vb index 0fa56325..f48e86fa 100644 --- a/CompactGUI/ViewModels/FolderViewModel.vb +++ b/CompactGUI/ViewModels/FolderViewModel.vb @@ -167,7 +167,7 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem _snackbarService.ShowAppliedToAllFolders() End Sub - + Private Sub Pause() If Folder.FolderActionState = ActionState.Working Then @@ -184,7 +184,15 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) End Function - + Private Function CanPause() As Boolean + Return CanControlCompression() + End Function + + Private Function CanCancel() As Boolean + Return CanControlCompression() + End Function + + Private Async Function Cancel() As Task If Not TypeOf Folder.Compressor Is Core.Compactor Then Folder.Compressor?.Cancel() @@ -237,4 +245,3 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem End Class - From 2ad4793c0c1ff27adcb73d2137527329889bfe65 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:14:08 -0400 Subject: [PATCH 18/39] Fix compression stop async cleanup flow --- CompactGUI/ViewModels/HomeViewModel.vb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CompactGUI/ViewModels/HomeViewModel.vb b/CompactGUI/ViewModels/HomeViewModel.vb index 8face382..dda03a85 100644 --- a/CompactGUI/ViewModels/HomeViewModel.vb +++ b/CompactGUI/ViewModels/HomeViewModel.vb @@ -1,6 +1,7 @@ Imports System.Collections.ObjectModel Imports System.Collections.Specialized Imports System.ComponentModel +Imports System.Runtime.ExceptionServices Imports CommunityToolkit.Mvvm.ComponentModel Imports CommunityToolkit.Mvvm.Input @@ -257,6 +258,7 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient HomeViewModelLog.StartingBatchCompression(_logger, foldersToCompress.Count) Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() + Dim capturedException As Exception = Nothing Try For Each folder In foldersToCompress @@ -291,12 +293,18 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient watcherTargetOverrides.TryGetValue(folder, targetOverride) AddOrUpdateFolderWatcher(folder, wasCompressedInCurrentBatch, targetOverride) Next + Catch ex As Exception + capturedException = ex Finally Compressing = False RemoveFolderCommand.NotifyCanExecuteChanged() Core.SharedMethods.RestoreSleep() - Await _watcher.EnableBackgrounding() End Try + + Await _watcher.EnableBackgrounding() + If capturedException IsNot Nothing Then + ExceptionDispatchInfo.Capture(capturedException).Throw() + End If End Function From bab9d827ea607971c81f4aa496ded9b60f620283 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:14:43 -0400 Subject: [PATCH 19/39] Restore normal build workflow --- .github/workflows/build-release.yml | 268 ++++++++++++++++++---------- 1 file changed, 173 insertions(+), 95 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index e37fe250..e1df4923 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -1,114 +1,192 @@ -name: Apply compression stop compile fixes +name: Build and Release on: pull_request: branches: - master + push: + branches: + - master + tags: + - "v*" workflow_dispatch: + inputs: + release_tag: + description: "Optional tag to create a release from the selected ref (for example: v4.0.0-beta.7-marc.1)" + required: false + type: string + prerelease: + description: "Mark a manually created release as a prerelease" + required: false + default: true + type: boolean permissions: - contents: write + contents: read + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + PROJECT_PATH: CompactGUI/CompactGUI.vbproj + RUNTIME_IDENTIFIER: win-x64 jobs: - apply-fix: - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + build: + name: Build Windows x64 + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 9.0.x + + - name: Restore + run: dotnet restore $env:PROJECT_PATH + + - name: Publish self-contained executable + shell: pwsh + run: | + $publishDir = Join-Path $env:RUNNER_TEMP "compactgui-publish" + Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue + + dotnet publish $env:PROJECT_PATH ` + --configuration Release ` + --runtime $env:RUNTIME_IDENTIFIER ` + --self-contained true ` + --no-restore ` + --output $publishDir ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:DebugType=None ` + -p:DebugSymbols=false ` + -p:TreatWarningsAsErrors=true + + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + $exe = Join-Path $publishDir "CompactGUI.exe" + if (-not (Test-Path $exe)) { + throw "Publish completed without producing CompactGUI.exe" + } + + - name: Package artifacts + shell: pwsh + run: | + $publishDir = Join-Path $env:RUNNER_TEMP "compactgui-publish" + $packageDir = Join-Path $env:RUNNER_TEMP "compactgui-package" + $artifactDir = Join-Path $env:RUNNER_TEMP "compactgui-artifacts" + + Remove-Item $packageDir, $artifactDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item $packageDir, $artifactDir -ItemType Directory -Force | Out-Null + + Copy-Item (Join-Path $publishDir "CompactGUI.exe") $packageDir + Copy-Item "LICENSE" $packageDir + + @( + "Repository: $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY" + "Commit: $env:GITHUB_SHA" + "Ref: $env:GITHUB_REF" + "Runtime: $env:RUNTIME_IDENTIFIER" + ) | Set-Content (Join-Path $packageDir "BUILD-INFO.txt") -Encoding utf8 + + $zipPath = Join-Path $artifactDir "CompactGUI-win-x64.zip" + Compress-Archive -Path (Join-Path $packageDir "*") -DestinationPath $zipPath -CompressionLevel Optimal + Copy-Item (Join-Path $publishDir "CompactGUI.exe") (Join-Path $artifactDir "CompactGUI.exe") + + Get-ChildItem $artifactDir -File | + Sort-Object Name | + ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash *$($_.Name)" + } | Set-Content (Join-Path $artifactDir "SHA256SUMS.txt") -Encoding ascii + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: CompactGUI-win-x64 + path: ${{ runner.temp }}/compactgui-artifacts/* + if-no-files-found: error + retention-days: 14 + + release: + name: Publish GitHub release + needs: build + if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') runs-on: ubuntu-latest + permissions: + contents: write + steps: - - name: Check out feature branch + - name: Check out repository uses: actions/checkout@v7 with: - ref: ${{ github.head_ref }} fetch-depth: 0 - - name: Apply compile fixes and restore normal workflow + - name: Download release assets + uses: actions/download-artifact@v8 + with: + name: CompactGUI-win-x64 + path: release-assets + + - name: Resolve release tag + id: release + shell: bash + env: + MANUAL_TAG: ${{ inputs.release_tag }} + MANUAL_PRERELEASE: ${{ inputs.prerelease }} + run: | + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + tag="${GITHUB_REF#refs/tags/}" + else + tag="$MANUAL_TAG" + fi + + if [[ -z "$tag" || "$tag" != v* ]]; then + echo "Release tags must start with v (for example: v4.0.0-beta.7-marc.1)." >&2 + exit 1 + fi + + prerelease=false + if [[ "$tag" == *-* ]] || [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$MANUAL_PRERELEASE" == "true" ]]; then + prerelease=true + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + + - name: Create or update release shell: bash env: - HEAD_REF: ${{ github.head_ref }} + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + PRERELEASE: ${{ steps.release.outputs.prerelease }} run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - def replace(path: str, old: str, new: str) -> None: - p = Path(path) - raw = p.read_bytes() - bom = raw.startswith(b'\xef\xbb\xbf') - text = raw.decode('utf-8-sig') - if old not in text: - raise RuntimeError(f'Expected block not found in {path}') - text = text.replace(old, new, 1) - p.write_bytes((b'\xef\xbb\xbf' if bom else b'') + text.encode('utf-8')) - - folder_vm = 'CompactGUI/ViewModels/FolderViewModel.vb' - replace(folder_vm, - ' \n Private Sub Pause()', - ' \n Private Sub Pause()') - replace(folder_vm, - ''' Private Function CanControlCompression() As Boolean - Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) - End Function - - - Private Async Function Cancel() As Task''', - ''' Private Function CanControlCompression() As Boolean - Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) - End Function - - Private Function CanPause() As Boolean - Return CanControlCompression() - End Function - - Private Function CanCancel() As Boolean - Return CanControlCompression() - End Function - - - Private Async Function Cancel() As Task''') - - home_vm = 'CompactGUI/ViewModels/HomeViewModel.vb' - replace(home_vm, - 'Imports System.ComponentModel\n', - 'Imports System.ComponentModel\nImports System.Runtime.ExceptionServices\n') - replace(home_vm, - ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() - - Try''', - ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() - Dim capturedException As Exception = Nothing - - Try''') - replace(home_vm, - ''' Next - Finally - Compressing = False - RemoveFolderCommand.NotifyCanExecuteChanged() - Core.SharedMethods.RestoreSleep() - Await _watcher.EnableBackgrounding() - End Try - End Function''', - ''' Next - Catch ex As Exception - capturedException = ex - Finally - Compressing = False - RemoveFolderCommand.NotifyCanExecuteChanged() - Core.SharedMethods.RestoreSleep() - End Try - - Await _watcher.EnableBackgrounding() - If capturedException IsNot Nothing Then - ExceptionDispatchInfo.Capture(capturedException).Throw() - End If - End Function''') - PY - - git fetch origin master --depth=1 - git checkout origin/master -- .github/workflows/build-release.yml - rm -f .github/workflows/diagnose-stop-build.yml - rm -f .github/workflows/fix-stop-build.yml - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Fix compression stop command and cleanup flow" - git push origin "HEAD:$HEAD_REF" + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" + exit 0 + fi + + release_args=( + release create "$TAG" + release-assets/* + --repo "$GITHUB_REPOSITORY" + --title "CompactGUI $TAG" + --generate-notes + ) + + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + release_args+=(--verify-tag) + else + release_args+=(--target "$GITHUB_SHA") + fi + + if [[ "$PRERELEASE" == "true" ]]; then + release_args+=(--prerelease) + fi + + gh "${release_args[@]}" From 4941efbe11f48923a454162aa694313840824653 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:15:01 -0400 Subject: [PATCH 20/39] Remove temporary compression stop diagnostics --- .github/workflows/diagnose-stop-build.yml | 45 ----------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/diagnose-stop-build.yml diff --git a/.github/workflows/diagnose-stop-build.yml b/.github/workflows/diagnose-stop-build.yml deleted file mode 100644 index c8ae1aa1..00000000 --- a/.github/workflows/diagnose-stop-build.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Diagnose compression stop build - -on: - pull_request: - branches: - - master - -permissions: - contents: read - -jobs: - diagnose: - if: github.head_ref == 'agent/compression-stop-options' - runs-on: windows-latest - steps: - - name: Check out repository - uses: actions/checkout@v7 - - name: Set up .NET - uses: actions/setup-dotnet@v6 - with: - dotnet-version: 9.0.x - - name: Restore - shell: pwsh - run: dotnet restore CompactGUI/CompactGUI.vbproj - - name: Capture strict publish output - shell: pwsh - continue-on-error: true - run: | - dotnet publish CompactGUI/CompactGUI.vbproj ` - --configuration Release ` - --runtime win-x64 ` - --self-contained true ` - --no-restore ` - --output "$env:RUNNER_TEMP/diagnostic-publish" ` - -p:PublishSingleFile=true ` - -p:IncludeNativeLibrariesForSelfExtract=true ` - -p:TreatWarningsAsErrors=true 2>&1 | Tee-Object "$env:RUNNER_TEMP/compression-stop-build.log" - - name: Upload diagnostic log - if: always() - uses: actions/upload-artifact@v7 - with: - name: compression-stop-build-log - path: ${{ runner.temp }}/compression-stop-build.log - if-no-files-found: error - retention-days: 1 From 848ec955a086016e128d4836c12a5e26af1dd370 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:15:08 -0400 Subject: [PATCH 21/39] Remove temporary compression stop fix workflow --- .github/workflows/fix-stop-build.yml | 111 --------------------------- 1 file changed, 111 deletions(-) delete mode 100644 .github/workflows/fix-stop-build.yml diff --git a/.github/workflows/fix-stop-build.yml b/.github/workflows/fix-stop-build.yml deleted file mode 100644 index ac4863b0..00000000 --- a/.github/workflows/fix-stop-build.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Fix compression stop build - -on: - pull_request: - branches: - - master - -permissions: - contents: write - -jobs: - fix: - if: github.head_ref == 'agent/compression-stop-options' && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - name: Check out feature branch - uses: actions/checkout@v7 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Fix command generation and async cleanup - shell: bash - env: - HEAD_REF: ${{ github.head_ref }} - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - def replace(path: str, old: str, new: str) -> None: - p = Path(path) - raw = p.read_bytes() - bom = raw.startswith(b'\xef\xbb\xbf') - text = raw.decode('utf-8-sig') - if old not in text: - raise RuntimeError(f'Expected block not found in {path}') - text = text.replace(old, new, 1) - p.write_bytes((b'\xef\xbb\xbf' if bom else b'') + text.encode('utf-8')) - - folder_vm = 'CompactGUI/ViewModels/FolderViewModel.vb' - replace(folder_vm, - ' \n Private Sub Pause()', - ' \n Private Sub Pause()') - replace(folder_vm, - ''' Private Function CanControlCompression() As Boolean - Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) - End Function - - - Private Async Function Cancel() As Task''', - ''' Private Function CanControlCompression() As Boolean - Return Folder IsNot Nothing AndAlso (Folder.FolderActionState = ActionState.Working OrElse Folder.FolderActionState = ActionState.Paused) - End Function - - Private Function CanPause() As Boolean - Return CanControlCompression() - End Function - - Private Function CanCancel() As Boolean - Return CanControlCompression() - End Function - - - Private Async Function Cancel() As Task''') - - home_vm = 'CompactGUI/ViewModels/HomeViewModel.vb' - replace(home_vm, - 'Imports System.ComponentModel\n', - 'Imports System.ComponentModel\nImports System.Runtime.ExceptionServices\n') - replace(home_vm, - ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() - - Try''', - ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() - Dim capturedException As Exception = Nothing - - Try''') - replace(home_vm, - ''' Next - Finally - Compressing = False - RemoveFolderCommand.NotifyCanExecuteChanged() - Core.SharedMethods.RestoreSleep() - Await _watcher.EnableBackgrounding() - End Try - End Function''', - ''' Next - Catch ex As Exception - capturedException = ex - Finally - Compressing = False - RemoveFolderCommand.NotifyCanExecuteChanged() - Core.SharedMethods.RestoreSleep() - End Try - - Await _watcher.EnableBackgrounding() - If capturedException IsNot Nothing Then - ExceptionDispatchInfo.Capture(capturedException).Throw() - End If - End Function''') - PY - - rm -f .github/workflows/diagnose-stop-build.yml - rm -f .github/workflows/fix-stop-build.yml - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Fix compression stop command and cleanup flow" - git push origin "HEAD:$HEAD_REF" From 579440afe08e9c4a7eee058e99ef8f856270184e Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:19:13 -0400 Subject: [PATCH 22/39] Restore build workflow exactly --- .github/workflows/build-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index e1df4923..7f64645e 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -189,4 +189,4 @@ jobs: release_args+=(--prerelease) fi - gh "${release_args[@]}" + gh "${release_args[@]}" \ No newline at end of file From 37a6bd40c88f9db39ea5e647d3a4548cdbe4822f Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:56:53 -0400 Subject: [PATCH 23/39] Stage zero-work compression crash fix --- .github/workflows/build-release.yml | 361 ++++++++++++++-------------- 1 file changed, 187 insertions(+), 174 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 7f64645e..17fe3d71 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -1,192 +1,205 @@ -name: Build and Release +name: Apply zero-work compression fix on: pull_request: branches: - master - push: - branches: - - master - tags: - - "v*" - workflow_dispatch: - inputs: - release_tag: - description: "Optional tag to create a release from the selected ref (for example: v4.0.0-beta.7-marc.1)" - required: false - type: string - prerelease: - description: "Mark a manually created release as a prerelease" - required: false - default: true - type: boolean permissions: - contents: read - -env: - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - PROJECT_PATH: CompactGUI/CompactGUI.vbproj - RUNTIME_IDENTIFIER: win-x64 + contents: write jobs: - build: - name: Build Windows x64 - runs-on: windows-latest - - steps: - - name: Check out repository - uses: actions/checkout@v7 - - - name: Set up .NET - uses: actions/setup-dotnet@v6 - with: - dotnet-version: 9.0.x - - - name: Restore - run: dotnet restore $env:PROJECT_PATH - - - name: Publish self-contained executable - shell: pwsh - run: | - $publishDir = Join-Path $env:RUNNER_TEMP "compactgui-publish" - Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue - - dotnet publish $env:PROJECT_PATH ` - --configuration Release ` - --runtime $env:RUNTIME_IDENTIFIER ` - --self-contained true ` - --no-restore ` - --output $publishDir ` - -p:PublishSingleFile=true ` - -p:IncludeNativeLibrariesForSelfExtract=true ` - -p:DebugType=None ` - -p:DebugSymbols=false ` - -p:TreatWarningsAsErrors=true - - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - - $exe = Join-Path $publishDir "CompactGUI.exe" - if (-not (Test-Path $exe)) { - throw "Publish completed without producing CompactGUI.exe" - } - - - name: Package artifacts - shell: pwsh - run: | - $publishDir = Join-Path $env:RUNNER_TEMP "compactgui-publish" - $packageDir = Join-Path $env:RUNNER_TEMP "compactgui-package" - $artifactDir = Join-Path $env:RUNNER_TEMP "compactgui-artifacts" - - Remove-Item $packageDir, $artifactDir -Recurse -Force -ErrorAction SilentlyContinue - New-Item $packageDir, $artifactDir -ItemType Directory -Force | Out-Null - - Copy-Item (Join-Path $publishDir "CompactGUI.exe") $packageDir - Copy-Item "LICENSE" $packageDir - - @( - "Repository: $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY" - "Commit: $env:GITHUB_SHA" - "Ref: $env:GITHUB_REF" - "Runtime: $env:RUNTIME_IDENTIFIER" - ) | Set-Content (Join-Path $packageDir "BUILD-INFO.txt") -Encoding utf8 - - $zipPath = Join-Path $artifactDir "CompactGUI-win-x64.zip" - Compress-Archive -Path (Join-Path $packageDir "*") -DestinationPath $zipPath -CompressionLevel Optimal - Copy-Item (Join-Path $publishDir "CompactGUI.exe") (Join-Path $artifactDir "CompactGUI.exe") - - Get-ChildItem $artifactDir -File | - Sort-Object Name | - ForEach-Object { - $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash *$($_.Name)" - } | Set-Content (Join-Path $artifactDir "SHA256SUMS.txt") -Encoding ascii - - - name: Upload build artifacts - uses: actions/upload-artifact@v7 - with: - name: CompactGUI-win-x64 - path: ${{ runner.temp }}/compactgui-artifacts/* - if-no-files-found: error - retention-days: 14 - - release: - name: Publish GitHub release - needs: build - if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + apply-fix: + if: github.head_ref == 'agent/compression-stop-options' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Check out repository + - name: Check out feature branch uses: actions/checkout@v7 with: + ref: ${{ github.head_ref }} fetch-depth: 0 - - name: Download release assets - uses: actions/download-artifact@v8 - with: - name: CompactGUI-win-x64 - path: release-assets - - - name: Resolve release tag - id: release - shell: bash - env: - MANUAL_TAG: ${{ inputs.release_tag }} - MANUAL_PRERELEASE: ${{ inputs.prerelease }} - run: | - if [[ "$GITHUB_REF" == refs/tags/* ]]; then - tag="${GITHUB_REF#refs/tags/}" - else - tag="$MANUAL_TAG" - fi - - if [[ -z "$tag" || "$tag" != v* ]]; then - echo "Release tags must start with v (for example: v4.0.0-beta.7-marc.1)." >&2 - exit 1 - fi - - prerelease=false - if [[ "$tag" == *-* ]] || [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$MANUAL_PRERELEASE" == "true" ]]; then - prerelease=true - fi - - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" - - - name: Create or update release + - name: Patch no-work completion and watcher cleanup shell: bash env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.release.outputs.tag }} - PRERELEASE: ${{ steps.release.outputs.prerelease }} + HEAD_REF: ${{ github.head_ref }} run: | - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release upload "$TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" - exit 0 - fi - - release_args=( - release create "$TAG" - release-assets/* - --repo "$GITHUB_REPOSITORY" - --title "CompactGUI $TAG" - --generate-notes - ) - - if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - release_args+=(--verify-tag) - else - release_args+=(--target "$GITHUB_SHA") - fi - - if [[ "$PRERELEASE" == "true" ]]; then - release_args+=(--prerelease) - fi - - gh "${release_args[@]}" \ No newline at end of file + set -euo pipefail + python - <<'PY' + from pathlib import Path + + def replace(path: str, old: str, new: str) -> None: + p = Path(path) + raw = p.read_bytes() + bom = raw.startswith(b'\xef\xbb\xbf') + text = raw.decode('utf-8-sig') + if old not in text: + raise RuntimeError(f'Expected block not found in {path}') + text = text.replace(old, new, 1) + p.write_bytes((b'\xef\xbb\xbf' if bom else b'') + text.encode('utf-8')) + + replace( + 'CompactGUI.Core/Compactor.cs', + ''' public IReadOnlyDictionary ProcessedFiles => processedFiles; + + public Compactor''', + ''' public IReadOnlyDictionary ProcessedFiles => processedFiles; + public int WorkItemCount { get; private set; } + + public Compactor''') + + replace( + 'CompactGUI.Core/Compactor.cs', + ''' var workingFiles = await BuildWorkingFilesList(filesList).ConfigureAwait(false); + long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize); + + totalProcessedBytes = 0; + processedFiles.Clear(); + + var sw = Stopwatch.StartNew();''', + ''' var workingFiles = (await BuildWorkingFilesList(filesList).ConfigureAwait(false)).ToList(); + WorkItemCount = workingFiles.Count; + long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize); + + totalProcessedBytes = 0; + processedFiles.Clear(); + + if (WorkItemCount == 0) + { + progressMonitor?.Report(new CompressionProgress(100, string.Empty)); + CompactorLog.CompressionCompleted(_logger, 0); + return true; + } + + var sw = Stopwatch.StartNew();''') + + replace( + 'CompactGUI.Core/Compactor.cs', + ''' progressMonitor?.Report(new CompressionProgress((int)((double)totalProcessedBytes / totalFilesSize * 100.0), file.FileName));''', + ''' var progressPercent = totalFilesSize <= 0 + ? 100 + : (int)((double)totalProcessedBytes / totalFilesSize * 100.0); + progressMonitor?.Report(new CompressionProgress(progressPercent, file.FileName));''') + + replace( + 'CompactGUI/Models/CompressionStopModels.vb', + '''Public NotInheritable Class CompressionRunResult + Public Property Completed As Boolean + Public Property StopChoice As CompressionStopChoice? +End Class''', + '''Public NotInheritable Class CompressionRunResult + Public Property Completed As Boolean + Public Property HadWork As Boolean + Public Property StopChoice As CompressionStopChoice? +End Class''') + + replace( + 'CompactGUI/Services/CompressableFolderService.vb', + ''' runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) + + If isCompressing Then''', + ''' runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) + runResult.HadWork = Not isCompressing OrElse Not TypeOf compressor Is Compactor OrElse DirectCast(compressor, Compactor).WorkItemCount > 0 + + If isCompressing Then''') + + replace( + 'CompactGUI/Services/CompressableFolderService.vb', + ''' folder.FolderActionState = ActionState.Results + folder.IsFreshlyCompressed = runResult.Completed''', + ''' folder.FolderActionState = ActionState.Results + folder.IsFreshlyCompressed = runResult.Completed AndAlso runResult.HadWork''') + + replace( + 'CompactGUI/Services/CompressableFolderService.vb', + ''' Dim retAnalysisResults = Await folder.Analyser.GetAnalysedFilesAsync(token) + If cts.IsCancellationRequested Then + folder.FolderActionState = ActionState.Idle + Return 1 + End If + + folder.AnalysisResults = New ObservableCollection(Of AnalysedFileDetails)(retAnalysisResults)''', + ''' Dim retAnalysisResults = Await folder.Analyser.GetAnalysedFilesAsync(token) + If cts.IsCancellationRequested Then + folder.FolderActionState = ActionState.Idle + Return 1 + End If + + If retAnalysisResults Is Nothing Then + If folder.AnalysisResults IsNot Nothing AndAlso folder.AnalysisResults.Count > 0 Then + folder.FolderActionState = ActionState.Results + Else + folder.FolderActionState = ActionState.Idle + End If + Return -2 + End If + + folder.AnalysisResults = New ObservableCollection(Of AnalysedFileDetails)(retAnalysisResults)''') + + replace( + 'CompactGUI/ViewModels/HomeViewModel.vb', + ''' Dim runResult = Await _compressableFolderService.CompressFolder(folder) + Await _compressableFolderService.AnalyseFolderAsync(folder) + + If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then + Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) + End If + + Dim watcherTarget = previousWatcherTarget + If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then + watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) + End If + + watcherTargetOverrides(folder) = watcherTarget + Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) + Return True''', + ''' Dim runResult = Await _compressableFolderService.CompressFolder(folder) + If runResult.HadWork Then + Await _compressableFolderService.AnalyseFolderAsync(folder) + End If + + If runResult.Completed AndAlso runResult.HadWork AndAlso _settingsService.AppSettings.ShowNotifications Then + Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) + End If + + Dim watcherTarget = previousWatcherTarget + If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then + watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) + End If + + watcherTargetOverrides(folder) = watcherTarget + If runResult.HadWork Then + Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) + End If + Return True''') + + replace( + 'CompactGUI/ViewModels/HomeViewModel.vb', + ''' Await _watcher.EnableBackgrounding() + If capturedException IsNot Nothing Then + ExceptionDispatchInfo.Capture(capturedException).Throw() + End If''', + ''' Await _watcher.EnableBackgrounding() + If capturedException IsNot Nothing Then + _logger.LogError(capturedException, "Manual compression completion failed") + End If''') + + replace( + 'CompactGUI.Watcher/Watcher.vb', + ''' Else + UpdateFolderProperties(existingItem, item) + End If''', + ''' Else + UpdateFolderProperties(existingItem, item) + If Not Object.ReferenceEquals(existingItem, item) Then item.Dispose() + End If''') + PY + + git fetch origin master --depth=1 + git checkout origin/master -- .github/workflows/build-release.yml + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Handle already-compressed folders safely" + git push origin "HEAD:$HEAD_REF" From 4ee7f9450b7529235b72634447d1caeaea52f683 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:58:59 -0400 Subject: [PATCH 24/39] Run staged zero-work fix on branch pushes --- .github/workflows/build-release.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 17fe3d71..5a5328a9 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -4,25 +4,28 @@ on: pull_request: branches: - master + push: + branches: + - agent/compression-stop-options permissions: contents: write jobs: apply-fix: - if: github.head_ref == 'agent/compression-stop-options' && github.event.pull_request.head.repo.full_name == github.repository + if: (github.event_name == 'push' && github.ref_name == 'agent/compression-stop-options') || (github.event_name == 'pull_request' && github.head_ref == 'agent/compression-stop-options' && github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest steps: - name: Check out feature branch uses: actions/checkout@v7 with: - ref: ${{ github.head_ref }} + ref: ${{ github.head_ref || github.ref_name }} fetch-depth: 0 - name: Patch no-work completion and watcher cleanup shell: bash env: - HEAD_REF: ${{ github.head_ref }} + HEAD_REF: ${{ github.head_ref || github.ref_name }} run: | set -euo pipefail python - <<'PY' From ba680478751276755020e42c5b742c993c5deffd Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:00:26 -0400 Subject: [PATCH 25/39] Handle empty compression worklists safely --- CompactGUI.Core/Compactor.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/CompactGUI.Core/Compactor.cs b/CompactGUI.Core/Compactor.cs index b32e74ef..8f07d2e1 100644 --- a/CompactGUI.Core/Compactor.cs +++ b/CompactGUI.Core/Compactor.cs @@ -1,5 +1,4 @@ - -using CompactGUI.Logging.Core; +using CompactGUI.Logging.Core; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Win32.SafeHandles; @@ -59,12 +58,19 @@ public async Task RunAsync(List? filesList, IProgress f.UncompressedSize); totalProcessedBytes = 0; processedFiles.Clear(); + if (workingFiles.Count == 0) + { + progressMonitor?.Report(new CompressionProgress(100, string.Empty)); + CompactorLog.CompressionCompleted(_logger, 0); + return false; + } + var sw = Stopwatch.StartNew(); if (maxParallelism <= 0) maxParallelism = Environment.ProcessorCount; @@ -110,7 +116,10 @@ private async Task PauseAndProcessFile(FileDetails file, long totalFilesSize, Ca processedFiles.TryAdd(file.FileName, file.OriginalCompressionMode); } Interlocked.Add(ref totalProcessedBytes, file.UncompressedSize); - progressMonitor?.Report(new CompressionProgress((int)((double)totalProcessedBytes / totalFilesSize * 100.0), file.FileName)); + var progressPercent = totalFilesSize <= 0 + ? 100 + : (int)((double)totalProcessedBytes / totalFilesSize * 100.0); + progressMonitor?.Report(new CompressionProgress(progressPercent, file.FileName)); } From c793ed647a72ab8d1bbb2632a963d0cf6d0139fc Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:00:55 -0400 Subject: [PATCH 26/39] Restore normal build workflow --- .github/workflows/build-release.yml | 360 +++++++++++++--------------- 1 file changed, 172 insertions(+), 188 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 5a5328a9..7f64645e 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -1,4 +1,4 @@ -name: Apply zero-work compression fix +name: Build and Release on: pull_request: @@ -6,203 +6,187 @@ on: - master push: branches: - - agent/compression-stop-options + - master + tags: + - "v*" + workflow_dispatch: + inputs: + release_tag: + description: "Optional tag to create a release from the selected ref (for example: v4.0.0-beta.7-marc.1)" + required: false + type: string + prerelease: + description: "Mark a manually created release as a prerelease" + required: false + default: true + type: boolean permissions: - contents: write + contents: read + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + PROJECT_PATH: CompactGUI/CompactGUI.vbproj + RUNTIME_IDENTIFIER: win-x64 jobs: - apply-fix: - if: (github.event_name == 'push' && github.ref_name == 'agent/compression-stop-options') || (github.event_name == 'pull_request' && github.head_ref == 'agent/compression-stop-options' && github.event.pull_request.head.repo.full_name == github.repository) + build: + name: Build Windows x64 + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 9.0.x + + - name: Restore + run: dotnet restore $env:PROJECT_PATH + + - name: Publish self-contained executable + shell: pwsh + run: | + $publishDir = Join-Path $env:RUNNER_TEMP "compactgui-publish" + Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue + + dotnet publish $env:PROJECT_PATH ` + --configuration Release ` + --runtime $env:RUNTIME_IDENTIFIER ` + --self-contained true ` + --no-restore ` + --output $publishDir ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:DebugType=None ` + -p:DebugSymbols=false ` + -p:TreatWarningsAsErrors=true + + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + $exe = Join-Path $publishDir "CompactGUI.exe" + if (-not (Test-Path $exe)) { + throw "Publish completed without producing CompactGUI.exe" + } + + - name: Package artifacts + shell: pwsh + run: | + $publishDir = Join-Path $env:RUNNER_TEMP "compactgui-publish" + $packageDir = Join-Path $env:RUNNER_TEMP "compactgui-package" + $artifactDir = Join-Path $env:RUNNER_TEMP "compactgui-artifacts" + + Remove-Item $packageDir, $artifactDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item $packageDir, $artifactDir -ItemType Directory -Force | Out-Null + + Copy-Item (Join-Path $publishDir "CompactGUI.exe") $packageDir + Copy-Item "LICENSE" $packageDir + + @( + "Repository: $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY" + "Commit: $env:GITHUB_SHA" + "Ref: $env:GITHUB_REF" + "Runtime: $env:RUNTIME_IDENTIFIER" + ) | Set-Content (Join-Path $packageDir "BUILD-INFO.txt") -Encoding utf8 + + $zipPath = Join-Path $artifactDir "CompactGUI-win-x64.zip" + Compress-Archive -Path (Join-Path $packageDir "*") -DestinationPath $zipPath -CompressionLevel Optimal + Copy-Item (Join-Path $publishDir "CompactGUI.exe") (Join-Path $artifactDir "CompactGUI.exe") + + Get-ChildItem $artifactDir -File | + Sort-Object Name | + ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash *$($_.Name)" + } | Set-Content (Join-Path $artifactDir "SHA256SUMS.txt") -Encoding ascii + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: CompactGUI-win-x64 + path: ${{ runner.temp }}/compactgui-artifacts/* + if-no-files-found: error + retention-days: 14 + + release: + name: Publish GitHub release + needs: build + if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') runs-on: ubuntu-latest + permissions: + contents: write + steps: - - name: Check out feature branch + - name: Check out repository uses: actions/checkout@v7 with: - ref: ${{ github.head_ref || github.ref_name }} fetch-depth: 0 - - name: Patch no-work completion and watcher cleanup + - name: Download release assets + uses: actions/download-artifact@v8 + with: + name: CompactGUI-win-x64 + path: release-assets + + - name: Resolve release tag + id: release + shell: bash + env: + MANUAL_TAG: ${{ inputs.release_tag }} + MANUAL_PRERELEASE: ${{ inputs.prerelease }} + run: | + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + tag="${GITHUB_REF#refs/tags/}" + else + tag="$MANUAL_TAG" + fi + + if [[ -z "$tag" || "$tag" != v* ]]; then + echo "Release tags must start with v (for example: v4.0.0-beta.7-marc.1)." >&2 + exit 1 + fi + + prerelease=false + if [[ "$tag" == *-* ]] || [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$MANUAL_PRERELEASE" == "true" ]]; then + prerelease=true + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + + - name: Create or update release shell: bash env: - HEAD_REF: ${{ github.head_ref || github.ref_name }} + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + PRERELEASE: ${{ steps.release.outputs.prerelease }} run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - def replace(path: str, old: str, new: str) -> None: - p = Path(path) - raw = p.read_bytes() - bom = raw.startswith(b'\xef\xbb\xbf') - text = raw.decode('utf-8-sig') - if old not in text: - raise RuntimeError(f'Expected block not found in {path}') - text = text.replace(old, new, 1) - p.write_bytes((b'\xef\xbb\xbf' if bom else b'') + text.encode('utf-8')) - - replace( - 'CompactGUI.Core/Compactor.cs', - ''' public IReadOnlyDictionary ProcessedFiles => processedFiles; - - public Compactor''', - ''' public IReadOnlyDictionary ProcessedFiles => processedFiles; - public int WorkItemCount { get; private set; } - - public Compactor''') - - replace( - 'CompactGUI.Core/Compactor.cs', - ''' var workingFiles = await BuildWorkingFilesList(filesList).ConfigureAwait(false); - long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize); - - totalProcessedBytes = 0; - processedFiles.Clear(); - - var sw = Stopwatch.StartNew();''', - ''' var workingFiles = (await BuildWorkingFilesList(filesList).ConfigureAwait(false)).ToList(); - WorkItemCount = workingFiles.Count; - long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize); - - totalProcessedBytes = 0; - processedFiles.Clear(); - - if (WorkItemCount == 0) - { - progressMonitor?.Report(new CompressionProgress(100, string.Empty)); - CompactorLog.CompressionCompleted(_logger, 0); - return true; - } - - var sw = Stopwatch.StartNew();''') - - replace( - 'CompactGUI.Core/Compactor.cs', - ''' progressMonitor?.Report(new CompressionProgress((int)((double)totalProcessedBytes / totalFilesSize * 100.0), file.FileName));''', - ''' var progressPercent = totalFilesSize <= 0 - ? 100 - : (int)((double)totalProcessedBytes / totalFilesSize * 100.0); - progressMonitor?.Report(new CompressionProgress(progressPercent, file.FileName));''') - - replace( - 'CompactGUI/Models/CompressionStopModels.vb', - '''Public NotInheritable Class CompressionRunResult - Public Property Completed As Boolean - Public Property StopChoice As CompressionStopChoice? -End Class''', - '''Public NotInheritable Class CompressionRunResult - Public Property Completed As Boolean - Public Property HadWork As Boolean - Public Property StopChoice As CompressionStopChoice? -End Class''') - - replace( - 'CompactGUI/Services/CompressableFolderService.vb', - ''' runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) - - If isCompressing Then''', - ''' runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) - runResult.HadWork = Not isCompressing OrElse Not TypeOf compressor Is Compactor OrElse DirectCast(compressor, Compactor).WorkItemCount > 0 - - If isCompressing Then''') - - replace( - 'CompactGUI/Services/CompressableFolderService.vb', - ''' folder.FolderActionState = ActionState.Results - folder.IsFreshlyCompressed = runResult.Completed''', - ''' folder.FolderActionState = ActionState.Results - folder.IsFreshlyCompressed = runResult.Completed AndAlso runResult.HadWork''') - - replace( - 'CompactGUI/Services/CompressableFolderService.vb', - ''' Dim retAnalysisResults = Await folder.Analyser.GetAnalysedFilesAsync(token) - If cts.IsCancellationRequested Then - folder.FolderActionState = ActionState.Idle - Return 1 - End If - - folder.AnalysisResults = New ObservableCollection(Of AnalysedFileDetails)(retAnalysisResults)''', - ''' Dim retAnalysisResults = Await folder.Analyser.GetAnalysedFilesAsync(token) - If cts.IsCancellationRequested Then - folder.FolderActionState = ActionState.Idle - Return 1 - End If - - If retAnalysisResults Is Nothing Then - If folder.AnalysisResults IsNot Nothing AndAlso folder.AnalysisResults.Count > 0 Then - folder.FolderActionState = ActionState.Results - Else - folder.FolderActionState = ActionState.Idle - End If - Return -2 - End If - - folder.AnalysisResults = New ObservableCollection(Of AnalysedFileDetails)(retAnalysisResults)''') - - replace( - 'CompactGUI/ViewModels/HomeViewModel.vb', - ''' Dim runResult = Await _compressableFolderService.CompressFolder(folder) - Await _compressableFolderService.AnalyseFolderAsync(folder) - - If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then - Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) - End If - - Dim watcherTarget = previousWatcherTarget - If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then - watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) - End If - - watcherTargetOverrides(folder) = watcherTarget - Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) - Return True''', - ''' Dim runResult = Await _compressableFolderService.CompressFolder(folder) - If runResult.HadWork Then - Await _compressableFolderService.AnalyseFolderAsync(folder) - End If - - If runResult.Completed AndAlso runResult.HadWork AndAlso _settingsService.AppSettings.ShowNotifications Then - Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) - End If - - Dim watcherTarget = previousWatcherTarget - If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then - watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) - End If - - watcherTargetOverrides(folder) = watcherTarget - If runResult.HadWork Then - Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) - End If - Return True''') - - replace( - 'CompactGUI/ViewModels/HomeViewModel.vb', - ''' Await _watcher.EnableBackgrounding() - If capturedException IsNot Nothing Then - ExceptionDispatchInfo.Capture(capturedException).Throw() - End If''', - ''' Await _watcher.EnableBackgrounding() - If capturedException IsNot Nothing Then - _logger.LogError(capturedException, "Manual compression completion failed") - End If''') - - replace( - 'CompactGUI.Watcher/Watcher.vb', - ''' Else - UpdateFolderProperties(existingItem, item) - End If''', - ''' Else - UpdateFolderProperties(existingItem, item) - If Not Object.ReferenceEquals(existingItem, item) Then item.Dispose() - End If''') - PY - - git fetch origin master --depth=1 - git checkout origin/master -- .github/workflows/build-release.yml - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Handle already-compressed folders safely" - git push origin "HEAD:$HEAD_REF" + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" + exit 0 + fi + + release_args=( + release create "$TAG" + release-assets/* + --repo "$GITHUB_REPOSITORY" + --title "CompactGUI $TAG" + --generate-notes + ) + + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + release_args+=(--verify-tag) + else + release_args+=(--target "$GITHUB_SHA") + fi + + if [[ "$PRERELEASE" == "true" ]]; then + release_args+=(--prerelease) + fi + + gh "${release_args[@]}" \ No newline at end of file From c8a2970ca3f35cd33c53684685c65476ea49c918 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:10:37 -0400 Subject: [PATCH 27/39] Prepare zero-work crash fix sources --- .../workflows/materialize-zero-work-fix.yml | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 .github/workflows/materialize-zero-work-fix.yml diff --git a/.github/workflows/materialize-zero-work-fix.yml b/.github/workflows/materialize-zero-work-fix.yml new file mode 100644 index 00000000..910b39d8 --- /dev/null +++ b/.github/workflows/materialize-zero-work-fix.yml @@ -0,0 +1,158 @@ +name: Materialize zero-work crash fix + +on: + push: + branches: + - agent/compression-stop-options + +permissions: + contents: read + +jobs: + materialize: + runs-on: ubuntu-latest + steps: + - name: Check out feature branch + uses: actions/checkout@v7 + + - name: Apply zero-work fix in workspace + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + def replace(path: str, old: str, new: str) -> None: + p = Path(path) + raw = p.read_bytes() + bom = raw.startswith(b'\xef\xbb\xbf') + text = raw.decode('utf-8-sig') + if old not in text: + raise RuntimeError(f'Expected block not found in {path}') + text = text.replace(old, new, 1) + p.write_bytes((b'\xef\xbb\xbf' if bom else b'') + text.encode('utf-8')) + + replace( + 'CompactGUI.Core/Compactor.cs', + ''' public IReadOnlyDictionary ProcessedFiles => processedFiles; + + public Compactor''', + ''' public IReadOnlyDictionary ProcessedFiles => processedFiles; + public int WorkItemCount { get; private set; } + + public Compactor''') + + replace( + 'CompactGUI.Core/Compactor.cs', + ''' var workingFiles = (await BuildWorkingFilesList(filesList).ConfigureAwait(false)).ToList(); + long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize);''', + ''' var workingFiles = (await BuildWorkingFilesList(filesList).ConfigureAwait(false)).ToList(); + WorkItemCount = workingFiles.Count; + long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize);''') + + replace( + 'CompactGUI/Models/CompressionStopModels.vb', + '''Public NotInheritable Class CompressionRunResult + Public Property Completed As Boolean + Public Property StopChoice As CompressionStopChoice? +End Class''', + '''Public NotInheritable Class CompressionRunResult + Public Property Completed As Boolean + Public Property HadWork As Boolean + Public Property StopChoice As CompressionStopChoice? +End Class''') + + replace( + 'CompactGUI/Services/CompressableFolderService.vb', + ''' runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) + + If isCompressing Then''', + ''' runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) + runResult.HadWork = Not isCompressing OrElse Not TypeOf compressor Is Compactor OrElse DirectCast(compressor, Compactor).WorkItemCount > 0 + + If isCompressing Then''') + + replace( + 'CompactGUI/Services/CompressableFolderService.vb', + ''' folder.FolderActionState = ActionState.Results + folder.IsFreshlyCompressed = runResult.Completed''', + ''' folder.FolderActionState = ActionState.Results + folder.IsFreshlyCompressed = runResult.Completed AndAlso runResult.HadWork''') + + replace( + 'CompactGUI/ViewModels/HomeViewModel.vb', + ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() + Dim capturedException As Exception = Nothing''', + ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() + Dim foldersWithCompressionWork As New HashSet(Of CompressableFolder)() + Dim capturedException As Exception = Nothing''') + + replace( + 'CompactGUI/ViewModels/HomeViewModel.vb', + ''' Dim runResult = Await _compressableFolderService.CompressFolder(folder) + Await _compressableFolderService.AnalyseFolderAsync(folder) + + If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then + Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) + End If + + Dim watcherTarget = previousWatcherTarget + If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then + watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) + End If + + watcherTargetOverrides(folder) = watcherTarget + Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) + Return True''', + ''' Dim runResult = Await _compressableFolderService.CompressFolder(folder) + If Not runResult.HadWork Then Return True + + foldersWithCompressionWork.Add(folder) + Await _compressableFolderService.AnalyseFolderAsync(folder) + + If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then + Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) + End If + + Dim watcherTarget = previousWatcherTarget + If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then + watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) + End If + + watcherTargetOverrides(folder) = watcherTarget + Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) + Return True''') + + replace( + 'CompactGUI/ViewModels/HomeViewModel.vb', + ''' For Each folder In Folders.Where(Function(item) item.CompressionOptions.WatchFolderForChanges) + Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed''', + ''' For Each folder In Folders.Where(Function(item) item.CompressionOptions.WatchFolderForChanges) + Dim isAlreadyWatched = _watcher.WatchedFolders.Any(Function(watched) String.Equals(watched.Folder, folder.FolderName, StringComparison.OrdinalIgnoreCase)) + If isAlreadyWatched AndAlso Not foldersWithCompressionWork.Contains(folder) Then Continue For + + Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed''') + + replace( + 'CompactGUI.Watcher/Watcher.vb', + ''' Else + UpdateFolderProperties(existingItem, item) + End If''', + ''' Else + UpdateFolderProperties(existingItem, item) + If Not Object.ReferenceEquals(existingItem, item) Then item.Dispose() + End If''') + PY + + - name: Upload patched source files + uses: actions/upload-artifact@v7 + with: + name: zero-work-crash-fix-sources + path: | + CompactGUI.Core/Compactor.cs + CompactGUI.Watcher/Watcher.vb + CompactGUI/Models/CompressionStopModels.vb + CompactGUI/Services/CompressableFolderService.vb + CompactGUI/ViewModels/HomeViewModel.vb + if-no-files-found: error + retention-days: 1 From 2162716f0e8311f6d13c4112e019006da422d4a0 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:12:02 -0400 Subject: [PATCH 28/39] Track whether compression had eligible work --- CompactGUI/Models/CompressionStopModels.vb | 1 + 1 file changed, 1 insertion(+) diff --git a/CompactGUI/Models/CompressionStopModels.vb b/CompactGUI/Models/CompressionStopModels.vb index 69af38df..12263b37 100644 --- a/CompactGUI/Models/CompressionStopModels.vb +++ b/CompactGUI/Models/CompressionStopModels.vb @@ -14,6 +14,7 @@ End Enum Public NotInheritable Class CompressionRunResult Public Property Completed As Boolean + Public Property HadWork As Boolean Public Property StopChoice As CompressionStopChoice? End Class From 81be6450042b48a8885ed257680b899abd7b3251 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:12:36 -0400 Subject: [PATCH 29/39] Expose compression work count --- CompactGUI.Core/Compactor.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CompactGUI.Core/Compactor.cs b/CompactGUI.Core/Compactor.cs index 8f07d2e1..67fe55e0 100644 --- a/CompactGUI.Core/Compactor.cs +++ b/CompactGUI.Core/Compactor.cs @@ -31,6 +31,7 @@ public sealed class Compactor : ICompressor, IDisposable private Analyser _analyser; public IReadOnlyDictionary ProcessedFiles => processedFiles; + public int WorkItemCount { get; private set; } public Compactor(string folderPath, WOFCompressionAlgorithm compressionLevel, string[] excludedFileTypes, Analyser analyser, ILogger? logger = null) { @@ -59,6 +60,7 @@ public async Task RunAsync(List? filesList, IProgress f.UncompressedSize); totalProcessedBytes = 0; @@ -202,4 +204,4 @@ public void Dispose() public readonly record struct FileDetails(string FileName, long UncompressedSize, WOFCompressionAlgorithm OriginalCompressionMode); -} \ No newline at end of file +} From af08c0795929ec2ed2b6db5448650c83534c9411 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:13:29 -0400 Subject: [PATCH 30/39] Propagate zero-work compression state --- CompactGUI/Services/CompressableFolderService.vb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CompactGUI/Services/CompressableFolderService.vb b/CompactGUI/Services/CompressableFolderService.vb index 5d487c3b..eca472ce 100644 --- a/CompactGUI/Services/CompressableFolderService.vb +++ b/CompactGUI/Services/CompressableFolderService.vb @@ -74,6 +74,7 @@ Public Class CompressableFolderService Try runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) + runResult.HadWork = Not isCompressing OrElse Not TypeOf compressor Is Compactor OrElse DirectCast(compressor, Compactor).WorkItemCount > 0 If isCompressing Then Dim stopChoice As CompressionStopChoice @@ -90,7 +91,7 @@ Public Class CompressableFolderService End If folder.FolderActionState = ActionState.Results - folder.IsFreshlyCompressed = runResult.Completed + folder.IsFreshlyCompressed = runResult.Completed AndAlso runResult.HadWork Else folder.FolderActionState = ActionState.Idle folder.IsFreshlyCompressed = False From 80f3a66994f9b9434220e897b8d5bb6432a436de Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:14:42 -0400 Subject: [PATCH 31/39] Bypass completion work for empty compression runs --- CompactGUI/ViewModels/HomeViewModel.vb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CompactGUI/ViewModels/HomeViewModel.vb b/CompactGUI/ViewModels/HomeViewModel.vb index dda03a85..89944537 100644 --- a/CompactGUI/ViewModels/HomeViewModel.vb +++ b/CompactGUI/ViewModels/HomeViewModel.vb @@ -258,6 +258,7 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient HomeViewModelLog.StartingBatchCompression(_logger, foldersToCompress.Count) Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() + Dim foldersWithCompressionWork As New HashSet(Of CompressableFolder)() Dim capturedException As Exception = Nothing Try @@ -270,6 +271,9 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Await Task.Run(Async Function() HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) Dim runResult = Await _compressableFolderService.CompressFolder(folder) + If Not runResult.HadWork Then Return True + + foldersWithCompressionWork.Add(folder) Await _compressableFolderService.AnalyseFolderAsync(folder) If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then @@ -288,6 +292,9 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Next For Each folder In Folders.Where(Function(item) item.CompressionOptions.WatchFolderForChanges) + Dim isAlreadyWatched = _watcher.WatchedFolders.Any(Function(watched) String.Equals(watched.Folder, folder.FolderName, StringComparison.OrdinalIgnoreCase)) + If isAlreadyWatched AndAlso Not foldersWithCompressionWork.Contains(folder) Then Continue For + Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed Dim targetOverride = Core.WOFCompressionAlgorithm.NO_COMPRESSION watcherTargetOverrides.TryGetValue(folder, targetOverride) From 421bf1f44c93de86441af0b6bf33d77b03111733 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:14:53 -0400 Subject: [PATCH 32/39] Remove temporary zero-work materialization workflow --- .../workflows/materialize-zero-work-fix.yml | 158 ------------------ 1 file changed, 158 deletions(-) delete mode 100644 .github/workflows/materialize-zero-work-fix.yml diff --git a/.github/workflows/materialize-zero-work-fix.yml b/.github/workflows/materialize-zero-work-fix.yml deleted file mode 100644 index 910b39d8..00000000 --- a/.github/workflows/materialize-zero-work-fix.yml +++ /dev/null @@ -1,158 +0,0 @@ -name: Materialize zero-work crash fix - -on: - push: - branches: - - agent/compression-stop-options - -permissions: - contents: read - -jobs: - materialize: - runs-on: ubuntu-latest - steps: - - name: Check out feature branch - uses: actions/checkout@v7 - - - name: Apply zero-work fix in workspace - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - def replace(path: str, old: str, new: str) -> None: - p = Path(path) - raw = p.read_bytes() - bom = raw.startswith(b'\xef\xbb\xbf') - text = raw.decode('utf-8-sig') - if old not in text: - raise RuntimeError(f'Expected block not found in {path}') - text = text.replace(old, new, 1) - p.write_bytes((b'\xef\xbb\xbf' if bom else b'') + text.encode('utf-8')) - - replace( - 'CompactGUI.Core/Compactor.cs', - ''' public IReadOnlyDictionary ProcessedFiles => processedFiles; - - public Compactor''', - ''' public IReadOnlyDictionary ProcessedFiles => processedFiles; - public int WorkItemCount { get; private set; } - - public Compactor''') - - replace( - 'CompactGUI.Core/Compactor.cs', - ''' var workingFiles = (await BuildWorkingFilesList(filesList).ConfigureAwait(false)).ToList(); - long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize);''', - ''' var workingFiles = (await BuildWorkingFilesList(filesList).ConfigureAwait(false)).ToList(); - WorkItemCount = workingFiles.Count; - long totalFilesSize = workingFiles.Sum((f) => f.UncompressedSize);''') - - replace( - 'CompactGUI/Models/CompressionStopModels.vb', - '''Public NotInheritable Class CompressionRunResult - Public Property Completed As Boolean - Public Property StopChoice As CompressionStopChoice? -End Class''', - '''Public NotInheritable Class CompressionRunResult - Public Property Completed As Boolean - Public Property HadWork As Boolean - Public Property StopChoice As CompressionStopChoice? -End Class''') - - replace( - 'CompactGUI/Services/CompressableFolderService.vb', - ''' runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) - - If isCompressing Then''', - ''' runResult.Completed = Await compressor.RunAsync(filesList, progress, GetThreadCount(folder)) - runResult.HadWork = Not isCompressing OrElse Not TypeOf compressor Is Compactor OrElse DirectCast(compressor, Compactor).WorkItemCount > 0 - - If isCompressing Then''') - - replace( - 'CompactGUI/Services/CompressableFolderService.vb', - ''' folder.FolderActionState = ActionState.Results - folder.IsFreshlyCompressed = runResult.Completed''', - ''' folder.FolderActionState = ActionState.Results - folder.IsFreshlyCompressed = runResult.Completed AndAlso runResult.HadWork''') - - replace( - 'CompactGUI/ViewModels/HomeViewModel.vb', - ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() - Dim capturedException As Exception = Nothing''', - ''' Dim watcherTargetOverrides As New Dictionary(Of CompressableFolder, Core.WOFCompressionAlgorithm)() - Dim foldersWithCompressionWork As New HashSet(Of CompressableFolder)() - Dim capturedException As Exception = Nothing''') - - replace( - 'CompactGUI/ViewModels/HomeViewModel.vb', - ''' Dim runResult = Await _compressableFolderService.CompressFolder(folder) - Await _compressableFolderService.AnalyseFolderAsync(folder) - - If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then - Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) - End If - - Dim watcherTarget = previousWatcherTarget - If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then - watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) - End If - - watcherTargetOverrides(folder) = watcherTarget - Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) - Return True''', - ''' Dim runResult = Await _compressableFolderService.CompressFolder(folder) - If Not runResult.HadWork Then Return True - - foldersWithCompressionWork.Add(folder) - Await _compressableFolderService.AnalyseFolderAsync(folder) - - If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then - Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) - End If - - Dim watcherTarget = previousWatcherTarget - If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then - watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) - End If - - watcherTargetOverrides(folder) = watcherTarget - Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) - Return True''') - - replace( - 'CompactGUI/ViewModels/HomeViewModel.vb', - ''' For Each folder In Folders.Where(Function(item) item.CompressionOptions.WatchFolderForChanges) - Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed''', - ''' For Each folder In Folders.Where(Function(item) item.CompressionOptions.WatchFolderForChanges) - Dim isAlreadyWatched = _watcher.WatchedFolders.Any(Function(watched) String.Equals(watched.Folder, folder.FolderName, StringComparison.OrdinalIgnoreCase)) - If isAlreadyWatched AndAlso Not foldersWithCompressionWork.Contains(folder) Then Continue For - - Dim wasCompressedInCurrentBatch = foldersToCompress.Contains(folder) AndAlso folder.IsFreshlyCompressed''') - - replace( - 'CompactGUI.Watcher/Watcher.vb', - ''' Else - UpdateFolderProperties(existingItem, item) - End If''', - ''' Else - UpdateFolderProperties(existingItem, item) - If Not Object.ReferenceEquals(existingItem, item) Then item.Dispose() - End If''') - PY - - - name: Upload patched source files - uses: actions/upload-artifact@v7 - with: - name: zero-work-crash-fix-sources - path: | - CompactGUI.Core/Compactor.cs - CompactGUI.Watcher/Watcher.vb - CompactGUI/Models/CompressionStopModels.vb - CompactGUI/Services/CompressableFolderService.vb - CompactGUI/ViewModels/HomeViewModel.vb - if-no-files-found: error - retention-days: 1 From 2ccde330c2df4c0b862004545f375157defb1bd0 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:23:25 -0400 Subject: [PATCH 33/39] Marshal folder property updates to UI thread --- CompactGUI/ViewModels/FolderViewModel.vb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CompactGUI/ViewModels/FolderViewModel.vb b/CompactGUI/ViewModels/FolderViewModel.vb index f48e86fa..46caa8a5 100644 --- a/CompactGUI/ViewModels/FolderViewModel.vb +++ b/CompactGUI/ViewModels/FolderViewModel.vb @@ -125,6 +125,12 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem End Sub Private Sub OnFolderPropertyChanged(sender As Object, e As PropertyChangedEventArgs) + Dim dispatcher = Application.Current?.Dispatcher + If dispatcher IsNot Nothing AndAlso Not dispatcher.CheckAccess() Then + dispatcher.BeginInvoke(Sub() OnFolderPropertyChanged(sender, e)) + Return + End If + If e.PropertyName = NameOf(Folder.FolderActionState) Then OnPropertyChanged(NameOf(IsAnalysing)) OnPropertyChanged(NameOf(IsNotResultsOrAnalysing)) @@ -244,4 +250,4 @@ Public NotInheritable Class FolderViewModel : Inherits ObservableObject : Implem -End Class +End Class \ No newline at end of file From 36bfb2ca316f3489b4c4cc87aa9a747a511e4bfd Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:27:19 -0400 Subject: [PATCH 34/39] Keep compression completion on UI thread --- CompactGUI/ViewModels/HomeViewModel.vb | 34 ++++++++++++-------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/CompactGUI/ViewModels/HomeViewModel.vb b/CompactGUI/ViewModels/HomeViewModel.vb index 89944537..03d33256 100644 --- a/CompactGUI/ViewModels/HomeViewModel.vb +++ b/CompactGUI/ViewModels/HomeViewModel.vb @@ -32,7 +32,6 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Dim value As FolderViewModel = Nothing Return If(_folderViewModels.TryGetValue(SelectedFolder, value), value, Nothing) - End Get End Property @@ -268,27 +267,24 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Dim existingWatched = _watcher.WatchedFolders.FirstOrDefault(Function(watched) watched.Folder = folder.FolderName) Dim previousWatcherTarget = If(existingWatched?.CompressionLevel, Core.WOFCompressionAlgorithm.NO_COMPRESSION) - Await Task.Run(Async Function() - HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) - Dim runResult = Await _compressableFolderService.CompressFolder(folder) - If Not runResult.HadWork Then Return True + HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) + Dim runResult = Await _compressableFolderService.CompressFolder(folder) + If Not runResult.HadWork Then Continue For - foldersWithCompressionWork.Add(folder) - Await _compressableFolderService.AnalyseFolderAsync(folder) + foldersWithCompressionWork.Add(folder) + Await _compressableFolderService.AnalyseFolderAsync(folder) - If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then - Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) - End If + If runResult.Completed AndAlso _settingsService.AppSettings.ShowNotifications Then + Application.GetService(Of TrayNotifierService)().Notify_Compressed(folder.DisplayName, folder.UncompressedBytes - folder.CompressedBytes, folder.CompressionRatio) + End If - Dim watcherTarget = previousWatcherTarget - If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then - watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) - End If + Dim watcherTarget = previousWatcherTarget + If runResult.Completed OrElse (runResult.StopChoice.HasValue AndAlso runResult.StopChoice.Value = CompressionStopChoice.SaveProgress) Then + watcherTarget = Core.WOFHelper.WOFConvertCompressionLevel(folder.CompressionOptions.SelectedCompressionMode) + End If - watcherTargetOverrides(folder) = watcherTarget - Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) - Return True - End Function) + watcherTargetOverrides(folder) = watcherTarget + Await _watcher.UpdateWatched(folder.FolderName, folder.Analyser, runResult.Completed, targetCompressionLevel:=watcherTarget) Next For Each folder In Folders.Where(Function(item) item.CompressionOptions.WatchFolderForChanges) @@ -351,4 +347,4 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Application.GetService(Of CustomSnackBarService).ShowAddedToQueue() Await AddFoldersAsync({message.Value}) End Sub -End Class +End Class \ No newline at end of file From b30be0f39617ac2a09f13175c5653230901c0f8c Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:29:48 -0400 Subject: [PATCH 35/39] Trigger final threading-fix checks --- .github/NOOP | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/NOOP diff --git a/.github/NOOP b/.github/NOOP new file mode 100644 index 00000000..9e11f268 --- /dev/null +++ b/.github/NOOP @@ -0,0 +1 @@ +This file intentionally triggers no behavior. From b422ed20d095289da98898e2b815ce65589eb771 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:30:05 -0400 Subject: [PATCH 36/39] Remove temporary check trigger --- .github/NOOP | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/NOOP diff --git a/.github/NOOP b/.github/NOOP deleted file mode 100644 index 9e11f268..00000000 --- a/.github/NOOP +++ /dev/null @@ -1 +0,0 @@ -This file intentionally triggers no behavior. From 1e03bea0952d7ecd520b6a1d60d4ac3cf99b3693 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:33:48 -0400 Subject: [PATCH 37/39] Document UI-thread compression flow --- CompactGUI/ViewModels/HomeViewModel.vb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CompactGUI/ViewModels/HomeViewModel.vb b/CompactGUI/ViewModels/HomeViewModel.vb index 03d33256..7827e8a3 100644 --- a/CompactGUI/ViewModels/HomeViewModel.vb +++ b/CompactGUI/ViewModels/HomeViewModel.vb @@ -267,6 +267,8 @@ Partial Public NotInheritable Class HomeViewModel : Inherits ObservableRecipient Dim existingWatched = _watcher.WatchedFolders.FirstOrDefault(Function(watched) watched.Folder = folder.FolderName) Dim previousWatcherTarget = If(existingWatched?.CompressionLevel, Core.WOFCompressionAlgorithm.NO_COMPRESSION) + 'Keep the orchestration on the WPF synchronization context. The compressor performs + 'its own asynchronous file work, while folder state and commands remain UI-thread-owned. HomeViewModelLog.CompressingFolder(_logger, folder.FolderName) Dim runResult = Await _compressableFolderService.CompressFolder(folder) If Not runResult.HadWork Then Continue For From 44ffeb11c49de28fec09b90fa6f9adc58ffbc9bb Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:37:24 -0400 Subject: [PATCH 38/39] Trigger threading-fix note --- .github/threading-fix-note.tmp | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/threading-fix-note.tmp diff --git a/.github/threading-fix-note.tmp b/.github/threading-fix-note.tmp new file mode 100644 index 00000000..ed041f1e --- /dev/null +++ b/.github/threading-fix-note.tmp @@ -0,0 +1 @@ +temporary From 8b2a47194d1f5f2430d846b8673b60df027fc9fa Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:37:37 -0400 Subject: [PATCH 39/39] Remove temporary threading note file --- .github/threading-fix-note.tmp | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/threading-fix-note.tmp diff --git a/.github/threading-fix-note.tmp b/.github/threading-fix-note.tmp deleted file mode 100644 index ed041f1e..00000000 --- a/.github/threading-fix-note.tmp +++ /dev/null @@ -1 +0,0 @@ -temporary