Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
08641fb
CI: export current source for stop workflow implementation
marcmy Jul 21, 2026
f9f3c7f
Stage compression stop payload part 1
marcmy Jul 21, 2026
ba8060e
Stage stop workflow patch part 1
marcmy Jul 21, 2026
457c255
Stage stop workflow patch part 2
marcmy Jul 21, 2026
c2c4acc
Stage stop workflow patch part 3
marcmy Jul 21, 2026
5d0ef3c
Stage stop workflow patch part 4
marcmy Jul 21, 2026
83ac53c
CI: apply safe compression stop workflow
marcmy Jul 21, 2026
db4886e
Add safe manual compression stop choices
github-actions[bot] Jul 21, 2026
cd49c78
Polish compression stop result models
marcmy Jul 21, 2026
086abd7
CI: capture compression stop build diagnostics
marcmy Jul 21, 2026
4e26332
CI: fix compression stop compile error
marcmy Jul 21, 2026
839629d
Fix compression stop enum and clean diagnostics
github-actions[bot] Jul 21, 2026
cf9725b
Document compression stop decisions
marcmy Jul 21, 2026
4ddb19e
CI: capture remaining compression stop diagnostics
marcmy Jul 21, 2026
9e1d66e
CI: fix compression stop generator errors
marcmy Jul 21, 2026
001559a
CI: route compression stop fixes through trusted workflow
marcmy Jul 21, 2026
8e8b560
Fix compression stop command generation
marcmy Jul 21, 2026
2ad4793
Fix compression stop async cleanup flow
marcmy Jul 21, 2026
bab9d82
Restore normal build workflow
marcmy Jul 21, 2026
4941efb
Remove temporary compression stop diagnostics
marcmy Jul 21, 2026
848ec95
Remove temporary compression stop fix workflow
marcmy Jul 21, 2026
579440a
Restore build workflow exactly
marcmy Jul 21, 2026
37a6bd4
Stage zero-work compression crash fix
marcmy Jul 22, 2026
4ee7f94
Run staged zero-work fix on branch pushes
marcmy Jul 22, 2026
ba68047
Handle empty compression worklists safely
marcmy Jul 22, 2026
c793ed6
Restore normal build workflow
marcmy Jul 22, 2026
c8a2970
Prepare zero-work crash fix sources
marcmy Jul 22, 2026
2162716
Track whether compression had eligible work
marcmy Jul 22, 2026
81be645
Expose compression work count
marcmy Jul 22, 2026
af08c07
Propagate zero-work compression state
marcmy Jul 22, 2026
80f3a66
Bypass completion work for empty compression runs
marcmy Jul 22, 2026
421bf1f
Remove temporary zero-work materialization workflow
marcmy Jul 22, 2026
2ccde33
Marshal folder property updates to UI thread
marcmy Jul 22, 2026
36bfb2c
Keep compression completion on UI thread
marcmy Jul 22, 2026
b30be0f
Trigger final threading-fix checks
marcmy Jul 22, 2026
b422ed2
Remove temporary check trigger
marcmy Jul 22, 2026
1e03bea
Document UI-thread compression flow
marcmy Jul 22, 2026
44ffeb1
Trigger threading-fix note
marcmy Jul 22, 2026
8b2a471
Remove temporary threading note file
marcmy Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 37 additions & 11 deletions CompactGUI.Core/Compactor.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,11 +24,15 @@ 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<string, WOFCompressionAlgorithm> processedFiles = new(StringComparer.OrdinalIgnoreCase);

private ILogger<Compactor> _logger;

private Analyser _analyser;

public IReadOnlyDictionary<string, WOFCompressionAlgorithm> ProcessedFiles => processedFiles;
public int WorkItemCount { get; private set; }

public Compactor(string folderPath, WOFCompressionAlgorithm compressionLevel, string[] excludedFileTypes, Analyser analyser, ILogger<Compactor>? logger = null)
{
workingDirectory = folderPath;
Expand All @@ -51,15 +54,24 @@ private void InitializeCompressionInfoPointer()
}


public async Task<bool> RunAsync(List<string> filesList, IProgress<CompressionProgress>? progressMonitor = null, int maxParallelism = 1)
public async Task<bool> RunAsync(List<string>? filesList, IProgress<CompressionProgress>? 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)).ToList();
WorkItemCount = workingFiles.Count;
long totalFilesSize = workingFiles.Sum((f) => 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();

Expand Down Expand Up @@ -101,8 +113,15 @@ 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));
var progressPercent = totalFilesSize <= 0
? 100
: (int)((double)totalProcessedBytes / totalFilesSize * 100.0);
progressMonitor?.Report(new CompressionProgress(progressPercent, file.FileName));

}

Expand All @@ -122,11 +141,19 @@ private async Task PauseAndProcessFile(FileDetails file, long totalFilesSize, Ca
}
}

public async Task<IEnumerable<FileDetails>> BuildWorkingFilesList()
public async Task<IEnumerable<FileDetails>> BuildWorkingFilesList(IReadOnlyCollection<string>? 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
Expand All @@ -135,14 +162,13 @@ public async Task<IEnumerable<FileDetails>> 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);
Expand Down Expand Up @@ -176,6 +202,6 @@ public void Dispose()
}


public readonly record struct FileDetails(string FileName, long UncompressedSize);
public readonly record struct FileDetails(string FileName, long UncompressedSize, WOFCompressionAlgorithm OriginalCompressionMode);

}
}
2 changes: 1 addition & 1 deletion CompactGUI.Core/ICompressor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

public interface ICompressor : IDisposable
{
Task<bool> RunAsync(List<String> filesList,
Task<bool> RunAsync(List<String>? filesList,
IProgress<CompressionProgress>? progressMonitor = null,
int maxParallelism = 1);

Expand Down
3 changes: 2 additions & 1 deletion CompactGUI.Core/Uncompactor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ public Uncompactor(ILogger<Uncompactor>? logger = null)
_logger = logger ?? NullLogger<Uncompactor>.Instance;
}

public async Task<bool> RunAsync(List<string> filesList, IProgress<CompressionProgress>? progressMonitor = null, int maxParallelism = 1)
public async Task<bool> RunAsync(List<string>? filesList, IProgress<CompressionProgress>? progressMonitor = null, int maxParallelism = 1)
{
filesList ??= [];
int totalFiles = filesList.Count;
if (maxParallelism <= 0) maxParallelism = Environment.ProcessorCount;
ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = maxParallelism };
Expand Down
4 changes: 2 additions & 2 deletions CompactGUI.Watcher/Watcher.vb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Imports System.Collections.ObjectModel
Imports System.Collections.ObjectModel
Imports System.Collections.Specialized
Imports System.Runtime
Imports System.Text.Json
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions CompactGUI/Application.xaml.vb
Original file line number Diff line number Diff line change
Expand Up @@ -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)()

Expand Down
6 changes: 4 additions & 2 deletions CompactGUI/Components/Converters/IValueConverters.vb
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CompactGUI/Components/FolderActionStateTemplateSelector.vb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,5 @@ Public Enum ActionState
Results
Paused
Waiting
Undoing
End Enum
28 changes: 28 additions & 0 deletions CompactGUI/Models/CompressionStopModels.vb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
' 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
Cancel
End Enum

Public NotInheritable Class CompressionRunResult
Public Property Completed As Boolean
Public Property HadWork 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
109 changes: 89 additions & 20 deletions CompactGUI/Services/CompressableFolderService.vb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Imports System.Collections.Concurrent
Imports System.Collections.ObjectModel
Imports System.Threading

Expand All @@ -15,56 +16,124 @@ 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))
runResult.HadWork = Not isCompressing OrElse Not TypeOf compressor Is Compactor OrElse DirectCast(compressor, Compactor).WorkItemCount > 0

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 AndAlso runResult.HadWork
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)

Expand Down
Loading