diff --git a/Compression.NativeUI/Controls/LogView.cs b/Compression.NativeUI/Controls/LogView.cs new file mode 100644 index 000000000..276b3ceb6 --- /dev/null +++ b/Compression.NativeUI/Controls/LogView.cs @@ -0,0 +1,95 @@ +using System.Drawing; +using Hawkynt.NativeForms; +using Hawkynt.NativeForms.Drawing; + +namespace Compression.NativeUI.Controls; + +/// +/// A read-only log that wraps every line to its width and keeps the newest line in view; the mouse +/// wheel scrolls back through older ones. +/// +/// +/// A multiline text box neither wraps nor scrolls sideways on every backend, so a long outcome line +/// ran off its right edge. Drawing the lines here wraps them the same way everywhere. +/// +internal sealed class LogView : OwnerDrawnControl { + private const int Inset = 3; + + private readonly List _lines = []; + private int _scrollBack; + + /// Every line logged since the last , unwrapped. + public string LogText => string.Join(Environment.NewLine, this._lines); + + public void Append(string line) { + this._lines.AddRange(line.Replace("\r\n", "\n").Split('\n')); + this._scrollBack = 0; + this.Invalidate(); + } + + public void Clear() { + this._lines.Clear(); + this._scrollBack = 0; + this.Invalidate(); + } + + /// + /// Breaks into rows no wider than : at spaces where + /// it can, inside a word only when the word alone is wider than the row. + /// + internal static List Wrap(string line, int width, Func measure) { + var rows = new List(); + if (line.Length == 0 || width <= 0 || measure(line) <= width) { + rows.Add(line); + return rows; + } + + var rest = line; + while (rest.Length > 0) { + if (measure(rest) <= width) { + rows.Add(rest); + break; + } + + // The longest prefix that fits, found by halving rather than one character at a time. + int lo = 1, hi = rest.Length; + while (lo < hi) { + var mid = (lo + hi + 1) / 2; + if (measure(rest[..mid]) <= width) lo = mid; + else hi = mid - 1; + } + + var cut = lo; + var space = rest.LastIndexOf(' ', Math.Min(cut, rest.Length - 1)); + if (space > 0) cut = space; + rows.Add(rest[..cut].TrimEnd()); + rest = rest[cut..].TrimStart(); + } + + return rows; + } + + protected override void OnPaint(PaintEventArgs e) { + var g = e.Graphics; + var theme = DefaultTheme.Instance; + g.FillRectangle(theme.FieldBackground, new(0, 0, this.Width, this.Height)); + g.DrawRectangle(theme.Border, new(0, 0, this.Width - 1, this.Height - 1)); + + var width = this.Width - 2 * Inset; + var rowHeight = Math.Max(1, g.MeasureText("Ag", this.Font).Height); + var rows = this._lines.SelectMany(l => Wrap(l, width, s => g.MeasureText(s, this.Font).Width)).ToList(); + var visible = Math.Max(1, (this.Height - 2 * Inset) / rowHeight); + this._scrollBack = Math.Clamp(this._scrollBack, 0, Math.Max(0, rows.Count - visible)); + + var first = Math.Max(0, rows.Count - visible - this._scrollBack); + var y = Inset; + for (var i = first; i < rows.Count && y + rowHeight <= this.Height - Inset + 1; ++i, y += rowHeight) + g.DrawText(rows[i], this.Font, theme.ControlText, new(Inset, y, width, rowHeight), ContentAlignment.MiddleLeft); + } + + protected override void OnMouseWheel(MouseEventArgs e) { + this._scrollBack += e.Delta > 0 ? 3 : -3; + if (this._scrollBack < 0) this._scrollBack = 0; + this.Invalidate(); + } +} diff --git a/Compression.NativeUI/Maintenance/BlockMapSnapshot.cs b/Compression.NativeUI/Maintenance/BlockMapSnapshot.cs new file mode 100644 index 000000000..9089c1367 --- /dev/null +++ b/Compression.NativeUI/Maintenance/BlockMapSnapshot.cs @@ -0,0 +1,432 @@ +using Compression.Registry; + +namespace Compression.NativeUI.Maintenance; + +/// +/// One row of the files panel: name, size, fragment count, method, modification date and thermal +/// class. The fragment count comes from whatever layout the format offered; formats that offer none +/// show an em dash rather than a figure nothing measured. +/// +internal sealed class FileRow { + public string Name { get; init; } = ""; + public long Size { get; init; } + public string SizeDisplay { get; init; } = ""; + public string FragmentsDisplay { get; init; } = ""; + public string MethodDisplay { get; init; } = ""; + public DateTime? Modified { get; init; } + public string ModifiedDisplay { get; init; } = ""; + public string Class { get; init; } = ""; +} + +/// +/// What the block view shows of an image at rest: its extents, its size, the files panel rows and a +/// line saying how honest the picture is. +/// +internal sealed record BlockMapSnapshot(IReadOnlyList? Map, long ImageSize, IReadOnlyList Rows, string Status) { + /// Cap on extents fed to the block map; adjacent same-kind regions merge beyond it. + internal const int MaxExtents = 50_000; + + public static BlockMapSnapshot Empty(string status) => new(null, 0, [], status); + + /// + /// Reads the layout of the image at . A descriptor that exposes a real + /// extent, archive or chunk layout gets the honest picture; anything else falls back to sizes laid + /// end to end from zero, which the status line labels as approximate. + /// + public static BlockMapSnapshot Read(string path, IArchiveFormatOperations? ops) { + if (ops is null) return Empty("No layout: the format is not recognised."); + + if (ops is IFilesystemExtentMap extentMap && Try(() => FromExtentMap(path, extentMap, ops)) is { } extents) return extents; + if (ops is IArchiveLayoutMap archiveLayout && Try(() => FromArchiveLayout(path, archiveLayout, ops)) is { } archive) return archive; + if (ops is IFileInternalLayoutMap fileLayout && Try(() => FromFileInternalLayout(path, fileLayout)) is { } chunks) return chunks; + return Try(() => Approximate(path, ops)) ?? Empty("No layout could be read."); + + // A walker failing mid-stream falls through to the next, so the user still sees something. + static BlockMapSnapshot? Try(Func read) { + try { + return read(); + } catch { + return null; + } + } + } + + private static BlockMapSnapshot? FromExtentMap(string path, IFilesystemExtentMap extentMap, IArchiveFormatOperations ops) { + using var stream = File.OpenRead(path); + var imageSize = stream.Length; + if (imageSize <= 0) return null; + + // Runs are counted in the order the walker yields — the file's own order — before the extents + // are sorted by offset for drawing: sorted, a file stored backwards would look contiguous. + List raw = [.. extentMap.EnumerateExtents(stream)]; + var runs = OwnerLookup.From(CountRunsByOwner(raw).Select(r => (r.Key, r.Value))); + var filled = NormalizeExtents(raw, imageSize); + if (filled is null) return null; + + OwnerLookup? mtimeByName = null; + List? entries = null; + try { + stream.Position = 0; + entries = ops.List(stream, password: null); + mtimeByName = OwnerLookup.From(entries.Where(e => !e.IsDirectory).Select(e => (e.Name, e.LastModified))); + } catch { + // Classification is best-effort; the layout itself is what matters here. + } + + var thresholds = mtimeByName is null ? null : ComputeMtimeQuartiles(mtimeByName.Values); + var classified = new List(filled.Count); + foreach (var ex in filled) + classified.Add(ex.Kind == DefragBlockKind.Used && ex.FileName is { } owner && mtimeByName is not null && mtimeByName.TryGetValue(owner, out var mtime) + ? ex with { Classification = thresholds is null ? DefragBlockClass.Normal : Classify(mtime, thresholds, 0, 1) } + : ex); + + IReadOnlyList rows = entries is null ? [] : [.. entries.Where(e => !e.IsDirectory).Select(e => new FileRow { + Name = e.Name, + Size = e.OriginalSize, + SizeDisplay = MaintenanceInput.FormatSize(e.OriginalSize), + FragmentsDisplay = runs.TryGetValue(e.Name, out var count) && count > 0 ? count.ToString("N0") : "—", + Modified = e.LastModified, + ModifiedDisplay = e.LastModified is { } dt ? dt.ToString("yyyy-MM-dd HH:mm") : "—", + Class = "—", + })]; + + var usedExtents = classified.Count(ex => ex.Kind == DefragBlockKind.Used); + var fragmentedFiles = entries is null ? 0 : runs.Values.Count(r => r > 1); + var status = fragmentedFiles > 0 + ? $"Real on-disk layout — {usedExtents:N0} extents, {fragmentedFiles:N0} fragmented file(s)" + : $"Real on-disk layout — {usedExtents:N0} extents (no fragmentation detected)"; + return new(Bin(classified), imageSize, rows, status); + } + + private static BlockMapSnapshot? FromArchiveLayout(string path, IArchiveLayoutMap archiveLayout, IArchiveFormatOperations ops) { + using var stream = File.OpenRead(path); + var imageSize = stream.Length; + if (imageSize <= 0) return null; + + List raw = [.. archiveLayout.EnumerateLayout(stream)]; + var runs = OwnerLookup.From(CountRunsByOwner(raw).Select(r => (r.Key, r.Value))); + var filled = NormalizeExtents(raw, imageSize); + if (filled is null) return null; + + OwnerLookup? methodByName = null; + List? entries = null; + try { + stream.Position = 0; + entries = ops.List(stream, password: null); + methodByName = OwnerLookup.From(entries.Where(e => !e.IsDirectory).Select(e => (e.Name, e.Method ?? ""))); + } catch { + // Classification is best-effort. + } + + var classified = new List(filled.Count); + foreach (var ex in filled) + classified.Add(ex.Kind == DefragBlockKind.Used && ex.FileName is { } owner && methodByName is not null && methodByName.TryGetValue(owner, out var method) + ? ex with { Classification = ClassifyByMethod(method) } + : ex); + + // An entry the map does not name is not one run — it is unmeasured. + IReadOnlyList rows = entries is null ? [] : [.. entries.Where(e => !e.IsDirectory).Select(e => new FileRow { + Name = e.Name, + Size = e.OriginalSize, + SizeDisplay = MaintenanceInput.FormatSize(e.OriginalSize), + FragmentsDisplay = runs.TryGetValue(e.Name, out var count) ? count.ToString("N0") : "—", + MethodDisplay = e.Method ?? "", + Modified = e.LastModified, + ModifiedDisplay = e.LastModified is { } dt ? dt.ToString("yyyy-MM-dd HH:mm") : "", + Class = ClassifyByMethod(e.Method ?? "").ToString(), + })]; + + var regionCount = classified.Count(ex => ex.Kind == DefragBlockKind.Used); + var freeBytes = classified.Where(ex => ex.Kind == DefragBlockKind.Free).Sum(ex => ex.Length); + var status = freeBytes > 0 + ? $"Real archive layout — {regionCount:N0} regions, {MaintenanceInput.FormatSize(freeBytes)} wasted" + : $"Real archive layout — {regionCount:N0} regions (tightly packed)"; + return new(Bin(classified), imageSize, rows, status); + } + + private static BlockMapSnapshot? FromFileInternalLayout(string path, IFileInternalLayoutMap fileLayout) { + using var stream = File.OpenRead(path); + var imageSize = stream.Length; + if (imageSize <= 0) return null; + + var filled = NormalizeExtents([.. fileLayout.EnumerateChunks(stream)], imageSize); + if (filled is null) return null; + + // A row here is one stretch of the file, so the count belongs to the chunk's owner and comes from + // the same layout the map was drawn from. + var runs = CountRunsByOwner(filled, onlyKind: null); + IReadOnlyList rows = [.. filled.Select(ch => new FileRow { + Name = ch.FileName ?? ch.Kind.ToString(), + Size = ch.Length, + SizeDisplay = MaintenanceInput.FormatSize(ch.Length), + FragmentsDisplay = ch.FileName is { } owner && runs.TryGetValue(owner, out var count) ? count.ToString("N0") : "—", + MethodDisplay = ch.Kind.ToString(), + Class = ch.Classification?.ToString() ?? "—", + })]; + + return new(filled, imageSize, rows, $"File-internal layout — {filled.Count(c => c.Kind != DefragBlockKind.Free):N0} chunks"); + } + + private static BlockMapSnapshot Approximate(string path, IArchiveFormatOperations ops) { + using var stream = File.OpenRead(path); + var entries = ops.List(stream, password: null); + var imageSize = stream.Length; + var map = new List(); + var rows = new List(); + var offset = 0L; + + var files = entries.Where(e => !e.IsDirectory).ToList(); + var thresholds = ComputeMtimeQuartiles(files.Select(f => f.LastModified)); + for (var k = 0; k < files.Count; ++k) { + var e = files[k]; + var size = Math.Max(1, e.OriginalSize); + var cls = Classify(e.LastModified, thresholds, k, files.Count); + map.Add(new(offset, size, DefragBlockKind.Used, e.Name, cls)); + rows.Add(new() { + Name = e.Name, + Size = e.OriginalSize, + SizeDisplay = MaintenanceInput.FormatSize(e.OriginalSize), + // The format reported no layout, so these tiles are sizes laid end to end rather than where + // anything actually is. Counting runs off that would report one for everything. + FragmentsDisplay = "—", + Modified = e.LastModified, + ModifiedDisplay = e.LastModified is { } dt ? dt.ToString("yyyy-MM-dd HH:mm") : "—", + Class = cls.ToString(), + }); + + offset += size; + if (offset >= imageSize) break; + } + + if (offset < imageSize) map.Add(new(offset, imageSize - offset, DefragBlockKind.Free)); + return new(map, imageSize, rows, "Approximate layout: the format reports no positions, so files are drawn end to end"); + } + + /// + /// Projects the archive's entries onto the image's byte span, weighted by size, so a staged rebuild + /// has something to animate against before any output exists. + /// + public static IReadOnlyList? StagedProjection(string path, IArchiveFormatOperations ops, long displaySize) { + try { + using var stream = File.OpenRead(path); + var entries = ops.List(stream, null).Where(e => !e.IsDirectory).ToArray(); + if (entries.Length == 0) return null; + + var weights = entries.Select(e => Math.Max(1L, e.OriginalSize)).ToArray(); + var totalWeight = Math.Max(1L, weights.Sum()); + var size = Math.Max(1L, displaySize); + var map = new List(entries.Length); + long cumulative = 0; + long offset = 0; + for (var i = 0; i < entries.Length; ++i) { + cumulative += weights[i]; + var end = i == entries.Length - 1 ? size : (long)((double)cumulative / totalWeight * size); + end = Math.Clamp(end, offset, size); + if (end > offset) map.Add(new(offset, end - offset, DefragBlockKind.Used, entries[i].Name, ClassifyByMethod(entries[i].Method ?? ""))); + offset = end; + } + + return map; + } catch { + // The existing picture stays usable when a projection cannot be built. + return null; + } + } + + // ── helpers ───────────────────────────────────────────────────────────────────────────────── + + private static IReadOnlyList Bin(List extents) + => extents.Count > MaxExtents ? BinExtents(extents, MaxExtents) : extents; + + /// + /// Splits the modification times into four equal-count buckets. The most recent quarter is hot, + /// the oldest is frozen. + /// + internal static (DateTime? Cold, DateTime? Normal, DateTime? Hot)? ComputeMtimeQuartiles(IEnumerable times) { + var sorted = times.Where(t => t.HasValue).Select(t => t!.Value).Order().ToList(); + if (sorted.Count == 0) return null; + + var n = sorted.Count; + return (sorted[Math.Min(n - 1, n / 4)], sorted[Math.Min(n - 1, n / 2)], sorted[Math.Min(n - 1, 3 * n / 4)]); + } + + internal static DefragBlockClass Classify(DateTime? modified, (DateTime? Cold, DateTime? Normal, DateTime? Hot)? thresholds, int index, int count) { + if (thresholds is not { } t) + // No dates anywhere — fall back to listing-order quartile so the chart still has structure. + return (index * 4 / Math.Max(1, count)) switch { + 0 => DefragBlockClass.Hot, + 1 => DefragBlockClass.Normal, + 2 => DefragBlockClass.Cold, + _ => DefragBlockClass.Frozen, + }; + + // An undated entry in a dated set stands out as frozen rather than being guessed at. + if (modified is not { } when) return DefragBlockClass.Frozen; + if (when < t.Cold) return DefragBlockClass.Frozen; + if (when < t.Normal) return DefragBlockClass.Cold; + return when < t.Hot ? DefragBlockClass.Normal : DefragBlockClass.Hot; + } + + /// Maps a compression method name to a thermal class for colour-coding archive entries. + internal static DefragBlockClass ClassifyByMethod(string method) { + var m = method.ToUpperInvariant(); + if (m.Contains("STORE") || m.Contains("COPY") || m is "NONE" or "") return DefragBlockClass.Frozen; + if (m.Contains("LZMA") || m.Contains("PPMD") || m.Contains("BZIP")) return DefragBlockClass.Hot; + if (m.Contains("ZSTD")) return DefragBlockClass.Cold; + return DefragBlockClass.Normal; + } + + /// + /// Sorts extents by offset, clips them to the image, fills the gaps as free space and trims + /// overlaps so a later extent at the same offset wins. Returns null when nothing usable remains. + /// + internal static List? NormalizeExtents(List raw, long imageSize) { + if (raw.Count == 0) return null; + raw.Sort(static (a, b) => a.Offset.CompareTo(b.Offset)); + + var clipped = new List(raw.Count); + foreach (var ex in raw) { + if (ex.Offset >= imageSize) continue; + var length = Math.Min(ex.Length, imageSize - ex.Offset); + if (length <= 0) continue; + clipped.Add(ex with { Length = length }); + } + if (clipped.Count == 0) return null; + + var filled = new List(clipped.Count * 2); + var cursor = 0L; + foreach (var ex in clipped) { + if (ex.Offset > cursor) + filled.Add(new(cursor, ex.Offset - cursor, DefragBlockKind.Free)); + else if (ex.Offset < cursor) { + if (ex.Offset + ex.Length <= cursor) continue; + var trimmed = ex with { Length = ex.Length - (cursor - ex.Offset), Offset = cursor }; + filled.Add(trimmed); + cursor = trimmed.Offset + trimmed.Length; + continue; + } + + filled.Add(ex); + cursor = ex.Offset + ex.Length; + } + + if (cursor < imageSize) filled.Add(new(cursor, imageSize - cursor, DefragBlockKind.Free)); + return filled; + } + + /// + /// Bins an oversized extent list down to by merging adjacent same-kind + /// extents, then — if still over budget — by equal-sized byte bins taking each bin's dominant kind. + /// + internal static List BinExtents(List input, int maxCount) { + if (input.Count <= maxCount) return input; + + var merged = new List(input.Count); + foreach (var ex in input) { + if (merged.Count > 0 && merged[^1] is var last && last.Kind == ex.Kind && last.Offset + last.Length == ex.Offset && last.FileName == ex.FileName) + merged[^1] = last with { Length = last.Length + ex.Length }; + else + merged.Add(ex); + } + if (merged.Count <= maxCount) return merged; + + var imageSize = merged[^1].Offset + merged[^1].Length; + var bytesPerBin = (double)imageSize / maxCount; + var binned = new List(maxCount); + var binStart = 0L; + var binIndex = 0; + var binKind = DefragBlockKind.Free; + var binBestLength = 0L; + string? binBestName = null; + + foreach (var ex in merged) { + while (ex.Offset + ex.Length > binStart + bytesPerBin && binIndex < maxCount - 1) { + if (ex.Length > binBestLength) { + binBestLength = ex.Length; + binKind = ex.Kind; + binBestName = ex.FileName; + } + + binned.Add(new(binStart, (long)bytesPerBin, binKind, binBestName)); + binStart += (long)bytesPerBin; + ++binIndex; + binKind = DefragBlockKind.Free; + binBestLength = 0; + binBestName = null; + } + + if (ex.Length > binBestLength) { + binBestLength = ex.Length; + binKind = ex.Kind; + binBestName = ex.FileName; + } + } + + binned.Add(new(binStart, imageSize - binStart, binKind, binBestName)); + return binned; + } + + /// + /// How many separate stretches of the container each owner's data occupies. Counting the entries a + /// map yields would measure the map rather than the layout: some merge an owner's consecutive + /// blocks and some emit one per block. Stretches that end exactly where the next begins are one run. + /// + internal static Dictionary CountRunsByOwner(IEnumerable extents, DefragBlockKind? onlyKind = DefragBlockKind.Used) { + var runs = new Dictionary(StringComparer.Ordinal); + var endOf = new Dictionary(StringComparer.Ordinal); + + foreach (var extent in extents) { + if (onlyKind is { } wanted && extent.Kind != wanted) continue; + if (extent.FileName is not { } owner) continue; + + if (!endOf.TryGetValue(owner, out var previousEnd) || previousEnd != extent.Offset) { + runs.TryGetValue(owner, out var count); + runs[owner] = count + 1; + } + + endOf[owner] = extent.Offset + extent.Length; + } + + return runs; + } +} + +/// +/// Finds what a layout map says about a file by the name the listing gives it. A format's layout +/// walker and its lister do not always spell a name alike — FAT's walker keeps the stored 8.3 name +/// in capitals while the lister applies the lowercase flags, so BIG.BIN in the map is +/// big.bin in the list — so a name that does not match exactly is matched ignoring case and +/// separators, but only when that match is unambiguous. +/// +internal sealed class OwnerLookup { + private readonly Dictionary _exact = new(StringComparer.Ordinal); + private readonly Dictionary _folded = new(StringComparer.OrdinalIgnoreCase); + + internal OwnerLookup(IEnumerable<(string Name, T Value)> pairs) { + foreach (var (name, value) in pairs) { + var key = Normalize(name); + if (!this._exact.TryAdd(key, value)) continue; + this._folded[key] = this._folded.TryGetValue(key, out var seen) ? (seen.Value, true) : (value, false); + } + } + + public IEnumerable Values => this._exact.Values; + + public bool TryGetValue(string name, out T value) { + var key = Normalize(name); + if (this._exact.TryGetValue(key, out value!)) return true; + if (this._folded.TryGetValue(key, out var folded) && !folded.Ambiguous) { + value = folded.Value; + return true; + } + + value = default!; + return false; + } + + private static string Normalize(string name) => name.Replace('\\', '/').TrimStart('/'); +} + +internal static class OwnerLookup { + public static OwnerLookup From(IEnumerable<(string Name, T Value)> pairs) => new(pairs); +} diff --git a/Compression.NativeUI/Maintenance/MaintenanceInput.cs b/Compression.NativeUI/Maintenance/MaintenanceInput.cs new file mode 100644 index 000000000..25ec1e4e4 --- /dev/null +++ b/Compression.NativeUI/Maintenance/MaintenanceInput.cs @@ -0,0 +1,66 @@ +using System.Globalization; + +namespace Compression.NativeUI.Maintenance; + +/// A value typed into the ribbon, read: either the value, or the reason it cannot be used. +internal readonly record struct Parsed(T Value, string? Error) { + public bool IsValid => this.Error is null; + public static Parsed Ok(T value) => new(value, null); + public static Parsed Fail(string error) => new(default!, error); +} + +/// Reads and formats the few values the Defragment tab takes as text. +internal static class MaintenanceInput { + public const int MinInterleave = 1; + public const int MaxInterleave = 256; + + /// + /// The block interleave stride: 1 is contiguous, 2 every other block, up to 256. Anything outside + /// that is refused rather than clamped, so what runs is what was typed. + /// + public static Parsed ParseInterleave(string? text) { + var trimmed = text?.Trim() ?? ""; + if (!int.TryParse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture, out var stride)) + return Parsed.Fail($"Interleave must be a whole number from {MinInterleave} to {MaxInterleave}."); + return stride is < MinInterleave or > MaxInterleave + ? Parsed.Fail($"Interleave {stride} is outside {MinInterleave}–{MaxInterleave}.") + : Parsed.Ok(stride); + } + + /// A byte count with an optional k/m/g suffix (KiB, MiB, GiB): 64m, 1g, 524288. + public static Parsed ParseSize(string? text) { + var s = text?.Trim().ToLowerInvariant() ?? ""; + var multiplier = 1L; + if (s.EndsWith('k')) (multiplier, s) = (1L << 10, s[..^1]); + else if (s.EndsWith('m')) (multiplier, s) = (1L << 20, s[..^1]); + else if (s.EndsWith('g')) (multiplier, s) = (1L << 30, s[..^1]); + + if (!long.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out var n) || n <= 0) + return Parsed.Fail("Hole size must be a positive size such as 64m, 1g or 524288."); + return n > long.MaxValue / multiplier + ? Parsed.Fail("Hole size is too large.") + : Parsed.Ok(n * multiplier); + } + + /// Where a carved hole starts: auto (after the last live extent, -1) or a byte offset. + public static Parsed ParseHoleAt(string? text) { + var s = text?.Trim() ?? ""; + if (s.Length == 0 || s.Equals("auto", StringComparison.OrdinalIgnoreCase)) return Parsed.Ok(-1); + return long.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out var offset) + ? Parsed.Ok(offset) + : Parsed.Fail("Hole offset must be 'auto' or a byte offset."); + } + + /// Scramble's seed: any 32-bit integer. The same seed deals the same layout. + public static Parsed ParseSeed(string? text) + => int.TryParse(text?.Trim(), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var seed) + ? Parsed.Ok(seed) + : Parsed.Fail("Seed must be a whole number."); + + public static string FormatSize(long bytes) => bytes switch { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", + < 1024 * 1024 * 1024 => $"{bytes / (1024.0 * 1024):F1} MB", + _ => $"{bytes / (1024.0 * 1024 * 1024):F2} GB", + }; +} diff --git a/Compression.NativeUI/Maintenance/MaintenancePresenter.cs b/Compression.NativeUI/Maintenance/MaintenancePresenter.cs new file mode 100644 index 000000000..0247a8053 --- /dev/null +++ b/Compression.NativeUI/Maintenance/MaintenancePresenter.cs @@ -0,0 +1,357 @@ +using System.Diagnostics; +using Compression.Lib; +using Compression.Registry; +using Compression.Registry.Layout; + +namespace Compression.NativeUI.Maintenance; + +/// A live update from a running operation, for the block view. +internal sealed record MaintenanceProgress( + IReadOnlyList? Map, + long ImageSize, + long ReadHead, + long WriteHead, + double Fraction, + string? Status, + bool CommitStarted = false); + +internal enum MaintenanceOutcomeKind { Succeeded, Refused, Failed, Cancelled } + +/// How an operation ended, in one line, and whether the image on disk changed. +internal sealed record MaintenanceOutcome(MaintenanceOutcomeKind Kind, string Summary, bool Mutated); + +/// +/// The Defragment tab without the tab: one target image, what its format supports, what the user +/// picked, and the code that runs the picked operation off the UI thread. The view binds to this and +/// marshals the callbacks; nothing here touches a control. +/// +internal sealed class MaintenancePresenter { + /// The operations in the order the ribbon offers them; the first supported one is preselected. + public static readonly MaintenanceVerb[] VerbOrder = [ + MaintenanceVerb.Defragment, MaintenanceVerb.Optimize, MaintenanceVerb.Shrink, MaintenanceVerb.Compact, + MaintenanceVerb.WipeEmpty, MaintenanceVerb.Purge, MaintenanceVerb.Scramble, + ]; + + public MaintenancePresenter(string imagePath, string formatId) { + this.ImagePath = imagePath; + this.FormatId = formatId; + this.Capabilities = TargetCapabilities.For(formatId); + this.Verb = VerbOrder.Cast().FirstOrDefault(v => this.Capabilities.Verb(v!.Value).Supported); + this.Strategy = Enum.GetValues().FirstOrDefault(s => this.Capabilities.Strategy(s).Supported); + this.OptimizeMethod = this.Capabilities.OptimizeMethods.FirstOrDefault(); + } + + public string ImagePath { get; } + public string FormatId { get; } + public TargetCapabilities Capabilities { get; } + public IArchiveFormatOperations? Operations => FormatRegistry.GetArchiveOps(this.FormatId); + + // ── what the user picked ──────────────────────────────────────────────────────────────────── + + public MaintenanceVerb? Verb { get; set; } + public DefragStrategy Strategy { get; set; } + public bool PackAtEnd { get; set; } + public string InterleaveText { get; set; } = "1"; + public MetadataZone MetadataZone { get; set; } = MetadataZone.Unchanged; + public LayoutTemplate? LayoutProfile { get; set; } + public string HoleSizeText { get; set; } = "64m"; + public string HoleAtText { get; set; } = "auto"; + public string SeedText { get; set; } = "1"; + public OptimizeMethod OptimizeMethod { get; set; } + + /// Whether the picked defragmentation moves extents, as against sorting directory entries. + public bool MovesExtents => this.Strategy != DefragStrategy.SortEntries; + + public bool IsRunning { get; private set; } + + /// + /// Why Start cannot run as things stand, or null when it can: no operation, one the format does + /// not support, a strategy it does not support, or a value that does not read. + /// + public string? StartBlocker { + get { + if (this.IsRunning) return "An operation is already running."; + if (this.Verb is not { } verb) return "Pick an operation."; + if (this.Capabilities.Verb(verb) is { Supported: false } refused) return refused.Reason; + if (verb == MaintenanceVerb.Defragment && this.Capabilities.Strategy(this.Strategy) is { Supported: false } strategy) return strategy.Reason; + if (verb == MaintenanceVerb.Optimize && !this.Capabilities.OptimizeMethods.Contains(this.OptimizeMethod)) + return $"This format does not offer {TargetCapabilities.Describe(this.OptimizeMethod)}."; + return this.Validate().FirstOrDefault(); + } + } + + /// Every value that would have to change before the picked operation could run. + public IReadOnlyList Validate() { + var errors = new List(); + switch (this.Verb) { + case MaintenanceVerb.Defragment: + if (this.MovesExtents && this.Capabilities.Interleave.Supported && MaintenanceInput.ParseInterleave(this.InterleaveText).Error is { } stride) errors.Add(stride); + if (this.Strategy == DefragStrategy.CarveHole) { + if (MaintenanceInput.ParseSize(this.HoleSizeText).Error is { } size) errors.Add(size); + if (MaintenanceInput.ParseHoleAt(this.HoleAtText).Error is { } at) errors.Add(at); + } + + break; + case MaintenanceVerb.Scramble: + if (MaintenanceInput.ParseSeed(this.SeedText).Error is { } seed) errors.Add(seed); + break; + } + + return errors; + } + + /// Whether the picked operation can be stopped part-way: only an extent move can. + public bool CanCancel => this.Verb == MaintenanceVerb.Defragment && this.MovesExtents; + + /// + /// Whether stopping discards a staged copy (the original untouched) rather than leaving in-place + /// moves where they got to. Extent moves are always in place. + /// + public bool IsStaged => false; + + /// The engine mode the picked strategy stands for. + public DefragMode EngineMode => this.Strategy switch { + DefragStrategy.Defrag => DefragMode.FillHolesLazy, + DefragStrategy.Reorder => DefragMode.AscendingOrder, + DefragStrategy.CarveHole => DefragMode.CarveHole, + // Pack at the end when asked to, or when that is the only packing the format knows. + _ => this.PackAtEnd && this.Capabilities.PackAtEnd.Supported || !this.Capabilities.PackAtStart.Supported + ? DefragMode.ConsolidateAtEnd + : DefragMode.ConsolidateAtStart, + }; + + /// + /// The options a defragmentation runs with. Only what the format supports is set: an option the + /// ribbon disabled stays at its default rather than reaching the engine to be refused. + /// + public DefragOptions BuildDefragOptions(Action? onProgress = null, CancellationToken token = default) { + var mode = this.EngineMode; + var carve = mode == DefragMode.CarveHole; + return new() { + Mode = mode, + HoleSize = carve ? MaintenanceInput.ParseSize(this.HoleSizeText).Value : 0, + HoleAt = carve ? MaintenanceInput.ParseHoleAt(this.HoleAtText).Value : -1, + InterleaveStride = this.Capabilities.Interleave.Supported && MaintenanceInput.ParseInterleave(this.InterleaveText) is { IsValid: true } stride ? stride.Value : 1, + MetadataZonePlacement = this.Capabilities.MetadataZone.Supported ? this.MetadataZone : MetadataZone.Unchanged, + LayoutTemplate = this.Capabilities.LayoutProfile.Supported ? this.LayoutProfile : null, + OnProgress = onProgress, + CancellationToken = token, + }; + } + + /// + /// The question to ask before running, for the operations that destroy or deliberately degrade + /// something; null for those that only improve the image or leave it as it was. + /// + public string? ConfirmationFor() { + var file = Path.GetFileName(this.ImagePath); + switch (this.Verb) { + case MaintenanceVerb.Purge: + var count = 0; + try { + using var stream = File.OpenRead(this.ImagePath); + count = this.Operations?.List(stream, password: null).Count(e => !e.IsDirectory) ?? 0; + } catch { + // The count is for the question only; the operation itself reports any failure. + } + + return $"Erase ALL {count} file(s) from {file}?\n\nThis leaves a valid but empty container. The data cannot be recovered."; + case MaintenanceVerb.Scramble: + return $"Fragment {file} on purpose?\n\nEvery file's blocks are scattered across the volume. The contents are preserved exactly " + + "-- only the layout changes, and Defragment undoes it -- but this is a testing tool, not a repair."; + default: + return null; + } + } + + /// Reads the image's layout for the block view. + public BlockMapSnapshot Analyze() => BlockMapSnapshot.Read(this.ImagePath, this.Operations); + + // ── running ───────────────────────────────────────────────────────────────────────────────── + + /// + /// Runs the picked operation on a worker thread. and + /// are called from that thread; the caller marshals them. A format refusing + /// the request () ends as + /// with the image as it was. + /// + public Task RunAsync(Action progress, Action log, CancellationToken token = default) { + if (this.StartBlocker is { } blocker) return Task.FromResult(new MaintenanceOutcome(MaintenanceOutcomeKind.Refused, blocker, Mutated: false)); + + var verb = this.Verb!.Value; + var path = this.ImagePath; + this.IsRunning = true; + log($"=== {DateTime.Now:HH:mm:ss} {Label(verb)} {Path.GetFileName(path)} ==="); + + // Everything the worker needs is read here, on the caller's thread, so a later ribbon change + // cannot alter a run already under way. + var defragOptions = verb == MaintenanceVerb.Defragment && this.MovesExtents ? this.BuildDefragOptions(ev => progress(FromEvent(ev)), token) : null; + var seed = MaintenanceInput.ParseSeed(this.SeedText).Value; + var method = this.OptimizeMethod; + + return Task.Run(() => { + var clock = Stopwatch.StartNew(); + MaintenanceOutcome outcome; + try { + outcome = verb switch { + MaintenanceVerb.Defragment when defragOptions is null => this.RunSortEntries(path), + MaintenanceVerb.Defragment => this.RunDefragment(path, defragOptions, log), + MaintenanceVerb.Optimize => RunOptimize(path, method), + MaintenanceVerb.Shrink => this.RunShrink(path), + MaintenanceVerb.WipeEmpty => this.RunWipe(path), + MaintenanceVerb.Purge => this.RunPurge(path), + MaintenanceVerb.Compact => RunCompact(path, log), + MaintenanceVerb.Scramble => this.RunScramble(path, seed, progress), + _ => new(MaintenanceOutcomeKind.Refused, $"{verb} is not an operation.", false), + }; + } catch (OperationCanceledException) { + outcome = this.IsStaged + ? new(MaintenanceOutcomeKind.Cancelled, "Cancelled — the staged copy was discarded; the image is unchanged.", false) + : new(MaintenanceOutcomeKind.Cancelled, "Cancelled — moves already completed stay where they are; the contents are unchanged.", true); + } catch (NotSupportedException ex) { + outcome = new(MaintenanceOutcomeKind.Refused, ex.Message, false); + } catch (Exception ex) { + outcome = new(MaintenanceOutcomeKind.Failed, $"{ex.GetType().Name}: {ex.Message}", false); + } finally { + this.IsRunning = false; + } + + log($"{outcome.Kind.ToString().ToUpperInvariant()} ({clock.ElapsedMilliseconds} ms) — {outcome.Summary}"); + return outcome; + }, CancellationToken.None); + } + + internal static string Label(MaintenanceVerb verb) => verb switch { + MaintenanceVerb.WipeEmpty => "Clearing free space in", + MaintenanceVerb.Defragment => "Defragmenting", + MaintenanceVerb.Optimize => "Optimizing", + MaintenanceVerb.Shrink => "Shrinking", + MaintenanceVerb.Purge => "Purging", + MaintenanceVerb.Compact => "Compacting", + MaintenanceVerb.Scramble => "Scrambling", + _ => verb.ToString(), + }; + + private static MaintenanceProgress FromEvent(DefragProgressEvent ev) + => new(ev.BlockMap, ev.ImageSize, ev.CurrentReadOffset, ev.CurrentWriteOffset, ev.Fraction, ev.Status, CommitStarted: ev.Phase == "committing"); + + private MaintenanceOutcome RunDefragment(string path, DefragOptions options, Action log) { + if (TargetCapabilities.Carrier(this.FormatId, MaintenanceCapability.DefragmentExtents) is not IArchiveDefragmentable defragmentable) + throw new NotSupportedException($"{this.FormatId} does not move extents in place."); + + log($"Mode: {options.Mode}{(options.InterleaveStride > 1 ? $", interleave {options.InterleaveStride}" : "")}" + + $"{(options.MetadataZonePlacement != MetadataZone.Unchanged ? $", metadata {options.MetadataZonePlacement}" : "")}" + + $"{(options.LayoutTemplate is { } t ? $", profile {t.Name}" : "")}" + + $"{(options.Mode == DefragMode.CarveHole ? $", hole {options.HoleSize:N0} bytes at {(options.HoleAt < 0 ? "auto" : options.HoleAt.ToString("N0"))}" : "")}"); + + string? finalStatus = null; + var reporting = options with { + OnProgress = ev => { + if (ev.Phase == "complete" && !string.IsNullOrWhiteSpace(ev.Status)) finalStatus = ev.Status; + options.OnProgress?.Invoke(ev); + }, + }; + + using (var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite)) + defragmentable.Defragment(stream, reporting); + + return new(MaintenanceOutcomeKind.Succeeded, finalStatus ?? "Defragmented; size and contents unchanged.", true); + } + + private MaintenanceOutcome RunSortEntries(string path) { + var before = new FileInfo(path).Length; + MaintenanceOperations.SortDirectoryEntries(path); + return new(MaintenanceOutcomeKind.Succeeded, $"Every directory is in name order; {MaintenanceInput.FormatSize(before)}, no data moved.", true); + } + + /// Optimizes through the registry's staged, verified file operation; the original is replaced atomically. + private static MaintenanceOutcome RunOptimize(string path, OptimizeMethod method) { + var result = method switch { + OptimizeMethod.Compress => MaintenanceOperations.Compress(path, path), + OptimizeMethod.Repack => MaintenanceOperations.Repack(path, path), + _ => MaintenanceOperations.Canonicalize(path, path), + }; + + return new(MaintenanceOutcomeKind.Succeeded, + result.Changed ? $"{method}: {SizeChange(result.OriginalSize, result.NewSize)}" : $"{method}: nothing to gain; the file is unchanged.", + result.Changed); + } + + private MaintenanceOutcome RunShrink(string path) { + if (TargetCapabilities.Carrier(this.FormatId, MaintenanceCapability.Shrink) is not IArchiveShrinkable shrinkable) + throw new NotSupportedException($"{this.FormatId} cannot shrink losslessly."); + + // Shrink into a staged file, then swap it in, so a crash mid-write cannot corrupt the source. + var originalSize = new FileInfo(path).Length; + var staged = path + ".shrink.tmp"; + try { + using (var input = File.OpenRead(path)) + using (var output = File.Create(staged)) + shrinkable.Shrink(input, output); + + var newSize = new FileInfo(staged).Length; + if (newSize >= originalSize) + return new(MaintenanceOutcomeKind.Succeeded, "Already compact; nothing to trim.", false); + + AtomicFileWriter.ReplaceTarget(staged, path); + return new(MaintenanceOutcomeKind.Succeeded, SizeChange(originalSize, newSize), true); + } finally { + AtomicFileWriter.TryDelete(staged); + } + } + + private MaintenanceOutcome RunWipe(string path) { + var ops = TargetCapabilities.Carrier(this.FormatId, MaintenanceCapability.WipeUnused); + var totalUnused = -1L; + long wiped; + using (var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite)) { + switch (ops) { + // Every extent and layout map is a wiper too; the interface default wipes through the map. + case IWipeEmpty wiper: + // The wiper reports only the bytes it had to overwrite; on a mostly empty image that and the + // free space diverge sharply, so the total is worked out first where the layout allows. + if (ops is IFilesystemExtentMap fsMap) totalUnused = UnusedSpaceWiper.ComputeUnusedBytes(fsMap.EnumerateExtents(stream), stream.Length); + else if (ops is IArchiveLayoutMap arMap) totalUnused = UnusedSpaceWiper.ComputeUnusedBytes(arMap.EnumerateLayout(stream), stream.Length); + stream.Position = 0; + wiped = wiper.WipeUnusedSpace(stream); + break; + default: + throw new NotSupportedException($"{this.FormatId} exposes no layout to tell free space from live data."); + } + } + + var summary = totalUnused >= 0 + ? $"{MaintenanceInput.FormatSize(totalUnused)} unused; {MaintenanceInput.FormatSize(wiped)} newly zeroed, {MaintenanceInput.FormatSize(Math.Max(0, totalUnused - wiped))} already zero." + : $"{MaintenanceInput.FormatSize(wiped)} zeroed."; + return new(MaintenanceOutcomeKind.Succeeded, summary, wiped > 0); + } + + private MaintenanceOutcome RunPurge(string path) { + if (TargetCapabilities.Carrier(this.FormatId, MaintenanceCapability.Purge) is not IArchivePurgeable purgeable) + throw new NotSupportedException($"{this.FormatId} has no valid empty state to purge to."); + + var originalSize = new FileInfo(path).Length; + using (var image = File.Open(path, FileMode.Open, FileAccess.ReadWrite)) + purgeable.Purge(image); + return new(MaintenanceOutcomeKind.Succeeded, $"Every file erased; {SizeChange(originalSize, new FileInfo(path).Length)}", true); + } + + private static MaintenanceOutcome RunCompact(string path, Action log) { + var result = CompactOperation.Compact(path, new CompactOperation.CompactOptions { Log = line => log(" " + line) }); + var steps = result.StepsRun.Count > 0 ? string.Join(", ", result.StepsRun) : "none"; + return new(MaintenanceOutcomeKind.Succeeded, $"steps: {steps}; {SizeChange(result.OriginalSize, result.NewSize)}", result.StepsRun.Count > 0); + } + + private MaintenanceOutcome RunScramble(string path, int seed, Action progress) { + if (this.Operations is not IFilesystemScrambleable scrambler) throw new NotSupportedException($"{this.FormatId} cannot scatter a volume in place."); + + using (var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite)) + scrambler.Scramble(stream, new ScrambleOptions { Seed = seed, OnProgress = ev => progress(FromEvent(ev)) }); + return new(MaintenanceOutcomeKind.Succeeded, $"seed {seed}: the volume is fragmented; Defragment puts it back.", true); + } + + private static string SizeChange(long before, long after) { + var pct = before > 0 ? 100.0 * (after - before) / before : 0; + return $"{MaintenanceInput.FormatSize(before)} → {MaintenanceInput.FormatSize(after)} ({pct:+0.0;-0.0;0.0}%)"; + } +} diff --git a/Compression.NativeUI/Maintenance/MaintenanceVerb.cs b/Compression.NativeUI/Maintenance/MaintenanceVerb.cs new file mode 100644 index 000000000..bf05d67f4 --- /dev/null +++ b/Compression.NativeUI/Maintenance/MaintenanceVerb.cs @@ -0,0 +1,65 @@ +namespace Compression.NativeUI.Maintenance; + +/// +/// The maintenance operations the Defragment tab runs: the five canonical verbs from +/// docs/ARCHIVE-MODEL.md plus the two composites. +/// +internal enum MaintenanceVerb { + /// Find and apply the best layout, or re-encode the payload; size preserved where possible. + Optimize, + + /// Keep the parameter set; minimise the stored footprint. + Shrink, + + /// Re-order entries and extents so files are contiguous; size preserved. + Defragment, + + /// Erase all live data, leaving a valid empty container. + Purge, + + /// Overwrite only unused space — free clusters, slack, deleted entries; size preserved. + WipeEmpty, + + /// Defragment, optimize and shrink in one pass: the smallest valid container. + Compact, + + /// + /// Scatter every allocation block across the volume — fragmentation on purpose, so + /// has something real to undo. Content is preserved; only its place changes. + /// + Scramble, +} + +/// +/// How a defragmentation lays the volume out, in the words the ribbon uses. Each maps onto one +/// engine , except , which is +/// the registry's separate directory sort and moves no data. +/// +internal enum DefragStrategy { + /// Pack every file at one end of the volume (ConsolidateAtStart, or ConsolidateAtEnd). + Consolidate, + + /// Fill existing holes from the tail, moving as little as possible (FillHolesLazy). + Defrag, + + /// Move only what reads backwards, so every file ascends (AscendingOrder). + Reorder, + + /// Sort the entries of every directory by name, in place (SortDirectoryEntries). + SortEntries, + + /// Reserve one contiguous free region of a chosen size (CarveHole). + CarveHole, +} + +/// How Optimize improves a container; the format offers some, all or none of them. +internal enum OptimizeMethod { + /// Re-encode the payload with the best compression, same format. + Compress, + + /// Rebuild from the same entries, stored bytes copied verbatim, dead space dropped. + Repack, + + /// Rewrite into the canonical representation (chunk order, padding, header normal form). + Canonicalize, +} diff --git a/Compression.NativeUI/Maintenance/TargetCapabilities.cs b/Compression.NativeUI/Maintenance/TargetCapabilities.cs new file mode 100644 index 000000000..4c89c080f --- /dev/null +++ b/Compression.NativeUI/Maintenance/TargetCapabilities.cs @@ -0,0 +1,145 @@ +using System.Collections.Concurrent; +using Compression.Registry; + +namespace Compression.NativeUI.Maintenance; + +/// One answer to "can this target do that?": yes or no, and the sentence that says why. +internal readonly record struct Capability(bool Supported, string Reason) { + public static Capability Yes(string detail) => new(true, detail); + public static Capability No(string reason) => new(false, reason); +} + +/// +/// What one format offers the Defragment tab: which operations, which defragmentation strategies, +/// which layout options and which ways of optimizing. The ribbon enables exactly what this says +/// and nothing else, so an option is never offered only to be ignored or refused. +/// +/// +/// This is the single place that turns the registry's answer — +/// , a — into +/// the ribbon's terms. The owner's names map onto the registry's operations as: Defragment → +/// (and Sort Entries → +/// ), Optimize → Compress, Repack or +/// Canonicalize as offered, Clear → , and Shrink, Purge, +/// Compact and Scramble by their own names. +/// +internal sealed record TargetCapabilities( + MaintenanceProfile Profile, + IReadOnlyDictionary Verbs, + IReadOnlyDictionary Strategies, + Capability PackAtStart, + Capability PackAtEnd, + Capability Interleave, + Capability MetadataZone, + Capability LayoutProfile, + IReadOnlyList OptimizeMethods) { + + /// Whether any operation at all can run on the target. + public bool AnySupported => this.Verbs.Values.Any(v => v.Supported); + + public Capability Verb(MaintenanceVerb verb) => this.Verbs.TryGetValue(verb, out var c) ? c : Capability.No("Not offered."); + public Capability Strategy(DefragStrategy strategy) => this.Strategies.TryGetValue(strategy, out var c) ? c : Capability.No("Not offered."); + + private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); + + /// + /// The capabilities of the format registered as . They depend on the + /// descriptor alone, and the shell asks on every command requery, so they are worked out once. + /// + public static TargetCapabilities For(string formatId) { + ArgumentNullException.ThrowIfNull(formatId); + return Cache.GetOrAdd(formatId, Evaluate); + } + + private static TargetCapabilities Evaluate(string formatId) { + var descriptor = FormatRegistry.GetById(formatId); + var profile = descriptor is null + ? new MaintenanceProfile(formatId, MaintenanceCapability.None, DefragFeature.None, []) + : MaintenanceCapabilities.Describe(descriptor); + return From(profile, descriptor?.DisplayName is { Length: > 0 } display ? display : formatId); + } + + /// Turns a registry profile into the ribbon's answers. + internal static TargetCapabilities From(MaintenanceProfile profile, string name) { + bool Has(MaintenanceCapability capability) => profile.Supports(capability); + bool Honours(DefragFeature feature) => profile.Supports(feature); + + var extents = Has(MaintenanceCapability.DefragmentExtents); + var sort = Has(MaintenanceCapability.SortDirectoryEntries); + OptimizeMethod[] methods = [ + .. Has(MaintenanceCapability.Compress) ? [OptimizeMethod.Compress] : Array.Empty(), + .. Has(MaintenanceCapability.Repack) ? [OptimizeMethod.Repack] : Array.Empty(), + .. Has(MaintenanceCapability.Canonicalize) ? [OptimizeMethod.Canonicalize] : Array.Empty(), + ]; + + var verbs = new Dictionary { + [MaintenanceVerb.Defragment] = extents || sort + ? Capability.Yes(extents ? $"Moves {name}'s extents in place; size and contents unchanged." : $"Sorts {name}'s directory entries in place.") + : Capability.No($"{name} neither moves extents in place nor sorts directory entries."), + [MaintenanceVerb.Optimize] = methods.Length > 0 + ? Capability.Yes($"Optimizes by {string.Join(", ", methods.Select(Describe))}; the original is replaced only by a verified result.") + : Capability.No($"{name} has no lossless compress, repack or canonical form."), + [MaintenanceVerb.Shrink] = Has(MaintenanceCapability.Shrink) + ? Capability.Yes("Reduces the container to what it needs, keeping every file.") + : Capability.No($"{name} cannot shrink losslessly."), + [MaintenanceVerb.Purge] = Has(MaintenanceCapability.Purge) + ? Capability.Yes("Erases every file, leaving a valid empty container. Asks first.") + : Capability.No($"{name} has no valid empty state to purge to."), + [MaintenanceVerb.WipeEmpty] = Has(MaintenanceCapability.WipeUnused) + ? Capability.Yes("Zeroes unused space — free clusters, slack, dead bytes — and nothing else.") + : Capability.No($"{name} exposes no layout to tell free space from live data."), + [MaintenanceVerb.Compact] = Has(MaintenanceCapability.Compact) + ? Capability.Yes("Defragment extents, compress and shrink in one pass, each where offered.") + : Capability.No($"{name} offers none of the steps Compact is made of."), + [MaintenanceVerb.Scramble] = Has(MaintenanceCapability.Scramble) + ? Capability.Yes("Scatters every block on purpose, so Defragment has something to undo. Contents preserved; asks first.") + : Capability.No($"{name} cannot scatter a volume in place."), + }; + + Capability Mode(DefragFeature feature, string yes, string what) + => extents && Honours(feature) ? Capability.Yes(yes) : Capability.No($"{name} cannot {what}."); + + var start = extents && Honours(DefragFeature.ConsolidateAtStart); + var end = extents && Honours(DefragFeature.ConsolidateAtEnd); + var strategies = new Dictionary { + [DefragStrategy.Consolidate] = start || end + ? Capability.Yes("Packs every file at one end; free space ends up in one piece.") + : Capability.No($"{name} cannot pack its files together."), + [DefragStrategy.Defrag] = Mode(DefragFeature.FillHolesLazy, "Fills existing holes from the tail, moving as few bytes as possible.", "fill holes lazily"), + [DefragStrategy.Reorder] = Mode(DefragFeature.AscendingOrder, "Moves only what reads backwards, so every file's blocks ascend.", "reorder blocks into ascending order"), + [DefragStrategy.SortEntries] = sort + ? Capability.Yes("Sorts every directory's entries by name in place; no data moves.") + : Capability.No($"{name} does not offer sorting directory entries."), + [DefragStrategy.CarveHole] = Mode(DefragFeature.CarveHole, "Reserves one contiguous free region of the size you choose.", "carve a hole"), + }; + + return new( + profile, + verbs, + strategies, + PackAtStart: Mode(DefragFeature.ConsolidateAtStart, "Packs at the front; free space ends up after the last file.", "pack at the start"), + PackAtEnd: Mode(DefragFeature.ConsolidateAtEnd, "Packs at the back instead; free space stays at the front.", "pack at the end"), + Interleave: Mode(DefragFeature.Interleave, $"{name} honours block interleave.", "interleave blocks"), + MetadataZone: Mode(DefragFeature.MetadataZone, $"{name} honours metadata placement.", "place metadata"), + LayoutProfile: Mode(DefragFeature.LayoutTemplate, $"{name} honours layout profiles.", "follow a layout profile"), + methods); + } + + internal static string Describe(OptimizeMethod method) => method switch { + OptimizeMethod.Compress => "re-encoding with the best compression", + OptimizeMethod.Repack => "repacking without dead space", + _ => "rewriting into canonical form", + }; + + /// + /// The object that carries for : the + /// descriptor, or its archive or stream operations object. + /// + internal static object? Carrier(string formatId, MaintenanceCapability capability) { + var descriptor = FormatRegistry.GetById(formatId); + foreach (var candidate in new object?[] { descriptor, FormatRegistry.GetArchiveOps(formatId), FormatRegistry.GetStreamOps(formatId) }) + if (candidate is not null && MaintenanceCapabilities.Of(candidate).HasFlag(capability)) + return candidate; + return null; + } +} diff --git a/Compression.NativeUI/README.md b/Compression.NativeUI/README.md index e5fc2bcfd..e71eb0b30 100644 --- a/Compression.NativeUI/README.md +++ b/Compression.NativeUI/README.md @@ -8,8 +8,9 @@ backends, so Windows and Linux run the same code rather than two frontends drift - **Ribbon** — the commands live in an Office-style ribbon instead of a menu bar: a Quick Access Toolbar (Back, Forward, Up, Refresh), File, Home (clipboard, organize, open, selection), View (navigation and preview panes, Details / Thumbnails), Tools (analysis, maintenance, partitions, - mounting, benchmark), and an Archive Tools tab that appears only inside an archive (extract, add, - test, and the maintenance verbs). Every shortcut works form-wide from the ribbon. + mounting, benchmark), an Archive Tools tab that appears only inside an archive (extract, add, test, + reconfigure), and a Disk Tools tab, Defragment, that appears while there is something to maintain. + Every shortcut works form-wide from the ribbon. - **Archive browser** — the main window, laid out like a file manager. A folder tree on the left shows the drives (or `/`) and home, with the open archive grafted in as a folder beside the file it lives in; it follows every navigation, however it happened. The breadcrumb bar is also an address @@ -49,8 +50,31 @@ backends, so Windows and Linux run the same code rather than two frontends drift for a directory. - **Binary analysis** — magic scan, algorithm fingerprints, entropy map, heatmap, trial decompression, chain reconstruction, statistics, strings and a struct-template interpreter. -- **Maintenance** — defragment, optimize, shrink, purge, wipe-empty, compact and scramble, over a - live block map that can also be projected onto a platter or a stack of platters. +- **Defragment tab** — maintenance is a contextual ribbon tab rather than a window. It appears while + the open archive or image, or an archive file selected in a folder, supports any maintenance + operation (Tools → Maintenance or Ctrl+Shift+D selects it; the Maintenance entries of the list's + context menu open it with their operation picked). Selecting it hands the client area below the + breadcrumb bar to the block map; leaving it shows the browser again exactly as it was. + + | Group | Items | + | --- | --- | + | Operation | Start, then Defragment, Optimize, Shrink, Compact, Clear (wipe free space), Purge, Scramble as a radio group; Stop while extents are being moved | + | Defrag Mode | Consolidate (Pack at End as its variant), Defrag (fill holes), Re-order (ascending blocks), Carve Hole with its size, placement and offset fields — all extent moves — and Sort Entries, which sorts every directory by name in place and moves no data | + | Options | Block interleave (spinner, 1–256), metadata placement, layout profile and Profiles… (the editor) for extent moves; the seed for Scramble and the method for Optimize (Compress, Repack or Canonicalize, as the format offers) appear while their operation is picked, and the Target choice appears only when there is one to make | + | View | Blocks, Circle, 3D Stack; Files panel; Legend; Analyze (read the layout again) | + + The line above the map names the target. It is the open volume, or a selected file whose content — + never its name — proves it a container ("Selected: inner.img (Fat)"); changing the selection never + retargets, only picking in the Target field does. + + Every item asks one adapter (`Maintenance/TargetCapabilities.cs`) whether the target supports it, + and is disabled with the reason in its tooltip when not. The adapter is a view of the registry's + `MaintenanceCapabilities.Describe` profile — operations, honoured defrag features — so the tab, the + CLI and the support matrices cannot disagree; a test holds it to the profile for every registered + format. Sort Entries is offered for FAT and exFAT. Operations run off the UI thread through + `MaintenancePresenter`, driving the map live; a refusal (`NotSupportedException`) is reported and + leaves the image byte-identical, and after a change the archive is re-listed in place. A nested + archive is maintained as a temporary copy and written back into its host after each change. - **Partition editor** — MBR and GPT tables: add, delete, purge, convert, format, verify. - **Benchmark** — every building block against seven synthetic data patterns. - **Reverse engineer** — discovers an unknown format by probing a tool or by locating known content @@ -85,7 +109,7 @@ ran at a scale factor other than 1. ## Screenshots `--screenshot=` builds a deterministic fixture and shows that -one window. It does not write an image: NativeForms has no way to render a window to a bitmap, so CI +one window (`maintenance` is the shell on the Defragment tab over a scrambled FAT floppy). It does not write an image: NativeForms has no way to render a window to a bitmap, so CI takes the picture from outside with an X11 capture tool. Everything that makes a capture reproducible lives here — fixed payloads, fixed timestamps, a fixed scramble seed, and an analysis window that leaves its elapsed time out of the status line. diff --git a/Compression.NativeUI/ScreenshotMode.cs b/Compression.NativeUI/ScreenshotMode.cs index f3d4cbde7..25900029e 100644 --- a/Compression.NativeUI/ScreenshotMode.cs +++ b/Compression.NativeUI/ScreenshotMode.cs @@ -44,8 +44,14 @@ public static Form CreateWindow(string target, string fixtureRoot) { return window; } - case "maintenance": - return new DefragmentWindow(CreateFilesystemFixture(fixtureRoot)); + case "maintenance": { + // The shell on the Defragment tab, so the capture shows the ribbon and the block view the + // way a user reaches them. + var window = new MainForm([]); + window.OpenArchive(CreateFilesystemFixture(fixtureRoot)); + window.OpenDefragmentTab(verb: null); + return window; + } default: throw new ArgumentOutOfRangeException(nameof(target), target, diff --git a/Compression.NativeUI/ViewModels/MainViewModel.cs b/Compression.NativeUI/ViewModels/MainViewModel.cs index 584894f0c..1ded0cc36 100644 --- a/Compression.NativeUI/ViewModels/MainViewModel.cs +++ b/Compression.NativeUI/ViewModels/MainViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; using Compression.Lib; +using Compression.NativeUI.Maintenance; using Compression.NativeUI.Views; using Compression.Registry; using Hawkynt.NativeForms; @@ -119,6 +120,9 @@ internal sealed class MainViewModel : ViewModelBase { public ICommand WipeEntryCommand { get; } public ICommand CompactEntryCommand { get; } public ICommand ScrambleEntryCommand { get; } + + /// Opens the Defragment tab on the resolved maintenance target, with no operation preselected. + public ICommand MaintenanceCommand { get; } public ICommand ReconfigureEntryCommand { get; } public ICommand DeleteSelectedCommand { get; } /// Asks the view to start editing the selected entry's name; see . @@ -224,6 +228,7 @@ public MainViewModel() { WipeEntryCommand = new RelayCommand(_ => OpenMaintenance(MaintenanceVerb.WipeEmpty), _ => CanMaintain(MaintenanceVerb.WipeEmpty)); CompactEntryCommand = new RelayCommand(_ => OpenMaintenance(MaintenanceVerb.Compact), _ => CanMaintain(MaintenanceVerb.Compact)); ScrambleEntryCommand = new RelayCommand(_ => OpenMaintenance(MaintenanceVerb.Scramble), _ => CanMaintain(MaintenanceVerb.Scramble)); + MaintenanceCommand = new RelayCommand(_ => MaintenanceRequested?.Invoke(this, new(null, PreferSelection: false)), _ => HasMaintenanceTarget); ReconfigureEntryCommand = new RelayCommand(_ => Reconfigure(), _ => CanReconfigure()); DeleteSelectedCommand = new RelayCommand(_ => DeleteSelectedEntries(), _ => CanDeleteSelected); CopyCommand = new RelayCommand(_ => PutSelectionOnClipboard(cut: false), _ => CanCopySelection()); @@ -811,20 +816,10 @@ async Task DeleteAndReportAsync() { } /// - /// Resolves which archive a maintenance verb should target, without - /// materialising anything (cheap enough for CanExecute). Two cases, in - /// priority order: - /// - /// A single selected non-directory entry whose extension routes to a - /// recognized archive/filesystem format — either a real on-disk file - /// (OS-browser) or an archive nested inside the currently-open archive. - /// is set to it. - /// Otherwise the currently-open archive itself (which is a temp file - /// when the user has descended into a nested archive — so the verbs remain - /// available "even when this is an archive within another one"). - /// - /// Returns false (verb disabled) when neither applies or the registry - /// is not yet warm. + /// Resolves which container a maintenance command acts on: a single selected file whose + /// content proves it a container (), else the + /// archive the shell is inside. A name alone never decides — a plain f19.bin inside a FAT + /// volume is not a BIN/CUE image, and the volume stays the target. /// private bool TryResolveMaintenanceTarget(out string formatId, out ArchiveEntryViewModel? archiveEntry) { formatId = ""; @@ -833,127 +828,176 @@ private bool TryResolveMaintenanceTarget(out string formatId, out ArchiveEntryVi // fires CommandManager.InvalidateRequerySuggested when registration ends. if (!Compression.Lib.FormatRegistration.IsReady) return false; - if (SelectedEntries.Count == 1) { - var e = SelectedEntries[0]; - if (!e.IsDirectory && !e.IsParentEntry) { - // A file on disk is judged by its content (cached, since this runs on every requery); an - // entry inside an archive has no bytes at hand, so its name decides. - var probeName = IsBrowsingOsFolder ? e.Path : e.Name; - if (!string.IsNullOrEmpty(probeName)) { - var f = IsBrowsingOsFolder && File.Exists(e.Path) - ? FormatDetector.DetectCached(e.Path) - : FormatDetector.DetectByExtension(probeName); - if (f != FormatDetector.Format.Unknown && !FormatDetector.IsStreamFormat(f)) { - // OS-browser candidate must actually exist on disk. - if (!IsBrowsingOsFolder || File.Exists(e.Path)) { - formatId = f.ToString(); - archiveEntry = e; - return true; - } - } - } - } + if (ProveSelectedContainer() is { } selected) { + (formatId, archiveEntry) = (selected.Format.ToString(), selected.Entry); + return true; } - // Fall back to the open archive itself. - if (HasArchive && !string.IsNullOrEmpty(ArchivePath) && !string.IsNullOrEmpty(Format) - && Format != FormatDetector.Format.Unknown.ToString()) { - formatId = Format; + if (OpenVolumeFormat() is { } open) { + formatId = open; return true; } + return false; } + /// The open archive's format while the shell is inside it; a folder on disk being browsed is not the archive left behind. + private string? OpenVolumeFormat() + => HasArchive && !IsBrowsingOsFolder && !string.IsNullOrEmpty(ArchivePath) && !string.IsNullOrEmpty(Format) + && Format != FormatDetector.Format.Unknown.ToString() + ? Format + : null; + + // Content verdicts for entries of the open archive, so a requery does not reopen the entry. + private readonly Dictionary _entryFormats = new(StringComparer.Ordinal); + + /// How much of an entry is read to identify it when its stream cannot seek. + private const int EntryProbeBytes = 1 << 20; + /// - /// True when the resolved maintenance target's descriptor implements the - /// capability interface backing . Mirrors the action - /// availability inside so a context-menu - /// item is never offered for an op the window can't actually run. + /// The single selected file, when its content identifies it as a container format (not a bare + /// stream): on disk by , inside the open archive by + /// opening the entry and running on its head. /// - private bool CanMaintain(MaintenanceVerb verb) { - if (!TryResolveMaintenanceTarget(out var formatId, out _)) return false; - var ops = FormatRegistry.GetArchiveOps(formatId); - if (ops == null) return false; - return verb switch { - MaintenanceVerb.Optimize => ops is IArchiveCreatable or IFileInternalChunkMover, - MaintenanceVerb.Shrink => ops is IArchiveShrinkable || formatId is "Fat" or "Ext" or "Ext1" or "Vhd", - MaintenanceVerb.Defragment => ops is IArchiveDefragmentable, - MaintenanceVerb.Purge => ops is IArchiveModifiable, - MaintenanceVerb.WipeEmpty => ops is IWipeEmpty or IFilesystemExtentMap or IArchiveLayoutMap, - MaintenanceVerb.Compact => ops is IArchiveDefragmentable or IArchiveShrinkable or IArchiveCreatable, - MaintenanceVerb.Scramble => ops is IFilesystemScrambleable, - _ => false, - }; + private (FormatDetector.Format Format, ArchiveEntryViewModel Entry)? ProveSelectedContainer() { + if (SelectedEntries.Count != 1) return null; + var e = SelectedEntries[0]; + if (e.IsDirectory || e.IsParentEntry || string.IsNullOrEmpty(e.Path)) return null; + + FormatDetector.Format format; + if (IsBrowsingOsFolder) { + if (!File.Exists(e.Path)) return null; + format = FormatDetector.DetectCached(e.Path); + } else if (HasArchive && File.Exists(ArchivePath)) { + format = DetectEntryByContent(e); + } else { + return null; + } + + return format == FormatDetector.Format.Unknown || FormatDetector.IsStreamFormat(format) ? null : (format, e); + } + + private FormatDetector.Format DetectEntryByContent(ArchiveEntryViewModel entry) { + var key = $"{ArchivePath}|{File.GetLastWriteTimeUtc(ArchivePath).Ticks}|{entry.Path}"; + if (_entryFormats.TryGetValue(key, out var known)) return known; + + var format = FormatDetector.Format.Unknown; + try { + using var stream = ArchiveOperations.OpenEntry(ArchivePath, entry.Path, password: null); + if (stream.CanSeek) { + format = FormatDetector.DetectByContent(stream); + } else { + var head = new byte[EntryProbeBytes]; + var read = 0; + for (int n; read < head.Length && (n = stream.Read(head, read, head.Length - read)) > 0;) read += n; + using var prefix = new MemoryStream(head, 0, read, writable: false); + format = FormatDetector.DetectByContent(prefix); + } + } catch { + // An entry that cannot be opened or read is not a target; the open volume stays one. + } + + return _entryFormats[key] = format; + } + + /// The open volume as a maintenance target, when it supports any operation. + internal MaintenanceTarget? OpenVolumeMaintenanceTarget + => FormatRegistration.IsReady && OpenVolumeFormat() is { } format && TargetCapabilities.For(format).AnySupported + ? new(MaintenanceTargetKind.OpenVolume, $"{format}|{ArchivePath}", format, Path.GetFileName(ArchivePath), Entry: null) + : null; + + /// The selected file as a maintenance target, when its content proves it a container that supports any operation. + internal MaintenanceTarget? SelectedMaintenanceTarget { + get { + if (!FormatRegistration.IsReady || ProveSelectedContainer() is not var (format, entry)) return null; + var id = format.ToString(); + if (!TargetCapabilities.For(id).AnySupported) return null; + var key = $"{id}|{(IsBrowsingOsFolder ? entry.Path : ArchivePath + "::" + entry.Path)}"; + return new(MaintenanceTargetKind.Selected, key, id, entry.Name, entry); + } + } + + /// Every target the Defragment tab can offer now: the open volume first, then a proven selection. + internal IReadOnlyList MaintenanceTargets() { + var targets = new List(2); + if (OpenVolumeMaintenanceTarget is { } open) targets.Add(open); + if (SelectedMaintenanceTarget is { } selected && targets.All(t => t.Key != selected.Key)) targets.Add(selected); + return targets; } /// - /// Opens pre-targeted at - /// against the resolved target. For an archive nested - /// inside the open archive, the entry is extracted to a temp file, maintained - /// there, and (when the host is ) written back - /// via . + /// True when the resolved maintenance target supports . Asks the same + /// capability adapter the Defragment tab enables its ribbon from, so a context-menu item is never + /// offered for an operation the tab would then refuse. /// - private void OpenMaintenance(MaintenanceVerb verb) { - if (!TryResolveMaintenanceTarget(out _, out var entry)) return; + private bool CanMaintain(MaintenanceVerb verb) + => TryResolveMaintenanceTarget(out var formatId, out _) && TargetCapabilities.For(formatId).Verb(verb).Supported; - string targetPath; - Action? cleanup = null; - Action? writeBack = null; + /// Whether there is a maintenance target that supports at least one operation. + internal bool HasMaintenanceTarget => MaintenanceTargets().Count > 0; + + /// + /// Raised when a maintenance command asks for the Defragment tab: the operation to preselect (null + /// for none) and whether the command was aimed at the selection rather than the open volume. + /// + public event EventHandler? MaintenanceRequested; + + // The list's context menu acts on what is selected; the tab's own entry defaults to the open volume. + private void OpenMaintenance(MaintenanceVerb verb) => MaintenanceRequested?.Invoke(this, new(verb, PreferSelection: true)); + + /// + /// Opens for maintenance. A real file — on disk, or the open archive — is + /// maintained in place. An archive nested inside the open archive is extracted to a temporary file, + /// maintained there, and written back into the host after each change when the host is + /// ; the session deletes the copy when disposed. + /// + internal MaintenanceSession? OpenMaintenanceSession(MaintenanceTarget target) { + var (key, formatId, entry) = (target.Key, target.FormatId, target.Entry); if (entry != null && IsBrowsingOsFolder) { - // Real on-disk archive file — maintained in place. - targetPath = entry.Path; - if (string.IsNullOrEmpty(targetPath) || !File.Exists(targetPath)) return; - } else if (entry != null) { - // Nested archive entry inside the currently-open archive: materialise → - // maintain → write back into the host (if the host supports modification). + if (string.IsNullOrEmpty(entry.Path) || !File.Exists(entry.Path)) return null; + return new(key, entry.Path, formatId, entry.Name, _ => RefreshVisibleEntries(), cleanup: null); + } + + if (entry != null) { try { var bytes = ArchiveOperations.ExtractEntry(ArchivePath, entry.Path, password: null); - var temp = Path.Combine(Path.GetTempPath(), - $"cwb_maint_{Guid.NewGuid():N}_{Path.GetFileName(entry.Name)}"); + var temp = Path.Combine(Path.GetTempPath(), $"cwb_maint_{Guid.NewGuid():N}_{Path.GetFileName(entry.Name)}"); File.WriteAllBytes(temp, bytes); - targetPath = temp; - cleanup = () => { try { File.Delete(temp); } catch { /* best effort */ } }; var hostPath = ArchivePath; var entryName = entry.Path; var entryLabel = entry.Name; - if (FormatRegistry.GetArchiveOps(Format) is IArchiveModifiable) { - writeBack = () => { - try { - ArchiveOperations.Replace(hostPath, entryName, temp); - StatusText = $"Wrote maintained '{entryLabel}' back into {Path.GetFileName(hostPath)}."; - } catch (Exception ex) { - StatusText = $"Could not write '{entryLabel}' back into host archive: {ex.Message}"; - } - }; - } else { - StatusText = $"'{Format}' is read-only — '{entryLabel}' will be maintained in a copy that is not written back."; - } + var writable = FormatRegistry.GetArchiveOps(Format) is IArchiveModifiable; + if (!writable) + StatusText = $"'{Format}' is read-only — '{entryLabel}' is maintained in a copy that is not written back."; + + return new(key, temp, formatId, entryLabel, _ => { + if (!writable) return; + try { + ArchiveOperations.Replace(hostPath, entryName, temp); + StatusText = $"Wrote maintained '{entryLabel}' back into {Path.GetFileName(hostPath)}."; + } catch (Exception ex) { + StatusText = $"Could not write '{entryLabel}' back into host archive: {ex.Message}"; + } + + if (HasArchive) ReloadArchiveInPlace(); + }, cleanup: () => { + try { File.Delete(temp); } catch { /* best effort */ } + }); } catch (Exception ex) { StatusText = $"Cannot open nested archive '{entry.Name}': {ex.Message}"; - return; + return null; } - } else { - // The currently-open archive itself (top-level real file, or a descended - // nested temp — the latter mutates the temp only, matching the existing - // nested-edit limitation). - if (string.IsNullOrEmpty(ArchivePath) || !File.Exists(ArchivePath)) return; - targetPath = ArchivePath; } - var dlg = new DefragmentWindow(targetPath, verb); - dlg.ArchiveMutated += mutated => { - writeBack?.Invoke(); - if (writeBack != null && HasArchive) - ReloadArchiveInPlace(); // nested write-back → re-list the host - else if (HasArchive && string.Equals(mutated, ArchivePath, StringComparison.OrdinalIgnoreCase)) - ReloadArchiveInPlace(); - else if (IsBrowsingOsFolder) - RefreshVisibleEntries(); - }; - dlg.FormClosed += (_, _) => cleanup?.Invoke(); - dlg.Show(); + // The open archive itself: a top-level file, or a descended nested temp — the latter mutates the + // temp only, matching the existing nested-edit limitation. + if (string.IsNullOrEmpty(ArchivePath) || !File.Exists(ArchivePath)) return null; + var archivePath = ArchivePath; + return new(key, archivePath, formatId, Path.GetFileName(archivePath), mutated => { + if (HasArchive && string.Equals(mutated, ArchivePath, StringComparison.OrdinalIgnoreCase)) ReloadArchiveInPlace(); + }, cleanup: null); } /// diff --git a/Compression.NativeUI/ViewModels/MaintenanceSession.cs b/Compression.NativeUI/ViewModels/MaintenanceSession.cs new file mode 100644 index 000000000..ffdec6de1 --- /dev/null +++ b/Compression.NativeUI/ViewModels/MaintenanceSession.cs @@ -0,0 +1,32 @@ +namespace Compression.NativeUI.ViewModels; + +/// +/// One maintenance target opened for the Defragment tab: the file the operations work on, its format, +/// what to do after the file changed, and what to clean up afterwards. A nested archive is worked on +/// as a temporary copy; disposing the session deletes it. +/// +internal sealed class MaintenanceSession( + string key, + string imagePath, + string formatId, + string displayName, + Action onMutated, + Action? cleanup) : IDisposable { + private Action? _cleanup = cleanup; + + /// Identifies the target, as does. + public string Key { get; } = key; + + /// The file the operations read and write. + public string ImagePath { get; } = imagePath; + + public string FormatId { get; } = formatId; + + /// The name the user knows the target by. + public string DisplayName { get; } = displayName; + + /// Tells the shell the file changed, so it re-lists it and writes a nested copy back. + public void NotifyMutated() => onMutated(this.ImagePath); + + public void Dispose() => Interlocked.Exchange(ref this._cleanup, null)?.Invoke(); +} diff --git a/Compression.NativeUI/ViewModels/MaintenanceTarget.cs b/Compression.NativeUI/ViewModels/MaintenanceTarget.cs new file mode 100644 index 000000000..845fd87b7 --- /dev/null +++ b/Compression.NativeUI/ViewModels/MaintenanceTarget.cs @@ -0,0 +1,30 @@ +namespace Compression.NativeUI.ViewModels; + +/// What a maintenance target is to the shell: the volume it has open, or something selected. +internal enum MaintenanceTargetKind { + /// The archive or image the shell is inside. + OpenVolume, + + /// A selected file — on disk, or an entry of the open archive — whose content proved it a container. + Selected, +} + +/// +/// One thing the Defragment tab can work on, as the Target field lists it. +/// +/// Whether it is the open volume or a selection. +/// Identifies the target across requeries. +/// The format its content was detected as. +/// The file name the user knows it by. +/// The selected entry, for . +internal sealed record MaintenanceTarget(MaintenanceTargetKind Kind, string Key, string FormatId, string Name, ArchiveEntryViewModel? Entry) { + /// How the Target field shows it. + public string Label => this.Kind == MaintenanceTargetKind.OpenVolume + ? $"Open volume ({this.Name})" + : $"Selected: {this.Name} ({this.FormatId})"; +} + +/// A command asking for the Defragment tab. +/// The operation to preselect, or null for none. +/// Whether the command came from the selection (the list's context menu), so a proven selected container is the target rather than the open volume. +internal sealed record MaintenanceRequest(Maintenance.MaintenanceVerb? Verb, bool PreferSelection); diff --git a/Compression.NativeUI/Views/DefragmentView.cs b/Compression.NativeUI/Views/DefragmentView.cs new file mode 100644 index 000000000..2065e4c91 --- /dev/null +++ b/Compression.NativeUI/Views/DefragmentView.cs @@ -0,0 +1,235 @@ +using System.Drawing; +using Compression.NativeUI.Controls; +using Compression.NativeUI.Maintenance; +using Compression.Registry; +using Hawkynt.NativeForms; +using Hawkynt.NativeForms.Drawing; + +namespace Compression.NativeUI.Views; + +/// +/// The client area of the Defragment tab: the block map filling the window, an optional files panel +/// beside it, the colour key under it and one status line with the progress of whatever is running. +/// It only shows what it is given; decides what that is. +/// +internal sealed class DefragmentView : Panel { + private const int StatusHeight = 22; + private const int LegendHeight = 40; + private const int ProgressHeight = 4; + private const int FilesPanelWidth = 340; + private const int LogHeight = 120; + + private static readonly Font MonoFont = new("Cascadia Mono", 8f, FontStyle.Regular); + + private readonly Label _status = new() { Text = "—", Dock = DockStyle.Top, Height = StatusHeight }; + private readonly SplitContainer _split = new() { FixedPanel = FixedPanel.Panel2, Panel1MinSize = 160, Panel2MinSize = 200, Dock = DockStyle.Fill }; + private readonly Label _filesStatus = new() { Text = "—", ForeColor = Color.DimGray, Dock = DockStyle.Top, Height = 18 }; + private readonly DataGridView _files = new() { ReadOnly = true, ShowGridLines = true, Dock = DockStyle.Fill }; + private readonly LogView _log = new() { Font = MonoFont, Dock = DockStyle.Bottom, Height = LogHeight }; + private readonly LegendStrip _legend = new() { Dock = DockStyle.Bottom, Height = LegendHeight }; + private readonly ProgressBar _progress = new() { Minimum = 0, Maximum = 100, Dock = DockStyle.Bottom, Height = ProgressHeight }; + private TileContentsWindow? _tileWindow; + private bool _splitSized; + + public DefragmentView() { + this.BackColor = DefaultTheme.Instance.ControlBackground; + this.Map.TileClicked += this.OnTileClicked; + + AddColumn("Name", static o => ((FileRow)o!).Name, 150); + AddColumn("Size", static o => ((FileRow)o!).SizeDisplay, 70, (a, b) => ((FileRow)a!).Size.CompareTo(((FileRow)b!).Size)); + var fragments = AddColumn("Frag", static o => ((FileRow)o!).FragmentsDisplay, 40); + fragments.Alignment = ContentAlignment.MiddleCenter; + fragments.TooltipSelector = static _ => "How many separate stretches of the container this file's data occupies. 1 is contiguous; higher is fragmented. An em dash means the format offers no layout to count from."; + AddColumn("Method", static o => ((FileRow)o!).MethodDisplay, 70); + AddColumn("Modified", static o => ((FileRow)o!).ModifiedDisplay, 110, (a, b) => Nullable.Compare(((FileRow)a!).Modified, ((FileRow)b!).Modified)); + AddColumn("Class", static o => ((FileRow)o!).Class, 56); + + // Docked children claim their edges in reverse order of addition: the status line takes the + // top, the progress line the very bottom, the colour key sits above it and the map fills the rest. + this.Map.Dock = DockStyle.Fill; + this._split.Panel1.Controls.Add(this.Map); + this._split.Panel2.Controls.AddRange(this._files, this._log, this._filesStatus); + this.Controls.AddRange(this._split, this._legend, this._progress, this._status); + + DataGridViewColumn AddColumn(string header, Func selector, int width, Comparison? sort = null) { + var column = new DataGridViewColumn(header, selector) { Width = width }; + if (sort is not null) column.SortComparison = sort; + this._files.Columns.Add(column); + return column; + } + } + + /// The block map itself. + public BlockMapControl Map { get; } = new(); + + /// The line above the map: how honest the picture is, or what the running operation is doing. + public string StatusText { + get => this._statusText; + set { + this._statusText = value; + this.ShowStatusLine(); + } + } + + /// What is being maintained — "disk.img — FAT · 16.0 MB" — shown at the start of the status line. + public string TargetText { + get => this._targetText; + set { + this._targetText = value; + this.ShowStatusLine(); + } + } + + private string _statusText = "—"; + private string _targetText = ""; + + private void ShowStatusLine() + => this._status.Text = this._targetText.Length == 0 ? this._statusText : $"{this._targetText} │ {this._statusText}"; + + /// Whether the files panel shows beside the map. + public bool FilesPanelVisible { + get => !this._split.Panel2Collapsed; + set => this._split.Panel2Collapsed = !value; + } + + /// Whether the colour key shows under the map. + /// + /// Kept as its own flag: reports the effective state, which is false + /// for as long as the whole view is hidden behind the browser. + /// + public bool LegendVisible { + get => this._legendVisible; + set { + this._legendVisible = value; + this._legend.Visible = value; + this.PerformLayout(); + } + } + + private bool _legendVisible = true; + + /// Every line logged since the view was last reset. + public string LogText => this._log.LogText; + + public int ProgressValue { + get => this._progress.Value; + set => this._progress.Value = Math.Clamp(value, 0, 100); + } + + /// Shows an image at rest: its layout, its files and how the layout was obtained. + public void ShowSnapshot(BlockMapSnapshot snapshot) { + this.Map.ReadHead = -1; + this.Map.WriteHead = -1; + this.Map.BlockMap = snapshot.Map; + this.Map.ImageSize = snapshot.ImageSize; + this._files.DataSource = snapshot.Rows; + this._filesStatus.Text = snapshot.Rows.Count switch { + 0 => "—", + 1 => "1 file", + var n => $"{n:N0} files", + }; + this.StatusText = snapshot.Status; + } + + /// Moves the picture along with a running operation. + public void Apply(MaintenanceProgress progress) { + if (progress.Map is not null) { + this.Map.BlockMap = progress.Map; + this.Map.ImageSize = progress.ImageSize; + } else if (progress.ImageSize > 0 && this.Map.ImageSize <= 0) { + this.Map.ImageSize = progress.ImageSize; + } + + this.Map.ReadHead = progress.ReadHead; + this.Map.WriteHead = progress.WriteHead; + if (progress.Fraction >= 0) this.ProgressValue = (int)(Math.Clamp(progress.Fraction, 0, 1) * 100); + if (!string.IsNullOrWhiteSpace(progress.Status)) this.StatusText = progress.Status!; + } + + public void Log(string line) { + this._log.Append(line); + } + + /// Forgets the previous image: no map, no rows, no log. + public void Clear() { + this.ShowSnapshot(BlockMapSnapshot.Empty("—")); + this._log.Clear(); + this.ProgressValue = 0; + } + + /// + /// Opens the files panel at a fixed width, once, when the view first has a real size; from then on + /// the map takes whatever the window gains, and a width the user dragged to is theirs. + /// + public void SizeFilesPanelOnce() { + if (this._splitSized || this.Width <= FilesPanelWidth + this._split.Panel1MinSize) return; + this._splitSized = true; + this._split.SplitterDistance = this.Width - FilesPanelWidth - this._split.SplitterWidth; + } + + /// + /// Opens a modeless drill-down listing every block under the clicked tile. Modeless so the user can + /// keep clicking tiles to compare; each click re-targets the same window. + /// + private void OnTileClicked(object? sender, TileClickedEventArgs e) { + if (this._tileWindow is null) { + this._tileWindow = new TileContentsWindow(); + this._tileWindow.FormClosed += (_, _) => this._tileWindow = null; + } + + this._tileWindow.SetContents(e.StartOffset, e.EndOffset, e.Contents); + this._tileWindow.Show(); + } + + /// The block-map colour key. + private sealed class LegendStrip : OwnerDrawnControl { + private static readonly (Color Color, string Label)[] Swatches = [ + (Color.FromArgb(0xE6, 0x4A, 0x19), "Hot"), + (Color.FromArgb(0x42, 0xA5, 0xF5), "Normal"), + (Color.FromArgb(0x66, 0xBB, 0x6A), "Cold"), + (Color.FromArgb(0x90, 0xA4, 0xAE), "Frozen"), + (Color.FromArgb(0xDA, 0xA5, 0x20), "Directory"), + (Color.FromArgb(0xE0, 0xE0, 0xE0), "Free"), + (Color.FromArgb(0xC0, 0x30, 0x30), "Bad"), + (Color.FromArgb(0x70, 0x70, 0x70), "Reserved"), + ]; + + protected override void OnPaint(PaintEventArgs e) { + var g = e.Graphics; + var theme = DefaultTheme.Instance; + var font = theme.DefaultFont; + g.FillRectangle(theme.ControlBackground, new(0, 0, this.Width, this.Height)); + + // Rows and labels are measured, so the key stays whole at any display scaling. + var row = Math.Max(16, g.MeasureText("Ag", font).Height); + var x = 0; + var y = 2; + foreach (var (color, label) in Swatches) { + var text = g.MeasureText(label, font).Width; + if (!this.Fit(ref x, ref y, 14 + text + 12, row)) return; + g.FillRectangle(color, new(x, y + (row - 10) / 2, 10, 10)); + g.DrawText(label, font, theme.ControlText, new(x + 14, y, text + 4, row), ContentAlignment.MiddleLeft); + x += 14 + text + 12; + } + + // The two heads are lines rather than swatches, matching how they are drawn on the map. + foreach (var (color, label) in new[] { (Color.LimeGreen, "Read head"), (Color.Orange, "Write head") }) { + var text = g.MeasureText(label, font).Width; + if (!this.Fit(ref x, ref y, 8 + text + 12, row)) return; + g.DrawLine(color, x + 2, y + 2, x + 2, y + row - 2, 2); + g.DrawText(label, font, theme.ControlText, new(x + 8, y, text + 4, row), ContentAlignment.MiddleLeft); + x += 8 + text + 12; + } + } + + /// Wraps to the next row when does not fit; false once out of rows. + private bool Fit(ref int x, ref int y, int width, int row) { + if (x > 0 && x + width > this.Width) { + x = 0; + y += row + 2; + } + + return y + row <= this.Height; + } + } +} diff --git a/Compression.NativeUI/Views/DefragmentWindow.cs b/Compression.NativeUI/Views/DefragmentWindow.cs deleted file mode 100644 index 764b17b4f..000000000 --- a/Compression.NativeUI/Views/DefragmentWindow.cs +++ /dev/null @@ -1,2221 +0,0 @@ -using System.Diagnostics; -using System.Drawing; -using Compression.Core.Layout; -using Compression.Lib; -using Compression.Lib.Layout; -using Compression.NativeUI.Controls; -using Compression.Registry; -using Compression.Registry.Layout; -using Hawkynt.NativeForms; -using Hawkynt.NativeForms.Drawing; - -namespace Compression.NativeUI.Views; - -/// -/// The five canonical maintenance verbs from docs/ARCHIVE-MODEL.md, plus the two composites. -/// Used to pre-focus the matching action when the window is opened from a context-menu entry. -/// -internal enum MaintenanceVerb { - /// Find and apply the best layout, or re-encode the payload; size preserved where possible. - Optimize, - - /// Keep the parameter set; minimise the stored footprint. - Shrink, - - /// Re-order entries and extents so files are contiguous; size preserved. - Defragment, - - /// Erase all live data, leaving a valid empty container. - Purge, - - /// Overwrite only unused space — free clusters, slack, deleted entries; size preserved. - WipeEmpty, - - /// Defragment, optimize and shrink in one pass: the smallest valid container. - Compact, - - /// - /// Scatter every allocation block across the volume — fragmentation on purpose, so - /// has something real to undo. Content is preserved; only its place changes. - /// - Scramble, -} - -/// -/// One row of the file-list panel: name, size, fragment count, modification date and thermal class. -/// The fragment count comes from whatever layout the format offered; formats that offer none show an -/// em dash rather than a figure nothing measured. -/// -internal sealed class FileRow { - public string Name { get; init; } = ""; - public long Size { get; init; } - public string SizeDisplay { get; init; } = ""; - public string FragmentsDisplay { get; init; } = ""; - public string MethodDisplay { get; init; } = ""; - public DateTime? Modified { get; init; } - public string ModifiedDisplay { get; init; } = ""; - public string Class { get; init; } = ""; -} - -/// -/// The single surface behind the explorer's maintenance menu: shows the detected format and what it -/// supports, lets the user pick a layout strategy, and runs the descriptor's optimize, shrink, -/// defragment, purge, wipe, compact and scramble paths through the matching capability interface. -/// -internal sealed class DefragmentWindow : Form { - /// Cap on extents fed to the block map; adjacent same-kind regions merge beyond it. - private const int MaxExtents = 50_000; - - private static readonly Color HeadingColor = Color.FromArgb(0x44, 0x66, 0xAA); - private static readonly Font MonoFont = new("Cascadia Mono", 8f, FontStyle.Regular); - - // Image picker. - private readonly GroupBox _imageBox = new() { Text = "Image" }; - private readonly TextBox _imagePathBox = new() { ReadOnly = true, Text = "Select a filesystem image to defragment...", ForeColor = Color.Gray }; - private readonly Button _browse = new() { Text = "Browse..." }; - - // Format and capability summary. - private readonly Panel _infoPanel = new() { BorderStyle = BorderStyle.FixedSingle }; - private readonly Label _formatLabel = new() { Text = "—" }; - private readonly Label _supportLabel = new() { Text = "—" }; - private readonly Label _sizeLabel = new() { Text = "—" }; - - // Filesystem layout strategy. - private readonly GroupBox _fsModesGroup = new() { Text = "Layout strategy" }; - private readonly RadioButton _modePackStart = new() { Text = "Pack at start (default)", Checked = true }; - private readonly RadioButton _modePackEnd = new() { Text = "Pack at end" }; - private readonly RadioButton _modeFillHoles = new() { Text = "Fill holes (lazy)" }; - private readonly RadioButton _modeAscending = new() { Text = "Ascending order only" }; - private readonly RadioButton _modeCarveHole = new() { Text = "Carve a hole" }; - private readonly TextBox _interleaveStride = new() { Text = "1" }; - private readonly ComboBox _metadataZone = new() { DropDownStyle = ComboBoxStyle.DropDownList }; - private readonly ComboBox _layoutProfile = new() { DropDownStyle = ComboBoxStyle.DropDownList }; - private readonly Button _editProfiles = new() { Text = "Edit profiles…" }; - - // Archive repack. - private readonly GroupBox _archiveRepackGroup = new() { Text = "Archive optimization", Visible = false }; - private readonly Label _archiveRepackNote = new() { ForeColor = Color.DimGray }; - private readonly CheckBox _smartSolidRepack = new() { Text = "Smart solid-block repack (7z only)", Visible = false }; - private readonly ComboBox _metadataPlacement = new() { DropDownStyle = ComboBoxStyle.DropDownList, Visible = false }; - private readonly Label _metadataPlacementCaption = new() { Text = "Metadata placement:", Visible = false }; - - // Carve-hole options. - private readonly GroupBox _carveOptsGroup = new() { Text = "Carve-hole options", Visible = false }; - private readonly TextBox _holeSize = new() { Text = "64m" }; - private readonly TextBox _holeAt = new() { Text = "auto" }; - - // Block map and file panel. - private readonly SplitContainer _split = new(); - private readonly GroupBox _blockMapGroup = new() { Text = "Block map" }; - private readonly Label _layoutStatus = new() { Text = "—", ForeColor = Color.DimGray }; - private readonly ComboBox _blockMapView = new() { DropDownStyle = ComboBoxStyle.DropDownList }; - private readonly BlockMapControl _blockMap = new(); - private readonly LegendStrip _legend = new(); - private readonly CheckBox _filesPanelToggle = new() { Text = "Files panel", Checked = true }; - private readonly GroupBox _filesPanelGroup = new() { Text = "Files" }; - private readonly Label _filesPanelStatus = new() { Text = "—", ForeColor = Color.DimGray }; - private readonly DataGridView _filesGrid = new() { ReadOnly = true, ShowGridLines = true }; - - // Output and progress. - private readonly GroupBox _outputGroup = new() { Text = "Output" }; - private readonly TextBox _outputBox = new() { ReadOnly = true, Multiline = true, Font = MonoFont, BackColor = Color.FromArgb(0xFA, 0xFA, 0xFA) }; - private readonly ProgressBar _progress = new() { Minimum = 0, Maximum = 100 }; - - // Actions. - private readonly TextBox _scrambleSeed = new() { Text = "1", Enabled = false }; - private readonly Button _scramble = new() { Text = "Scramble", Enabled = false }; - private readonly Button _run = new() { Text = "Defragment", Enabled = false }; - private readonly Button _cancel = new() { Text = "Cancel", Enabled = false }; - private readonly Button _close = new() { Text = "Close" }; - private readonly CheckBox _minimalGeometry = new() { Text = "Minimal geometry", Enabled = false }; - private readonly Button _compact = new() { Text = "Compact", Enabled = false }; - private readonly Button _purge = new() { Text = "Purge", Enabled = false }; - private readonly Button _wipeEmpty = new() { Text = "Wipe Empty", Enabled = false }; - private readonly Button _shrink = new() { Text = "Shrink", Enabled = false }; - private readonly ToolTip _toolTips = new(); - - private readonly List _layoutProfileEntries = []; - - private MaintenanceVerb? _requestedVerb; - private string? _imagePath; - private IArchiveDefragmentable? _defragmentable; - private IArchiveFormatOperations? _archiveOps; - private string? _formatId; - private bool _isArchiveMode; - private bool _isFileInternalMode; - private bool _isSevenZipFormat; - private IFileInternalChunkMover? _chunkMover; - private int _fileRowCount; - private string _filesSortDescription = "listing order"; - private LayoutTemplate? _selectedLayoutProfile; - private TileContentsWindow? _tileContentsWindow; - - private CancellationTokenSource? _maintenanceCancellation; - private bool _maintenanceIsStaged; - private bool _maintenanceCommitStarted; - private string? _maintenanceOperationName; - - public DefragmentWindow() { - this.Text = "Maintenance — Optimize / Shrink / Defragment / Purge / Wipe"; - this.ClientSize = new(900, 780); - this.MinimumSize = new(720, 560); - this.StartPosition = FormStartPosition.CenterParent; - - this.BuildImageBox(); - this.BuildInfoPanel(); - this.BuildFsModes(); - this.BuildArchiveRepack(); - this.BuildCarveOptions(); - this.BuildBlockMapArea(); - this.BuildOutput(); - this.BuildActions(); - - this.RefreshLayoutProfilesCombo(); - this.FormClosing += this.OnFormClosing; - this.Resize += (_, _) => this.LayoutChildren(); - this.LayoutChildren(); - } - - public DefragmentWindow(string preselectedImage) : this() { - if (!string.IsNullOrEmpty(preselectedImage) && File.Exists(preselectedImage)) - this.LoadImage(preselectedImage); - } - - /// - /// Opens the window pre-targeted at : the matching action is highlighted - /// and focused so it runs on Enter. It deliberately does not auto-run — maintenance mutates the - /// image in place, so the user confirms by clicking. - /// - public DefragmentWindow(string preselectedImage, MaintenanceVerb verb) : this(preselectedImage) { - this._requestedVerb = verb; - this.ApplyRequestedVerb(); - this.CorrectArchiveOptimizeAmbiguity(); - } - - /// - /// Path of the image last successfully mutated by this window. Stays null while the window only - /// previews. - /// - public string? MutatedImagePath { get; private set; } - - /// - /// Raised once an operation has modified the image on disk, carrying the path that changed. Hosts - /// compare it against their loaded archive and reload if they match. - /// - public event Action? ArchiveMutated; - - private void NotifyMutated(string path) { - this.MutatedImagePath = path; - this.ArchiveMutated?.Invoke(path); - } - - // ── Construction ──────────────────────────────────────────────────────────────────────────── - - private void BuildImageBox() { - this._browse.Click += (_, _) => { - var dialog = new OpenFileDialog { - Title = "Select filesystem image or archive", - Filter = "Supported files|*.img;*.iso;*.d64;*.d71;*.d81;*.adf;*.dsk;*.po;*.atr;*.ssd;*.dsd;*.zip;*.7z;*.tar;*.lzh;*.arj" - + "|Filesystem images|*.img;*.iso;*.d64;*.d71;*.d81;*.adf;*.dsk;*.po;*.atr;*.ssd;*.dsd" - + "|Archives|*.zip;*.7z;*.tar;*.lzh;*.arj|All Files|*.*", - }; - if (dialog.ShowDialog() == DialogResult.OK) this.LoadImage(dialog.FileName); - }; - - this._imageBox.Controls.AddRange(this._imagePathBox, this._browse); - this.Controls.Add(this._imageBox); - } - - private void BuildInfoPanel() { - this._infoPanel.Controls.AddRange( - Caption("Format:", 0), this._formatLabel, - Caption("Defrag support:", 22), this._supportLabel, - Caption("Image size:", 44), this._sizeLabel); - this.Controls.Add(this._infoPanel); - - static Label Caption(string text, int y) => new() { - Bounds = new(8, y + 4, 120, 20), - Text = text, - Font = new(DefaultTheme.Instance.DefaultFont.Family, 9f, FontStyle.Bold), - }; - } - - private void BuildFsModes() { - var modes = new (RadioButton Button, string Detail)[] { - (this._modePackStart, "Move every file to the front of the image. Free space ends up after the last file. Closest match to a traditional defrag tool."), - (this._modePackEnd, "Move every file to the back of the image. Free space stays at the front. Useful before injecting a bootloader or installer payload at low offsets."), - (this._modeFillHoles, "Best-fit fill of existing free regions from tail extents. Doesn't guarantee contiguity but moves the minimum number of bytes. Use on huge images with a few small holes."), - (this._modeAscending, "Move only what reads backwards, so every file's blocks end up in ascending order and a sequential read never seeks back. A file left in three ascending pieces stays in three pieces — but this moves about a third of the bytes packing does, because it touches only what is out of order."), - (this._modeCarveHole, "Reserve a contiguous free region of the chosen size. Live data in the way is relocated to other free space, or appended."), - }; - - foreach (var (button, detail) in modes) { - this._toolTips.SetToolTip(button, detail); - button.CheckedChanged += (_, _) => this.OnModeChanged(); - this._fsModesGroup.Controls.Add(button); - } - - this._toolTips.SetToolTip(this._interleaveStride, - "Block interleave factor. 1 = contiguous (default), 2 = every-other-block, N = place each file's Kth block at (start + K*stride). Range 1-256."); - - this._metadataZone.Items.AddRange([ - "Don't move metadata", - "Metadata at front (fast access)", - "Metadata at back (data-first)", - "Metadata in middle (min seek)", - "Before file content (read-ahead)", - ]); - this._metadataZone.SelectedIndex = 0; - this._toolTips.SetToolTip(this._metadataZone, - "Controls where filesystem metadata and directory entries are placed during defragmentation."); - - this._layoutProfile.SelectedIndexChanged += (_, _) => this.OnLayoutProfileChanged(); - this._toolTips.SetToolTip(this._layoutProfile, - "Optional zone-based layout template. When set, files are placed into named byte ranges with per-zone sort orders. Overrides the layout strategy and metadata placement above."); - - this._editProfiles.Click += (_, _) => { - new LayoutProfileEditor().ShowDialog(this); - this.RefreshLayoutProfilesCombo(); - }; - this._toolTips.SetToolTip(this._editProfiles, "Open the layout profile editor to create, modify, or delete profiles."); - - this._fsModesGroup.Controls.AddRange( - new Label { Text = "Block interleave:" }, this._interleaveStride, - new Label { Text = "1 = contiguous, 2 = every-other-block, up to 256", ForeColor = Color.DimGray }, - new Label { Text = "Metadata placement:" }, this._metadataZone, - new Label { Text = "Layout profile:" }, this._layoutProfile, this._editProfiles); - - this.Controls.Add(this._fsModesGroup); - } - - private void BuildArchiveRepack() { - this._archiveRepackNote.Text = - "Archive repack: entries will be extracted and re-created with optimal compression settings. " - + "The layout strategy modes above do not apply to archive formats."; - this._toolTips.SetToolTip(this._smartSolidRepack, - "Tries 5 different file grouping strategies and picks the one that produces the smallest archive. More thorough but slower than a plain repack."); - - this._metadataPlacement.Items.AddRange(["Format default", "Metadata first", "Data first"]); - this._metadataPlacement.SelectedIndex = 0; - - this._archiveRepackGroup.Controls.AddRange( - this._archiveRepackNote, this._smartSolidRepack, this._metadataPlacementCaption, this._metadataPlacement); - this.Controls.Add(this._archiveRepackGroup); - } - - private void BuildCarveOptions() { - this._toolTips.SetToolTip(this._holeSize, "Size in bytes. Suffixes: k=KiB, m=MiB, g=GiB. Examples: 64m, 1g, 524288."); - this._toolTips.SetToolTip(this._holeAt, "Byte offset where the hole should start. 'auto' = at the end after the last live extent."); - - this._carveOptsGroup.Controls.AddRange( - new Label { Text = "Hole size:" }, this._holeSize, - new Label { Text = "(64m / 1g / 524288)", ForeColor = Color.DimGray }, - new Label { Text = "Hole offset:" }, this._holeAt, - new Label { Text = "(auto / decimal byte offset)", ForeColor = Color.DimGray }); - - this.Controls.Add(this._carveOptsGroup); - } - - private void BuildBlockMapArea() { - this._blockMapView.Items.AddRange(["Linear blocks", "Circular platter (2-D)", "Cylinder stack (3-D)"]); - this._blockMapView.SelectedIndex = 0; - this._blockMapView.SelectedIndexChanged += (_, _) => this._blockMap.ViewMode = this._blockMapView.SelectedIndex switch { - 1 => BlockMapView.CircularPlatter, - 2 => BlockMapView.CylinderStack, - _ => BlockMapView.LinearBlocks, - }; - - this._blockMap.TileClicked += this.OnBlockMapTileClicked; - this._toolTips.SetToolTip(this._filesPanelToggle, "Show or hide the file list side panel."); - this._filesPanelToggle.CheckedChanged += (_, _) => { - this._split.Panel2Collapsed = !this._filesPanelToggle.Checked; - this.LayoutChildren(); - }; - - this._blockMapGroup.Controls.AddRange( - this._layoutStatus, new Label { Text = "View:" }, this._blockMapView, - this._blockMap, this._legend, this._filesPanelToggle); - - AddColumn(this._filesGrid, "Name", static o => ((FileRow)o!).Name, 160); - AddColumn(this._filesGrid, "Size", static o => ((FileRow)o!).SizeDisplay, 80, (a, b) => ((FileRow)a!).Size.CompareTo(((FileRow)b!).Size)); - var fragments = AddColumn(this._filesGrid, "Frag", static o => ((FileRow)o!).FragmentsDisplay, 44); - fragments.Alignment = ContentAlignment.MiddleCenter; - fragments.TooltipSelector = static _ => "How many separate stretches of the container this file's data occupies. 1 is contiguous; higher is fragmented. An em dash means the format offers no layout to count from."; - AddColumn(this._filesGrid, "Method", static o => ((FileRow)o!).MethodDisplay, 80); - AddColumn(this._filesGrid, "Modified", static o => ((FileRow)o!).ModifiedDisplay, 120, (a, b) => Nullable.Compare(((FileRow)a!).Modified, ((FileRow)b!).Modified)); - AddColumn(this._filesGrid, "Class", static o => ((FileRow)o!).Class, 64); - - // The grid sorts itself; this only observes the result so the status line can report it. - this._filesGrid.CellClick += (_, _) => this.TrackSort(); - - this._filesPanelGroup.Controls.AddRange(this._filesPanelStatus, this._filesGrid); - - this._split.Panel1.Controls.Add(this._blockMapGroup); - this._split.Panel2.Controls.Add(this._filesPanelGroup); - this._blockMapGroup.Dock = DockStyle.Fill; - this._filesPanelGroup.Dock = DockStyle.Fill; - this._split.Panel2MinSize = 220; - this.Controls.Add(this._split); - } - - private static DataGridViewColumn AddColumn(DataGridView grid, string header, Func selector, int width, Comparison? sort = null) { - var column = new DataGridViewColumn(header, selector) { Width = width }; - if (sort is not null) column.SortComparison = sort; - grid.Columns.Add(column); - return column; - } - - private void BuildOutput() { - this._outputGroup.Controls.Add(this._outputBox); - this.Controls.AddRange(this._outputGroup, this._progress); - } - - private void BuildActions() { - this._toolTips.SetToolTip(this._scrambleSeed, "Seeds Scramble's shuffle. The same seed deals the same layout every run."); - this._toolTips.SetToolTip(this._scramble, "Scatter every block of every file across the volume — fragmentation on purpose, so Defragment has something real to work against. Content is preserved; only the layout changes. Asks first."); - this._toolTips.SetToolTip(this._cancel, "Cancel the active maintenance pass. Staged rebuilds are discarded and leave the original unchanged; native in-place moves cancel at a safe boundary when supported."); - this._toolTips.SetToolTip(this._minimalGeometry, "Compact rebuilds at the smallest geometry the format allows (e.g. a 1.44 MB FAT floppy → a few KB). Contents preserved, but the result may not be a standard mountable image."); - this._toolTips.SetToolTip(this._compact, "Defragment + optimize + shrink in one pass — the smallest valid container that still holds the same contents. Tick 'Minimal geometry' for the bare-minimum (non-standard) rebuild."); - this._toolTips.SetToolTip(this._purge, "Erase ALL live data, leaving a valid empty container. Distinct from Wipe Empty (which only zeros dead space)."); - this._toolTips.SetToolTip(this._wipeEmpty, "Zero-fill all unused space: free clusters, cluster-tip slack, dead bytes. Forensic cleanliness tool."); - this._toolTips.SetToolTip(this._shrink, "Defragment + truncate trailing free space to minimize image size."); - - this._run.Click += (_, _) => this.OnRunWithBlockProgress(); - this._cancel.Click += (_, _) => this.RequestMaintenanceCancellation(confirmNativeInPlace: true); - this._scramble.Click += (_, _) => this.OnScramble(); - this._compact.Click += (_, _) => this.OnCompact(); - this._purge.Click += (_, _) => this.OnPurge(); - this._wipeEmpty.Click += (_, _) => this.OnWipeEmpty(); - this._shrink.Click += (_, _) => this.OnShrink(); - this._close.Click += (_, _) => this.Close(); - - this.Controls.AddRange( - new Label { Text = "Seed" }, this._scrambleSeed, this._scramble, this._run, this._cancel, this._close, - this._minimalGeometry, this._compact, this._purge, this._wipeEmpty, this._shrink); - this.AcceptButton = this._run; - } - - // ── Layout ────────────────────────────────────────────────────────────────────────────────── - - private void LayoutChildren() { - const int Gutter = 12; - var width = this.ClientSize.Width - 2 * Gutter; - - this._imageBox.Bounds = new(Gutter, Gutter, width, 52); - this._imagePathBox.Bounds = new(8, 20, Math.Max(80, width - 110), 24); - this._browse.Bounds = new(width - 96, 20, 88, 24); - - var y = Gutter + 60; - this._infoPanel.Bounds = new(Gutter, y, width, 70); - this._formatLabel.Bounds = new(132, 4, width - 140, 20); - this._supportLabel.Bounds = new(132, 26, width - 140, 20); - this._sizeLabel.Bounds = new(132, 48, width - 140, 20); - - y += 78; - var strategyHeight = this._fsModesGroup.Visible ? 240 : 0; - this._fsModesGroup.Bounds = new(Gutter, y, width, 240); - this.LayoutFsModes(width); - - this._archiveRepackGroup.Bounds = new(Gutter, y, width, 110); - this._archiveRepackNote.Bounds = new(8, 20, width - 16, 34); - this._smartSolidRepack.Bounds = new(8, 58, 300, 20); - this._metadataPlacementCaption.Bounds = new(8, 82, 120, 20); - this._metadataPlacement.Bounds = new(132, 80, 180, 24); - if (this._archiveRepackGroup.Visible) strategyHeight = 110; - - y += strategyHeight + (strategyHeight > 0 ? 8 : 0); - if (this._carveOptsGroup.Visible) { - this._carveOptsGroup.Bounds = new(Gutter, y, width, 76); - LayoutCarveRow(this._carveOptsGroup, 0, 20, width); - LayoutCarveRow(this._carveOptsGroup, 3, 46, width); - y += 84; - } - - var actionsHeight = 76; - var outputHeight = 84; - var mapHeight = Math.Max(160, this.ClientSize.Height - y - outputHeight - actionsHeight - Gutter); - this._split.Bounds = new(Gutter, y, width, mapHeight); - if (!this._split.Panel2Collapsed) this._split.SplitterDistance = Math.Max(240, width * 2 / 3); - - this._layoutStatus.Bounds = new(6, 18, Math.Max(50, this._blockMapGroup.Width - 12), 18); - this._blockMapGroup.Controls[1].Bounds = new(6, 40, 36, 20); // "View:" - this._blockMapView.Bounds = new(44, 38, 200, 24); - this._legend.Bounds = new(6, this._blockMapGroup.Height - 46, Math.Max(50, this._blockMapGroup.Width - 130), 40); - this._filesPanelToggle.Bounds = new(this._blockMapGroup.Width - 120, this._blockMapGroup.Height - 36, 110, 22); - this._blockMap.Bounds = new(6, 66, Math.Max(50, this._blockMapGroup.Width - 12), Math.Max(60, this._blockMapGroup.Height - 66 - 50)); - - this._filesPanelStatus.Bounds = new(4, 18, Math.Max(50, this._filesPanelGroup.Width - 8), 18); - this._filesGrid.Bounds = new(4, 40, Math.Max(50, this._filesPanelGroup.Width - 8), Math.Max(40, this._filesPanelGroup.Height - 46)); - - y += mapHeight + 8; - this._outputGroup.Bounds = new(Gutter, y, width, outputHeight - 8); - this._outputBox.Bounds = new(6, 18, Math.Max(50, width - 12), outputHeight - 32); - this._progress.Bounds = new(Gutter, y + outputHeight - 12, width, 4); - - this.LayoutActionButtons(Gutter, this.ClientSize.Height - actionsHeight + 8, width); - - static void LayoutCarveRow(GroupBox box, int first, int top, int width) { - box.Controls[first].Bounds = new(8, top + 2, 110, 20); - box.Controls[first + 1].Bounds = new(122, top, Math.Max(60, width - 260), 22); - box.Controls[first + 2].Bounds = new(width - 130, top + 2, 124, 20); - } - } - - private void LayoutFsModes(int width) { - var controls = this._fsModesGroup.Controls; - var y = 20; - - foreach (var button in new[] { this._modePackStart, this._modePackEnd, this._modeFillHoles, this._modeAscending, this._modeCarveHole }) { - button.Bounds = new(8, y, Math.Max(100, width - 20), 20); - y += 24; - } - - y += 6; - controls[5].Bounds = new(8, y + 2, 118, 20); // "Block interleave:" - this._interleaveStride.Bounds = new(128, y, 76, 22); - controls[7].Bounds = new(212, y + 2, 300, 20); // hint - - y += 28; - controls[8].Bounds = new(8, y + 2, 118, 20); // "Metadata placement:" - this._metadataZone.Bounds = new(128, y, 200, 24); - - y += 30; - controls[10].Bounds = new(8, y + 2, 118, 20); // "Layout profile:" - this._layoutProfile.Bounds = new(128, y, Math.Max(120, width - 280), 24); - this._editProfiles.Bounds = new(width - 140, y, 120, 24); - } - - private void LayoutActionButtons(int gutter, int top, int width) { - // Second row first, so the primary actions sit closest to the bottom edge. - var right = gutter + width; - var x = right - 108; - this._shrink.Bounds = new(x, top, 100, 28); - x -= 108; - this._wipeEmpty.Bounds = new(x, top, 100, 28); - x -= 108; - this._purge.Bounds = new(x, top, 100, 28); - x -= 108; - this._compact.Bounds = new(x, top, 100, 28); - x -= 140; - this._minimalGeometry.Bounds = new(x, top + 4, 134, 20); - - var second = top + 34; - x = right - 88; - this._close.Bounds = new(x, second, 80, 28); - x -= 88; - this._cancel.Bounds = new(x, second, 80, 28); - x -= 128; - this._run.Bounds = new(x, second, 120, 28); - x -= 108; - this._scramble.Bounds = new(x, second, 100, 28); - x -= 68; - this._scrambleSeed.Bounds = new(x, second + 3, 60, 22); - this.Controls[^11].Bounds = new(x - 40, second + 5, 36, 20); // "Seed" - } - - // ── Layout profiles ───────────────────────────────────────────────────────────────────────── - - private void RefreshLayoutProfilesCombo() { - var previousPath = this._layoutProfile.SelectedIndex >= 0 && this._layoutProfile.SelectedIndex < this._layoutProfileEntries.Count - ? this._layoutProfileEntries[this._layoutProfile.SelectedIndex]?.FilePath - : null; - - this._layoutProfile.Items.Clear(); - this._layoutProfileEntries.Clear(); - - this._layoutProfile.Items.Add("(none)"); - this._layoutProfileEntries.Add(null); - foreach (var entry in LayoutProfileStore.List()) { - this._layoutProfile.Items.Add($"{entry.Name} [{(entry.Origin == ProfileOrigin.Builtin ? "Built-in" : "User")}]"); - this._layoutProfileEntries.Add(entry); - } - - // Preserve the user's previous pick across refreshes. - if (!string.IsNullOrEmpty(previousPath)) - for (var i = 0; i < this._layoutProfileEntries.Count; ++i) - if (string.Equals(this._layoutProfileEntries[i]?.FilePath, previousPath, StringComparison.OrdinalIgnoreCase)) { - this._layoutProfile.SelectedIndex = i; - return; - } - - this._layoutProfile.SelectedIndex = 0; - } - - private void OnLayoutProfileChanged() { - var index = this._layoutProfile.SelectedIndex; - if (index < 0 || index >= this._layoutProfileEntries.Count || this._layoutProfileEntries[index] is not { } entry) { - this._selectedLayoutProfile = null; - return; - } - - try { - this._selectedLayoutProfile = LayoutProfileStore.Load(entry); - } catch (Exception ex) { - MessageBox.Show(this, $"Failed to load layout profile '{entry.Name}':\n{ex.Message}", - "Layout profile", MessageBoxButtons.OK, MessageBoxIcon.Error); - this._selectedLayoutProfile = null; - this._layoutProfile.SelectedIndex = 0; - } - } - - // ── Image loading ─────────────────────────────────────────────────────────────────────────── - - private void ApplyRequestedVerb() { - if (this._requestedVerb is not { } verb) return; - - this.Text = verb switch { - MaintenanceVerb.Optimize => "Optimize", - MaintenanceVerb.Shrink => "Shrink", - MaintenanceVerb.Defragment => "Defragment", - MaintenanceVerb.Purge => "Purge", - MaintenanceVerb.WipeEmpty => "Wipe Empty", - MaintenanceVerb.Compact => "Compact", - MaintenanceVerb.Scramble => "Scramble", - _ => "Maintenance", - }; - - var target = verb switch { - MaintenanceVerb.Shrink => this._shrink, - MaintenanceVerb.Purge => this._purge, - MaintenanceVerb.WipeEmpty => this._wipeEmpty, - MaintenanceVerb.Compact => this._compact, - MaintenanceVerb.Scramble => this._scramble, - // Optimize and Defragment both live on the morphing Run button. - _ => this._run, - }; - - if (!target.Enabled) return; - this.AcceptButton = target; - target.Focus(); - } - - /// - /// The loader sees before archive-repack support. When the - /// caller explicitly asked to optimize, correct that ambiguity so ZIP and 7z expose their repack - /// controls instead of looking like filesystem-only defraggers. - /// - private void CorrectArchiveOptimizeAmbiguity() { - if (this._requestedVerb != MaintenanceVerb.Optimize || this._formatId is not { Length: > 0 } id) return; - - var descriptor = FormatRegistry.GetById(id); - var ops = FormatRegistry.GetArchiveOps(id); - if (descriptor?.Category is not (FormatCategory.Archive or FormatCategory.CompoundTar) || ops is not IArchiveCreatable) return; - - this._isArchiveMode = true; - this._archiveOps = ops; - this._isSevenZipFormat = string.Equals(id, "SevenZip", StringComparison.Ordinal); - this._fsModesGroup.Visible = false; - this._archiveRepackGroup.Visible = true; - this._smartSolidRepack.Visible = this._isSevenZipFormat; - this._run.Text = "Optimize"; - this._run.Enabled = true; - this._supportLabel.Text = "Archive re-layout/repack with live staged-target visualization."; - this._supportLabel.ForeColor = Color.DarkGreen; - this._layoutStatus.Text = "Source + staged-target address spaces share the chart for progress; offsets are projected, not physical equivalence."; - this.LayoutChildren(); - } - - private void LoadImage(string path) { - this._imagePath = path; - this._isArchiveMode = false; - this._isFileInternalMode = false; - this._isSevenZipFormat = false; - this._archiveOps = null; - this._chunkMover = null; - - this._imagePathBox.Text = path; - this._imagePathBox.ForeColor = Color.Black; - - FormatRegistration.EnsureInitialized(); - var format = FormatDetector.Detect(path); - this._formatLabel.Text = format.ToString(); - this._formatId = format.ToString(); - - var ops = FormatRegistry.GetArchiveOps(this._formatId); - this._defragmentable = ops as IArchiveDefragmentable; - - var isArchiveLayout = ops is IArchiveLayoutMap; - var isArchiveCreatable = ops is IArchiveCreatable; - var isFileInternalLayout = ops is IFileInternalLayoutMap; - var isFileInternalOptimizable = ops is IFileInternalChunkMover; - - if (this._defragmentable is not null) { - var supported = ProbeSupportedModes(this._defragmentable); - this._supportLabel.Text = supported.Count >= 4 - ? "All four modes (in-place defragment)." - : $"{string.Join(", ", supported)} (other modes throw NotSupported)"; - this._supportLabel.ForeColor = Color.DarkGreen; - this._run.Text = "Defragment"; - this._run.Enabled = true; - } else if (isFileInternalLayout) { - this._isFileInternalMode = true; - this._archiveOps = ops; - this._chunkMover = ops as IFileInternalChunkMover; - this._supportLabel.Text = isFileInternalOptimizable - ? "File-internal layout optimization (e.g. MP4 fast-start)." - : "File-internal layout viewable but optimization not supported (read-only)."; - this._supportLabel.ForeColor = isFileInternalOptimizable ? Color.DarkGreen : Color.DarkOrange; - this._run.Enabled = isFileInternalOptimizable; - this._run.Text = "Optimize"; - } else if (isArchiveLayout || isArchiveCreatable) { - this._isArchiveMode = true; - this._isSevenZipFormat = this._formatId == "SevenZip"; - this._archiveOps = ops; - this._supportLabel.Text = isArchiveCreatable - ? "Archive optimization (extract + repack with optimal settings)." - : "Archive layout viewable but format does not support creation (read-only)."; - this._supportLabel.ForeColor = isArchiveCreatable ? Color.DarkGreen : Color.DarkOrange; - this._run.Enabled = isArchiveCreatable; - this._run.Text = "Optimize"; - } else { - this._supportLabel.Text = "Not supported by this format."; - this._supportLabel.ForeColor = Color.OrangeRed; - this._run.Text = "Defragment"; - this._run.Enabled = false; - } - - var showFsModes = !this._isArchiveMode && !this._isFileInternalMode; - this._fsModesGroup.Visible = showFsModes; - this._archiveRepackGroup.Visible = !showFsModes; - this._smartSolidRepack.Visible = this._isSevenZipFormat; - this._metadataPlacement.Visible = this._isFileInternalMode; - this._metadataPlacementCaption.Visible = this._isFileInternalMode; - - this._shrink.Enabled = this._formatId is "Fat" or "Ext" or "Ext1" or "Vhd" || ops is IArchiveShrinkable; - this._wipeEmpty.Enabled = ops is IWipeEmpty or IFilesystemExtentMap or IArchiveLayoutMap; - // Purge is realised through the modifier's Remove over every entry. - this._purge.Enabled = ops is IArchiveModifiable; - this._compact.Enabled = ops is IArchiveDefragmentable or IArchiveShrinkable or IArchiveCreatable; - this._minimalGeometry.Enabled = ops is IArchiveCreatable and IFormatOptionsSchema; - - // Scramble has no fallback: a rebuild would pack the volume, the opposite of what it says. - var canScramble = ops is IFilesystemScrambleable; - this._scramble.Enabled = canScramble; - this._scrambleSeed.Enabled = canScramble; - - var info = new FileInfo(path); - this._sizeLabel.Text = $"{FormatSize(info.Length)} ({info.Length:N0} bytes)"; - - this.PreviewBlockMap(path, ops); - this.ApplyRequestedVerb(); - this.LayoutChildren(); - } - - // ── Block map preview ─────────────────────────────────────────────────────────────────────── - - /// - /// Builds the initial block-map snapshot. A descriptor that exposes a real extent, archive or - /// chunk layout gets the honest picture; anything else falls back to sizes laid end to end from - /// zero, which the status line labels as approximate. - /// - private void PreviewBlockMap(string path, IArchiveFormatOperations? ops, bool wasMutated = false) { - if (wasMutated) this.NotifyMutated(path); - - this._blockMap.BlockMap = null; - this._blockMap.ImageSize = 0; - this._filesGrid.DataSource = Array.Empty(); - this._fileRowCount = 0; - this._filesSortDescription = "listing order"; - this._filesPanelStatus.Text = "—"; - this._layoutStatus.Text = "—"; - if (ops is null) return; - - if (ops is IFilesystemExtentMap extentMap) - try { - using var stream = File.OpenRead(path); - if (this.TryRenderExtentMap(extentMap, stream, ops)) return; - } catch { - // Extent walker failed mid-stream; fall through so the user still sees something. - } - - if (ops is IArchiveLayoutMap archiveLayout) - try { - using var stream = File.OpenRead(path); - if (this.TryRenderArchiveLayout(archiveLayout, stream, ops)) return; - } catch { - // Layout walker failed; fall through to the approximation. - } - - if (ops is IFileInternalLayoutMap fileLayout) - try { - using var stream = File.OpenRead(path); - if (this.TryRenderFileInternalLayout(fileLayout, stream)) return; - } catch { - // Chunk walker failed; fall through to the approximation. - } - - this._layoutStatus.Text = "Approximate layout (post-defrag preview)"; - try { - using var stream = File.OpenRead(path); - var entries = ops.List(stream, password: null); - var imageSize = stream.Length; - var map = new List(); - var rows = new List(); - var offset = 0L; - - var files = entries.Where(e => !e.IsDirectory).ToList(); - var thresholds = ComputeMtimeQuartiles(files.Select(f => f.LastModified)); - - for (var k = 0; k < files.Count; ++k) { - var e = files[k]; - var size = Math.Max(1, e.OriginalSize); - var cls = Classify(e.LastModified, thresholds, k, files.Count); - - map.Add(new(offset, size, DefragBlockKind.Used, e.Name, cls)); - rows.Add(new() { - Name = e.Name, - Size = e.OriginalSize, - SizeDisplay = FormatSize(e.OriginalSize), - // The format reported no layout, so these tiles are sizes laid end to end rather than - // where anything actually is. Counting runs off that would report one for everything. - FragmentsDisplay = "—", - Modified = e.LastModified, - ModifiedDisplay = e.LastModified is { } dt ? dt.ToString("yyyy-MM-dd HH:mm") : "—", - Class = cls.ToString(), - }); - - offset += size; - if (offset >= imageSize) break; - } - - if (offset < imageSize) map.Add(new(offset, imageSize - offset, DefragBlockKind.Free)); - - this._blockMap.BlockMap = map; - this._blockMap.ImageSize = imageSize; - this.SetFileRows(rows); - } catch { - // Preview is best-effort; an empty map is an acceptable outcome. - } - } - - /// - /// Splits the modification times into four equal-count buckets. The most recent quarter is hot, - /// the oldest is frozen. - /// - private static (DateTime? Cold, DateTime? Normal, DateTime? Hot)? ComputeMtimeQuartiles(IEnumerable times) { - var sorted = times.Where(t => t.HasValue).Select(t => t!.Value).Order().ToList(); - if (sorted.Count == 0) return null; - - var n = sorted.Count; - return (sorted[Math.Min(n - 1, n / 4)], sorted[Math.Min(n - 1, n / 2)], sorted[Math.Min(n - 1, 3 * n / 4)]); - } - - private static DefragBlockClass Classify(DateTime? modified, (DateTime? Cold, DateTime? Normal, DateTime? Hot)? thresholds, int index, int count) { - if (thresholds is not { } t) - // No dates anywhere — fall back to listing-order quartile so the chart still has structure. - return (index * 4 / Math.Max(1, count)) switch { - 0 => DefragBlockClass.Hot, - 1 => DefragBlockClass.Normal, - 2 => DefragBlockClass.Cold, - _ => DefragBlockClass.Frozen, - }; - - // An undated entry in a dated set stands out as frozen rather than being guessed at. - if (modified is not { } when_) return DefragBlockClass.Frozen; - - if (when_ < t.Cold) return DefragBlockClass.Frozen; - if (when_ < t.Normal) return DefragBlockClass.Cold; - return when_ < t.Hot ? DefragBlockClass.Normal : DefragBlockClass.Hot; - } - - private bool TryRenderExtentMap(IFilesystemExtentMap extentMap, Stream stream, IArchiveFormatOperations ops) { - var imageSize = stream.Length; - if (imageSize <= 0) return false; - - var filled = NormalizeExtents(extentMap.EnumerateExtents(stream).ToList(), imageSize); - if (filled is null) return false; - - Dictionary? mtimeByName = null; - List? entries = null; - try { - stream.Position = 0; - entries = ops.List(stream, password: null); - mtimeByName = entries.Where(e => !e.IsDirectory) - .GroupBy(e => e.Name) - .ToDictionary(g => g.Key, g => g.First().LastModified); - } catch { - // Classification is best-effort; the layout itself is what matters here. - } - - var thresholds = mtimeByName is null ? null : ComputeMtimeQuartiles(mtimeByName.Values); - var classified = new List(filled.Count); - foreach (var ex in filled) { - if (ex.Kind != DefragBlockKind.Used || ex.FileName is null || mtimeByName is null - || !mtimeByName.TryGetValue(ex.FileName, out var mtime)) { - classified.Add(ex); - continue; - } - - classified.Add(ex with { Classification = thresholds is null ? DefragBlockClass.Normal : Classify(mtime, thresholds, 0, 1) }); - } - - this._blockMap.BlockMap = classified.Count > MaxExtents ? BinExtents(classified, MaxExtents) : classified; - this._blockMap.ImageSize = imageSize; - - var fragCount = CountRunsByOwner(classified); - if (entries is not null) - this.SetFileRows([.. entries.Where(e => !e.IsDirectory).Select(e => new FileRow { - Name = e.Name, - Size = e.OriginalSize, - SizeDisplay = FormatSize(e.OriginalSize), - FragmentsDisplay = fragCount.TryGetValue(e.Name, out var runs) && runs > 0 ? runs.ToString("N0") : "—", - Modified = e.LastModified, - ModifiedDisplay = e.LastModified is { } dt ? dt.ToString("yyyy-MM-dd HH:mm") : "—", - Class = "—", - })]); - - var usedExtents = classified.Count(ex => ex.Kind == DefragBlockKind.Used); - var fragmentedFiles = entries is null ? 0 : fragCount.Values.Count(runs => runs > 1); - this._layoutStatus.Text = fragmentedFiles > 0 - ? $"Real on-disk layout — {usedExtents:N0} extents, {fragmentedFiles:N0} fragmented file(s)" - : $"Real on-disk layout — {usedExtents:N0} extents (no fragmentation detected)"; - return true; - } - - private bool TryRenderArchiveLayout(IArchiveLayoutMap archiveLayout, Stream stream, IArchiveFormatOperations? ops) { - var imageSize = stream.Length; - if (imageSize <= 0) return false; - - var filled = NormalizeExtents(archiveLayout.EnumerateLayout(stream).ToList(), imageSize); - if (filled is null) return false; - - Dictionary? methodByName = null; - List? entries = null; - try { - stream.Position = 0; - entries = ops?.List(stream, password: null); - if (entries is not null) - methodByName = entries.Where(e => !e.IsDirectory) - .GroupBy(e => e.Name) - .ToDictionary(g => g.Key, g => g.First().Method ?? ""); - } catch { - // Classification is best-effort. - } - - var classified = new List(filled.Count); - foreach (var ex in filled) { - if (ex.Kind != DefragBlockKind.Used || ex.FileName is null || methodByName is null - || !methodByName.TryGetValue(ex.FileName, out var method)) { - classified.Add(ex); - continue; - } - - classified.Add(ex with { Classification = ClassifyByMethod(method) }); - } - - this._blockMap.BlockMap = classified.Count > MaxExtents ? BinExtents(classified, MaxExtents) : classified; - this._blockMap.ImageSize = imageSize; - - if (entries is not null) { - // Counted off the layout the container reported. An entry the map does not name is not one - // run — it is unmeasured. - var runsByName = CountRunsByOwner(classified); - this.SetFileRows([.. entries.Where(e => !e.IsDirectory).Select(e => new FileRow { - Name = e.Name, - Size = e.OriginalSize, - SizeDisplay = FormatSize(e.OriginalSize), - FragmentsDisplay = runsByName.TryGetValue(e.Name, out var runs) ? runs.ToString("N0") : "—", - MethodDisplay = e.Method ?? "", - Modified = e.LastModified, - ModifiedDisplay = e.LastModified is { } dt ? dt.ToString("yyyy-MM-dd HH:mm") : "", - Class = ClassifyByMethod(e.Method ?? "").ToString(), - })]); - } - - var regionCount = classified.Count(ex => ex.Kind == DefragBlockKind.Used); - var freeBytes = classified.Where(ex => ex.Kind == DefragBlockKind.Free).Sum(ex => ex.Length); - this._layoutStatus.Text = freeBytes > 0 - ? $"Real archive layout — {regionCount:N0} regions, {FormatSize(freeBytes)} wasted" - : $"Real archive layout — {regionCount:N0} regions (tightly packed)"; - return true; - } - - private bool TryRenderFileInternalLayout(IFileInternalLayoutMap fileLayout, Stream stream) { - var imageSize = stream.Length; - if (imageSize <= 0) return false; - - var filled = NormalizeExtents(fileLayout.EnumerateChunks(stream).ToList(), imageSize); - if (filled is null) return false; - - this._blockMap.BlockMap = filled; - this._blockMap.ImageSize = imageSize; - - // A row here is one stretch of the file, so the count belongs to the chunk's owner and comes - // from the same layout the map was drawn from. - var chunkRuns = CountRunsByOwner(filled, onlyKind: null); - this.SetFileRows([.. filled.Select(ch => new FileRow { - Name = ch.FileName ?? ch.Kind.ToString(), - Size = ch.Length, - SizeDisplay = FormatSize(ch.Length), - FragmentsDisplay = ch.FileName is { } owner && chunkRuns.TryGetValue(owner, out var runs) ? runs.ToString("N0") : "—", - MethodDisplay = ch.Kind.ToString(), - Class = ch.Classification?.ToString() ?? "—", - })]); - - this._layoutStatus.Text = $"File-internal layout — {filled.Count(c => c.Kind != DefragBlockKind.Free):N0} chunks"; - return true; - } - - /// - /// Sorts extents by offset, clips them to the image, fills the gaps as free space and trims - /// overlaps so a later extent at the same offset wins. Returns null when nothing usable remains. - /// - private static List? NormalizeExtents(List raw, long imageSize) { - if (raw.Count == 0) return null; - raw.Sort(static (a, b) => a.Offset.CompareTo(b.Offset)); - - var clipped = new List(raw.Count); - foreach (var ex in raw) { - if (ex.Offset >= imageSize) continue; - var length = Math.Min(ex.Length, imageSize - ex.Offset); - if (length <= 0) continue; - clipped.Add(ex with { Length = length }); - } - if (clipped.Count == 0) return null; - - var filled = new List(clipped.Count * 2); - var cursor = 0L; - foreach (var ex in clipped) { - if (ex.Offset > cursor) - filled.Add(new(cursor, ex.Offset - cursor, DefragBlockKind.Free)); - else if (ex.Offset < cursor) { - if (ex.Offset + ex.Length <= cursor) continue; - var trimmed = ex with { Length = ex.Length - (cursor - ex.Offset), Offset = cursor }; - filled.Add(trimmed); - cursor = trimmed.Offset + trimmed.Length; - continue; - } - - filled.Add(ex); - cursor = ex.Offset + ex.Length; - } - - if (cursor < imageSize) filled.Add(new(cursor, imageSize - cursor, DefragBlockKind.Free)); - return filled; - } - - /// Maps a compression method name to a thermal class for colour-coding archive entries. - private static DefragBlockClass ClassifyByMethod(string method) { - var m = method.ToUpperInvariant(); - if (m.Contains("STORE") || m.Contains("COPY") || m is "NONE" or "") return DefragBlockClass.Frozen; - if (m.Contains("LZMA") || m.Contains("PPMD") || m.Contains("BZIP")) return DefragBlockClass.Hot; - if (m.Contains("ZSTD")) return DefragBlockClass.Cold; - return DefragBlockClass.Normal; - } - - /// - /// Bins an oversized extent list down to by merging adjacent - /// same-kind extents, then — if still over budget — by equal-sized byte bins taking each bin's - /// dominant kind. - /// - private static List BinExtents(List input, int maxCount) { - if (input.Count <= maxCount) return input; - - var merged = new List(input.Count); - foreach (var ex in input) { - if (merged.Count == 0) { - merged.Add(ex); - continue; - } - - var last = merged[^1]; - if (last.Kind == ex.Kind && last.Offset + last.Length == ex.Offset && last.FileName == ex.FileName) - merged[^1] = last with { Length = last.Length + ex.Length }; - else - merged.Add(ex); - } - if (merged.Count <= maxCount) return merged; - - var imageSize = merged[^1].Offset + merged[^1].Length; - var bytesPerBin = (double)imageSize / maxCount; - var binned = new List(maxCount); - var binStart = 0L; - var binIndex = 0; - var binKind = DefragBlockKind.Free; - var binBestLength = 0L; - string? binBestName = null; - - foreach (var ex in merged) { - while (ex.Offset + ex.Length > binStart + bytesPerBin && binIndex < maxCount - 1) { - if (ex.Length > binBestLength) { - binBestLength = ex.Length; - binKind = ex.Kind; - binBestName = ex.FileName; - } - - binned.Add(new(binStart, (long)bytesPerBin, binKind, binBestName)); - binStart += (long)bytesPerBin; - ++binIndex; - binKind = DefragBlockKind.Free; - binBestLength = 0; - binBestName = null; - } - - if (ex.Length > binBestLength) { - binBestLength = ex.Length; - binKind = ex.Kind; - binBestName = ex.FileName; - } - } - - binned.Add(new(binStart, imageSize - binStart, binKind, binBestName)); - return binned; - } - - /// - /// How many separate stretches of the container each owner's data occupies. - /// - /// Counting the entries a map yields would measure the map rather than the layout: some merge an - /// owner's consecutive blocks into one entry and some emit one per block, so a contiguous file and - /// a scattered one would report the same figure. Stretches that end exactly where the next begins - /// are one run either way. - /// - /// - private static Dictionary CountRunsByOwner( - IEnumerable extents, DefragBlockKind? onlyKind = DefragBlockKind.Used) { - var runs = new Dictionary(StringComparer.Ordinal); - var endOf = new Dictionary(StringComparer.Ordinal); - - foreach (var extent in extents) { - if (onlyKind is { } wanted && extent.Kind != wanted) continue; - if (extent.FileName is not { } owner) continue; - - if (!endOf.TryGetValue(owner, out var previousEnd) || previousEnd != extent.Offset) { - runs.TryGetValue(owner, out var count); - runs[owner] = count + 1; - } - - endOf[owner] = extent.Offset + extent.Length; - } - - return runs; - } - - private void SetFileRows(List rows) { - this._filesGrid.DataSource = rows; - this._fileRowCount = rows.Count; - this._filesSortDescription = "listing order"; - this.UpdateFilesPanelStatus(); - } - - /// - /// Refreshes the row-count and ordering line above the file grid, so a very large filesystem still - /// gives a sense of scale that scrolling alone does not. - /// - private void UpdateFilesPanelStatus() { - if (this._fileRowCount <= 0) { - this._filesPanelStatus.Text = "—"; - return; - } - - var countLabel = this._fileRowCount == 1 ? "1 file" : $"{this._fileRowCount:N0} files"; - this._filesPanelStatus.Text = $"{countLabel} · sorted by {this._filesSortDescription}"; - } - - private void TrackSort() { - if (this._filesGrid.SortedColumn is not { } column) return; - var arrow = this._filesGrid.SortOrder == SortOrder.Ascending ? "↑" : "↓"; - this._filesSortDescription = $"{column.HeaderText} {arrow}"; - this.UpdateFilesPanelStatus(); - } - - /// - /// Opens a modeless drill-down listing every block intersecting the clicked tile. Modeless so the - /// user can keep clicking tiles to compare; each click re-targets the same window. - /// - private void OnBlockMapTileClicked(object? sender, TileClickedEventArgs e) { - this._tileContentsWindow ??= CreateTileWindow(this); - this._tileContentsWindow.SetContents(e.StartOffset, e.EndOffset, e.Contents); - this._tileContentsWindow.Show(); - - TileContentsWindow CreateTileWindow(Form owner) { - var window = new TileContentsWindow(); - window.FormClosed += (_, _) => this._tileContentsWindow = null; - return window; - } - } - - private void OnModeChanged() { - this._carveOptsGroup.Visible = this._modeCarveHole.Checked; - this.LayoutChildren(); - } - - // ── Operation plumbing ────────────────────────────────────────────────────────────────────── - - private void OnFormClosing(object? sender, FormClosingEventArgs e) { - if (e.Cancel || this._maintenanceCancellation is null) return; - e.Cancel = true; - this.RequestMaintenanceCancellation(confirmNativeInPlace: true); - } - - private CancellationToken BeginMaintenanceOperation(string name, bool staged) { - this._maintenanceCancellation?.Dispose(); - this._maintenanceCancellation = new(); - this._maintenanceOperationName = name; - this._maintenanceIsStaged = staged; - this._maintenanceCommitStarted = false; - - this._cancel.Enabled = true; - foreach (var button in new[] { this._browse, this._run, this._shrink, this._wipeEmpty, this._purge, this._compact }) - button.Enabled = false; - - if (staged) - this._layoutStatus.Text = $"{name}: building a staged target — original remains unchanged until commit."; - - return this._maintenanceCancellation.Token; - } - - private void EndMaintenanceOperation() { - this._maintenanceCancellation?.Dispose(); - this._maintenanceCancellation = null; - this._maintenanceOperationName = null; - this._maintenanceIsStaged = false; - this._maintenanceCommitStarted = false; - this._cancel.Enabled = false; - this._browse.Enabled = true; - - // Reloading restores every button to what the format supports, rather than to whatever the last - // operation happened to leave behind. - if (this._imagePath is { Length: > 0 } path && File.Exists(path)) this.LoadImage(path); - } - - private void RequestMaintenanceCancellation(bool confirmNativeInPlace) { - if (this._maintenanceCancellation is not { } cts || cts.IsCancellationRequested) return; - - if (this._maintenanceCommitStarted) { - this.Append("Cancellation ignored: verified target commit has started; completing it is safer than interrupting the only commit step."); - this._cancel.Enabled = false; - return; - } - - if (!this._maintenanceIsStaged && confirmNativeInPlace) { - var answer = MessageBox.Show(this, - $"Cancel {this._maintenanceOperationName ?? "maintenance"}?\n\n" - + "This operation is moving data in place. Moves already completed are not rolled back. " - + "Cancellation is best-effort at the next safe boundary, so the layout may be partially changed although the container should remain valid.", - "Cancel in-place maintenance", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - if (answer != DialogResult.Yes) return; - this.Append("Cancellation requested — already-completed in-place moves will remain moved."); - } else { - this.Append("Cancellation requested — staged target will be discarded; existing archive remains unchanged."); - } - - this._cancel.Enabled = false; - cts.Cancel(); - } - - private void OnRunWithBlockProgress() { - if (this._imagePath is null || this._maintenanceCancellation is not null) return; - - if (this._isFileInternalMode) { - this.OnRunFileInternalOptimize(); - return; - } - - var ops = this._formatId is { Length: > 0 } id ? FormatRegistry.GetArchiveOps(id) : this._archiveOps; - var descriptor = this._formatId is { Length: > 0 } formatId ? FormatRegistry.GetById(formatId) : null; - var explicitlyOptimizingArchive = this._requestedVerb == MaintenanceVerb.Optimize - && descriptor?.Category is FormatCategory.Archive or FormatCategory.CompoundTar - && ops is IArchiveCreatable; - - if (this._isArchiveMode || explicitlyOptimizingArchive) { - this.RunArchiveOptimizeWithBlockProgress(ops); - return; - } - - this.RunDefragWithBlockProgress(); - } - - private void RunDefragWithBlockProgress() { - if (this._imagePath is not { } path || this._defragmentable is not { } defragmentable) return; - - var mode = this.SelectedMode(); - var holeSize = mode == DefragMode.CarveHole ? ParseSize(this._holeSize.Text) : 0L; - var holeAt = mode == DefragMode.CarveHole ? ParseHoleAt(this._holeAt.Text) : -1L; - var interleaveStride = ParseInterleaveStride(this._interleaveStride.Text); - var metadataZone = this.SelectedMetadataZone(); - var layoutProfile = this._selectedLayoutProfile; - var staged = UsesGenericStagedDefrag(defragmentable); - var token = this.BeginMaintenanceOperation("Defragment / re-layout", staged); - - this.Append($"=== {DateTime.Now:HH:mm:ss} Defragmenting {Path.GetFileName(path)} ==="); - this.Append($"Mode: {mode}"); - if (staged) this.Append("Strategy: verified staged rebuild; original is unchanged until commit."); - if (interleaveStride > 1) this.Append($"Block interleave: {interleaveStride}"); - if (metadataZone != MetadataZone.Unchanged) this.Append($"Metadata zone: {metadataZone}"); - if (layoutProfile is not null) this.Append($"Layout profile: {layoutProfile.Name} ({layoutProfile.Zones.Count} zone(s))"); - if (mode == DefragMode.CarveHole) { - this.Append($"Hole size: {holeSize:N0} bytes"); - this.Append($"Hole at: {(holeAt < 0 ? "auto (end)" : holeAt.ToString("N0"))}"); - } - - this._progress.Style = ProgressBarStyle.Blocks; - this._progress.Value = 0; - - Task.Run(() => { - var sw = Stopwatch.StartNew(); - Exception? error = null; - var cancelled = false; - string? finalStatus = null; - - void OnProgress(DefragProgressEvent ev) { - if (ev.Phase == "complete" && !string.IsNullOrWhiteSpace(ev.Status)) finalStatus = ev.Status; - this.BeginInvoke(() => { - if (ev.Phase == "committing") { - this._maintenanceCommitStarted = true; - this._cancel.Enabled = false; - } - - if (ev.BlockMap is not null) { - this._blockMap.BlockMap = ev.BlockMap; - this._blockMap.ImageSize = ev.ImageSize; - } else if (ev.ImageSize > 0 && this._blockMap.ImageSize <= 0) { - this._blockMap.ImageSize = ev.ImageSize; - } - - this._blockMap.ReadHead = ev.CurrentReadOffset; - this._blockMap.WriteHead = ev.CurrentWriteOffset; - if (ev.Fraction >= 0) this._progress.Value = (int)(Math.Clamp(ev.Fraction, 0, 1) * 100); - if (!string.IsNullOrWhiteSpace(ev.Status)) this._layoutStatus.Text = ev.Status; - }); - } - - try { - using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite); - defragmentable.Defragment(stream, new DefragOptions { - Mode = mode, - HoleSize = holeSize, - HoleAt = holeAt, - InterleaveStride = interleaveStride, - MetadataZonePlacement = metadataZone, - LayoutTemplate = layoutProfile, - OnProgress = OnProgress, - CancellationToken = token, - }); - } catch (OperationCanceledException) { - cancelled = true; - } catch (Exception ex) { - error = ex; - } - sw.Stop(); - - this.Invoke(() => { - this._blockMap.ReadHead = -1; - this._blockMap.WriteHead = -1; - this._progress.Value = 100; - - if (cancelled) - this.Append(staged - ? $"CANCELLED ({sw.ElapsedMilliseconds} ms) — staged rebuild discarded; existing container unchanged." - : $"CANCELLED ({sw.ElapsedMilliseconds} ms) — completed native moves remain in place."); - else if (error is not null) - this.Append($"FAILED ({sw.ElapsedMilliseconds} ms): {error.GetType().Name}: {error.Message}"); - else { - this.Append($"OK ({sw.ElapsedMilliseconds} ms)"); - if (token.IsCancellationRequested) - this.Append("Cancellation was requested, but the native operation completed before reaching a cancellable boundary."); - if (!string.IsNullOrWhiteSpace(finalStatus)) this.Append($"Status: {finalStatus}"); - if (mode == DefragMode.ConsolidateAtEnd) - this.Append("Tip: image byte size doesn't change for in-place end-pack — see the block chart for the new layout."); - this.NotifyMutated(path); - } - - this.Append(""); - this.EndMaintenanceOperation(); - }); - }); - } - - /// - /// True when the descriptor inherits the generic staged rebuild rather than moving blocks itself, - /// which is what makes a cancellation safe to discard. - /// - private static bool UsesGenericStagedDefrag(IArchiveDefragmentable defragmentable) { - var type = defragmentable.GetType(); - return type.GetMethod(nameof(IArchiveDefragmentable.Defragment), [typeof(Stream)]) is null - && type.GetMethod(nameof(IArchiveDefragmentable.Defragment), [typeof(Stream), typeof(DefragOptions)]) is null; - } - - private void RunArchiveOptimizeWithBlockProgress(IArchiveFormatOperations? ops) { - if (this._imagePath is not { } path || ops is null) return; - - if (this._isSevenZipFormat && this._smartSolidRepack.Checked) { - this.RunSmartSevenZipWithBlockProgress(ops); - return; - } - - // CVF formats have a dedicated per-cluster optimizer whose writer already chooses compress or - // store per cluster; trying every method at the container level would only waste CPU. - if (this._formatId is "DoubleSpace" or "DriveSpace" or "DriveSpace3") { - this.OnRunCvfOptimize(path, this._formatId); - return; - } - - var originalSize = new FileInfo(path).Length; - var tempOut = path + ".opt.tmp"; - AtomicFileWriter.TryDelete(tempOut); - var token = this.BeginMaintenanceOperation("Archive optimize / repack", staged: true); - this.SetStagedArchiveMap(path, ops, originalSize); - - this.Append($"=== {DateTime.Now:HH:mm:ss} Optimizing {Path.GetFileName(path)} ==="); - this.Append("Staged rebuild: green head = projected source consumption; orange head = staged-target bytes written."); - this._progress.Style = ProgressBarStyle.Blocks; - this._progress.Value = 0; - - Task.Run(() => { - var sw = Stopwatch.StartNew(); - Exception? error = null; - var cancelled = false; - var newSize = originalSize; - var entriesOptimized = 0; - - try { - var worker = Task.Run(() => ArchiveOperations.Optimize(path, tempOut, password: null)); - while (!worker.Wait(100)) { - var stagedBytes = FindStagedOutputLength(tempOut); - var fraction = originalSize > 0 ? Math.Clamp((double)stagedBytes / originalSize, 0, 0.95) : 0; - this.BeginInvoke(() => { - this._progress.Value = (int)(fraction * 100); - var displaySize = Math.Max(1L, this._blockMap.ImageSize > 0 ? this._blockMap.ImageSize : originalSize); - this._blockMap.ReadHead = Math.Clamp((long)(fraction * displaySize), 0, displaySize - 1); - this._blockMap.WriteHead = stagedBytes > 0 ? Math.Clamp(stagedBytes, 0, displaySize - 1) : -1; - this._layoutStatus.Text = token.IsCancellationRequested - ? "Cancellation pending — current codec unit will finish, then the staged target is discarded." - : $"Rebuilding staged target — {FormatSize(stagedBytes)} written; original unchanged."; - }); - } - - var result = worker.GetAwaiter().GetResult(); - newSize = result.OptimizedSize; - entriesOptimized = result.EntriesOptimized; - - if (token.IsCancellationRequested) { - cancelled = true; - } else { - this.Invoke(() => { - this._maintenanceCommitStarted = true; - this._cancel.Enabled = false; - this._progress.Value = 99; - this._layoutStatus.Text = "Staged target complete — committing; cancellation is no longer safe."; - }); - AtomicFileWriter.ReplaceTarget(tempOut, path); - } - } catch (OperationCanceledException) { - cancelled = true; - } catch (Exception ex) { - error = ex; - } finally { - AtomicFileWriter.TryDelete(tempOut); - } - sw.Stop(); - - this.Invoke(() => { - this._blockMap.ReadHead = -1; - this._blockMap.WriteHead = -1; - this._progress.Value = 100; - - if (cancelled) - this.Append($"CANCELLED ({sw.ElapsedMilliseconds} ms) — staged target discarded; existing archive unchanged."); - else if (error is not null) - this.Append($"FAILED ({sw.ElapsedMilliseconds} ms): {error.GetType().Name}: {error.Message}"); - else { - var delta = newSize - originalSize; - var pct = originalSize > 0 ? (double)delta / originalSize * 100 : 0; - this.Append($"OK ({sw.ElapsedMilliseconds} ms) — {entriesOptimized} entries re-encoded"); - this.Append($"Archive size: {originalSize:N0} -> {newSize:N0} bytes (Δ {delta:+#,#;-#,#;0}, {pct:+0.0;-0.0;0.0}%)"); - this.NotifyMutated(path); - } - - this.Append(""); - this.EndMaintenanceOperation(); - }); - }); - } - - private void RunSmartSevenZipWithBlockProgress(IArchiveFormatOperations ops) { - if (this._imagePath is not { } path) return; - - var originalSize = new FileInfo(path).Length; - var token = this.BeginMaintenanceOperation("7z solid-block re-group", staged: true); - this.SetStagedArchiveMap(path, ops, originalSize); - - this.Append($"=== {DateTime.Now:HH:mm:ss} Smart solid-block repack: {Path.GetFileName(path)} ==="); - this.Append("All candidate layouts are staged. Cancel discards them and leaves the current 7z untouched."); - this._progress.Style = ProgressBarStyle.Blocks; - this._progress.Value = 0; - - Task.Run(() => { - var sw = Stopwatch.StartNew(); - Exception? error = null; - var cancelled = false; - FileFormat.SevenZip.SolidBlockOptimizer.OptimizeResult? result = null; - - try { - using var fs = File.OpenRead(path); - result = FileFormat.SevenZip.SolidBlockOptimizer.Optimize( - fs, - maxTrials: 5, - onProgress: (index, total, name) => this.BeginInvoke(() => this.Append($" Trying strategy {index + 1}/{total}: {name}...")), - onDetailedProgress: detail => this.BeginInvoke(() => { - var displaySize = Math.Max(1L, this._blockMap.ImageSize > 0 ? this._blockMap.ImageSize : originalSize); - double fraction; - - switch (detail.Phase) { - case "extracting": - fraction = 0.30 * detail.BytesDone / Math.Max(1.0, detail.BytesTotal); - this._blockMap.ReadHead = Math.Clamp((long)(detail.BytesDone / Math.Max(1.0, detail.BytesTotal) * displaySize), 0, displaySize - 1); - this._blockMap.WriteHead = -1; - break; - case "strategy": - fraction = 0.30 + 0.10 * detail.Current / Math.Max(1.0, detail.Total); - this._blockMap.ReadHead = -1; - break; - case "building": - fraction = 0.40 + 0.55 * detail.Current / Math.Max(1.0, detail.Total); - this._blockMap.ReadHead = -1; - this._blockMap.WriteHead = Math.Clamp((long)(detail.Current / Math.Max(1.0, detail.Total) * displaySize), 0, displaySize - 1); - break; - default: - fraction = 0; - break; - } - - this._progress.Value = (int)(Math.Clamp(fraction, 0, 0.95) * 100); - this._layoutStatus.Text = detail.Phase switch { - "extracting" => $"Reading source entry: {detail.Name}", - "strategy" => $"Planning solid grouping: {detail.Name}", - "building" => $"Building staged solid candidate: {detail.Name}", - _ => "Staged 7z regrouping", - }; - }), - cancellationToken: token); - } catch (OperationCanceledException) { - cancelled = true; - } catch (Exception ex) { - error = ex; - } - sw.Stop(); - - this.Invoke(() => { - this._blockMap.ReadHead = -1; - this._blockMap.WriteHead = -1; - this._progress.Value = 100; - - if (cancelled) { - this.Append($"CANCELLED ({sw.ElapsedMilliseconds} ms) — candidate regroup discarded; existing 7z unchanged."); - } else if (error is not null) { - this.Append($"FAILED ({sw.ElapsedMilliseconds} ms): {error.GetType().Name}: {error.Message}"); - } else if (result is not null) { - foreach (var trial in result.Trials) - this.Append($" {trial.StrategyName}: {FormatSize(trial.OutputSize)} ({trial.Elapsed.TotalMilliseconds:F0} ms)"); - - var newSize = (long)result.Data.Length; - var delta = newSize - originalSize; - var pct = originalSize > 0 ? (double)delta / originalSize * 100 : 0; - this.Append($" Winner: {result.WinningStrategy}"); - this.Append($"Archive size: {originalSize:N0} -> {newSize:N0} bytes ({delta:+#,#;-#,#;0}, {pct:+0.0;-0.0;0.0}%)"); - - if (newSize < originalSize) { - this._maintenanceCommitStarted = true; - this._cancel.Enabled = false; - this._layoutStatus.Text = "Winning staged layout selected — committing; cancellation disabled."; - try { - AtomicFileWriter.WriteAllBytesAtomic(path, result.Data); - this.Append("Optimized archive written."); - this.NotifyMutated(path); - } catch (Exception writeError) { - this.Append($"FAILED while committing winner: {writeError.Message}"); - } - } else { - this.Append("No strategy improved on the original size; archive unchanged."); - } - } - - this.Append(""); - this.EndMaintenanceOperation(); - }); - }); - } - - private void OnRunCvfOptimize(string path, string formatId) { - var ops = this._archiveOps; - var descriptor = FormatRegistry.GetById(formatId); - if (descriptor is null) { - this.Append($"FAILED: format descriptor '{formatId}' not registered."); - this.Append(""); - this._run.Enabled = true; - return; - } - - Task.Run(() => { - Exception? error = null; - CvfOptimizer.OptimizeResult? result = null; - try { - result = CvfOptimizer.Optimize(path, descriptor); - } catch (Exception ex) { - error = ex; - } - - this.Invoke(() => { - this._progress.Value = 100; - this._run.Enabled = true; - this._blockMap.ReadHead = -1; - this._blockMap.WriteHead = -1; - - if (error is not null || result is null) { - this.Append($"FAILED: {error?.GetType().Name}: {error?.Message}"); - } else { - var delta = result.OptimizedSize - result.OriginalSize; - var pct = result.OriginalSize > 0 ? (double)delta / result.OriginalSize * 100 : 0; - this.Append($"Optimized via {result.MethodUsed}: {result.OriginalSize / (1024.0 * 1024.0):F2} MB → {result.OptimizedSize / (1024.0 * 1024.0):F2} MB (Δ {pct:+0.0;-0.0;0.0}%)"); - if (result.FilesCompressed >= 0 || result.FilesStoredVerbatim >= 0) - this.Append($"Cluster mix: {result.FilesCompressed} compressed, {result.FilesStoredVerbatim} stored verbatim (per-cluster shrink-or-store fallback)"); - this.Append($"Elapsed: {result.Elapsed.TotalMilliseconds:F0} ms"); - this.NotifyMutated(path); - } - - this.Append(""); - this.PreviewBlockMap(path, ops, wasMutated: error is null); - }); - }); - } - - private void OnRunFileInternalOptimize() { - if (this._imagePath is not { } path || this._chunkMover is not { } chunkMover) return; - - var ops = this._archiveOps; - var placementProfile = this.SelectedMetadataPlacement(); - - this.Append($"=== {DateTime.Now:HH:mm:ss} Optimizing {Path.GetFileName(path)} (file-internal) ==="); - if (placementProfile is not null) this.Append($"Metadata placement: {placementProfile.Name}"); - - this._run.Enabled = false; - this._progress.Style = ProgressBarStyle.Marquee; - - Task.Run(() => { - var sw = Stopwatch.StartNew(); - Exception? error = null; - var originalSize = new FileInfo(path).Length; - - try { - using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite); - chunkMover.Optimize(stream, placementProfile); - } catch (Exception ex) { - error = ex; - } - sw.Stop(); - var newSize = File.Exists(path) ? new FileInfo(path).Length : 0L; - - this.Invoke(() => { - this._progress.Style = ProgressBarStyle.Blocks; - this._progress.Value = 100; - this._run.Enabled = true; - this._blockMap.ReadHead = -1; - this._blockMap.WriteHead = -1; - - if (error is not null) - this.Append($"FAILED ({sw.ElapsedMilliseconds} ms): {error.GetType().Name}: {error.Message}"); - else { - this.Append($"OK ({sw.ElapsedMilliseconds} ms)"); - this.Append($"File size: {originalSize:N0} -> {newSize:N0} bytes (Δ {newSize - originalSize:+#,#;-#,#;0})"); - } - - this.Append(""); - this.PreviewBlockMap(path, ops, wasMutated: error is null); - }); - }); - } - - /// - /// Projects the archive's entries onto the image's byte span, weighted by size, so the staged - /// rebuild has something to animate against before any output exists. - /// - private void SetStagedArchiveMap(string path, IArchiveFormatOperations ops, long displaySize) { - try { - using var stream = File.OpenRead(path); - var entries = ops.List(stream, null).Where(e => !e.IsDirectory).ToArray(); - if (entries.Length == 0) return; - - var weights = entries.Select(e => Math.Max(1L, e.OriginalSize)).ToArray(); - var totalWeight = Math.Max(1L, weights.Sum()); - var size = Math.Max(1L, displaySize); - var map = new List(entries.Length); - long cumulative = 0; - long offset = 0; - - for (var i = 0; i < entries.Length; ++i) { - cumulative += weights[i]; - var end = i == entries.Length - 1 ? size : (long)((double)cumulative / totalWeight * size); - end = Math.Clamp(end, offset, size); - if (end > offset) - map.Add(new(offset, end - offset, DefragBlockKind.Used, entries[i].Name, ClassifyByMethod(entries[i].Method ?? ""))); - offset = end; - } - - this._blockMap.BlockMap = map; - this._blockMap.ImageSize = size; - this._blockMap.ReadHead = 0; - this._blockMap.WriteHead = -1; - this._layoutStatus.Text = "Staged rebuild projection — colored target blocks; green source head / orange target head use separate byte-spaces."; - } catch { - // The existing preview stays usable when a synthetic target map cannot be built. - } - } - - /// - /// The optimizer writes through its own temporary file, so the staged size is the largest of the - /// target and any sibling scratch file it is currently filling. - /// - private static long FindStagedOutputLength(string target) { - try { - var best = File.Exists(target) ? new FileInfo(target).Length : 0L; - var directory = Path.GetDirectoryName(target); - if (string.IsNullOrEmpty(directory)) directory = Directory.GetCurrentDirectory(); - - foreach (var candidate in Directory.EnumerateFiles(directory, Path.GetFileName(target) + ".tmp.*")) - best = Math.Max(best, new FileInfo(candidate).Length); - - return best; - } catch { - return 0; - } - } - - // ── Individual verbs ──────────────────────────────────────────────────────────────────────── - - private void OnShrink() { - if (this._imagePath is not { } path) return; - - var formatId = this._formatLabel.Text; - var ops = FormatRegistry.GetArchiveOps(formatId); - - this.Append($"=== {DateTime.Now:HH:mm:ss} Shrinking {Path.GetFileName(path)} ==="); - this._shrink.Enabled = false; - this._run.Enabled = false; - this._progress.Style = ProgressBarStyle.Blocks; - this._progress.Value = 0; - - Task.Run(() => { - var sw = Stopwatch.StartNew(); - Exception? error = null; - var originalSize = new FileInfo(path).Length; - long newSize = 0; - var summary = ""; - - try { - this.BeginInvoke(() => this._progress.Value = 20); - - if (formatId == "Fat") { - using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite); - var result = FileSystem.Fat.FatShrinkHelper.Shrink(stream); - newSize = result.NewSize; - summary = result.WasReduced - ? $"Reduced: {FormatSize(result.OriginalSize)} -> {FormatSize(result.NewSize)}" - : "No reduction (image was already compact)"; - } else if (formatId is "Ext" or "Ext1") { - using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite); - var result = FileSystem.Ext.ExtShrinkHelper.Shrink(stream); - newSize = result.NewSize; - summary = result.WasReduced - ? $"Reduced: {FormatSize(result.OriginalSize)} -> {FormatSize(result.NewSize)}" - : "No reduction (image was already compact)"; - } else if (formatId == "Vhd") { - using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite); - var result = FileFormat.Vhd.VhdCompactor.Compact(stream); - newSize = result.NewSize; - summary = result.WasReduced - ? $"Compacted: {FormatSize(result.OriginalSize)} -> {FormatSize(result.NewSize)} ({result.BlocksFreed} blocks freed)" - : "No reduction (already compact)"; - } else if (ops is IArchiveShrinkable shrinkable) { - // Shrink to a temp file then swap it in, so a crash mid-write cannot corrupt the source. - var tempOut = path + ".shrink.tmp"; - try { - using (var input = File.OpenRead(path)) - using (var output = File.Create(tempOut)) - shrinkable.Shrink(input, output); - - newSize = new FileInfo(tempOut).Length; - AtomicFileWriter.WriteAllBytesAtomic(path, File.ReadAllBytes(tempOut)); - summary = newSize < originalSize - ? $"Reduced: {FormatSize(originalSize)} -> {FormatSize(newSize)}" - : "No reduction (already compact)"; - } finally { - AtomicFileWriter.TryDelete(tempOut); - } - } else { - throw new NotSupportedException($"Shrink not supported for format: {formatId}"); - } - } catch (Exception ex) { - error = ex; - } - sw.Stop(); - if (newSize == 0 && File.Exists(path)) newSize = new FileInfo(path).Length; - - this.Invoke(() => { - this._progress.Value = 100; - this._shrink.Enabled = true; - this._run.Enabled = true; - - if (error is not null) - this.Append($"FAILED ({sw.ElapsedMilliseconds} ms): {error.GetType().Name}: {error.Message}"); - else { - this.Append($"OK ({sw.ElapsedMilliseconds} ms)"); - this.Append($" {summary}"); - this._sizeLabel.Text = $"{FormatSize(newSize)} ({newSize:N0} bytes)"; - } - - this.Append(""); - this.PreviewBlockMap(path, ops, wasMutated: error is null); - }); - }); - } - - private void OnWipeEmpty() { - if (this._imagePath is not { } path) return; - - var formatId = this._formatLabel.Text; - var ops = FormatRegistry.GetArchiveOps(formatId); - - this.Append($"=== {DateTime.Now:HH:mm:ss} Wiping unused space in {Path.GetFileName(path)} ==="); - this._wipeEmpty.Enabled = false; - this._shrink.Enabled = false; - this._run.Enabled = false; - this._progress.Style = ProgressBarStyle.Blocks; - this._progress.Value = 0; - - Task.Run(() => { - var sw = Stopwatch.StartNew(); - Exception? error = null; - var originalSize = new FileInfo(path).Length; - long wiped = 0; - - // Total span of unused bytes. Stays negative when the format cannot expose its extent map - // separately from the wipe itself. - var totalUnused = -1L; - - try { - this.BeginInvoke(() => this._progress.Value = 20); - - if (ops is IWipeEmpty wiper) { - using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite); - - // The wiper reports only the bytes it had to overwrite. On a mostly-empty image that - // figure and the actual free space diverge sharply, and the smaller one reads as "this - // image is barely empty", which is wrong — so compute the total up front when we can. - if (ops is IFilesystemExtentMap fsMap) { - stream.Position = 0; - totalUnused = UnusedSpaceWiper.ComputeUnusedBytes(fsMap.EnumerateExtents(stream), stream.Length); - } else if (ops is IArchiveLayoutMap arMap) { - stream.Position = 0; - totalUnused = UnusedSpaceWiper.ComputeUnusedBytes(arMap.EnumerateLayout(stream), stream.Length); - } - - stream.Position = 0; - wiped = wiper.WipeUnusedSpace(stream); - } else if (ops is IFilesystemExtentMap extentMap) { - using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite); - stream.Position = 0; - var extents = extentMap.EnumerateExtents(stream).ToList(); - totalUnused = UnusedSpaceWiper.ComputeUnusedBytes(extents, stream.Length); - wiped = UnusedSpaceWiper.Wipe(stream, extents, stream.Length); - } else if (ops is IArchiveLayoutMap layoutMap) { - using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite); - stream.Position = 0; - var extents = layoutMap.EnumerateLayout(stream).ToList(); - totalUnused = UnusedSpaceWiper.ComputeUnusedBytes(extents, stream.Length); - wiped = UnusedSpaceWiper.Wipe(stream, extents, stream.Length, wipeClusterTips: false); - } else { - throw new NotSupportedException($"Format {formatId} does not support wipe-empty."); - } - - this.BeginInvoke(() => this._progress.Value = 80); - } catch (Exception ex) { - error = ex; - } - sw.Stop(); - - this.Invoke(() => { - this._progress.Value = 100; - this._wipeEmpty.Enabled = true; - this._run.Enabled = true; - - if (error is not null) { - this.Append($"FAILED ({sw.ElapsedMilliseconds} ms): {error.GetType().Name}: {error.Message}"); - } else { - this.Append($"OK ({sw.ElapsedMilliseconds} ms)"); - if (totalUnused >= 0) { - var unusedPct = originalSize > 0 ? 100.0 * totalUnused / originalSize : 0; - var alreadyZero = Math.Max(0, totalUnused - wiped); - this.Append($" Unused space: {FormatSize(totalUnused)} ({unusedPct:F1}% of image)"); - this.Append($" Newly zeroed: {FormatSize(wiped)} ({wiped:N0} bytes); {FormatSize(alreadyZero)} was already zero"); - } else { - var writtenPct = originalSize > 0 ? 100.0 * wiped / originalSize : 0; - this.Append($" Wiped {FormatSize(wiped)} ({wiped:N0} bytes, {writtenPct:F1}% of image)"); - } - } - - this.Append(""); - this.PreviewBlockMap(path, ops, wasMutated: error is null); - }); - }); - } - - private void OnPurge() { - if (this._imagePath is not { } path) return; - - var formatId = this._formatLabel.Text; - var ops = FormatRegistry.GetArchiveOps(formatId); - if (ops is not IArchiveModifiable) { - this.Append($"Purge not supported: {formatId} is not modifiable."); - this.Append(""); - return; - } - - // Enumerate what is about to be erased so the confirmation is honest. - List entries; - try { - using var probe = File.OpenRead(path); - entries = ops.List(probe, password: null); - } catch (Exception ex) { - this.Append($"Purge aborted — could not list entries: {ex.Message}"); - this.Append(""); - return; - } - - var liveNames = entries.Where(e => !e.IsDirectory).Select(e => e.Name).ToArray(); - var allNames = entries.Select(e => e.Name).ToArray(); - if (allNames.Length == 0) { - this.Append("Container is already empty — nothing to purge."); - this.Append(""); - return; - } - - var confirm = MessageBox.Show(this, - $"Erase ALL {liveNames.Length} file(s) from {Path.GetFileName(path)}?\n\n" - + "This leaves a valid but empty container. The data cannot be recovered.", - "Confirm Purge", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - if (confirm != DialogResult.Yes) return; - - this.Append($"=== {DateTime.Now:HH:mm:ss} Purging {Path.GetFileName(path)} ==="); - foreach (var button in new[] { this._purge, this._wipeEmpty, this._shrink, this._run }) button.Enabled = false; - this._progress.Style = ProgressBarStyle.Marquee; - - Task.Run(() => { - var sw = Stopwatch.StartNew(); - Exception? error = null; - var originalSize = new FileInfo(path).Length; - - try { - ArchiveOperations.Remove(path, allNames); - } catch (Exception ex) { - error = ex; - } - sw.Stop(); - var newSize = File.Exists(path) ? new FileInfo(path).Length : 0L; - - this.Invoke(() => { - this._progress.Style = ProgressBarStyle.Blocks; - this._progress.Value = 100; - this._purge.Enabled = true; - this._run.Enabled = true; - - if (error is not null) - this.Append($"FAILED ({sw.ElapsedMilliseconds} ms): {error.GetType().Name}: {error.Message}"); - else { - this.Append($"OK ({sw.ElapsedMilliseconds} ms) — {liveNames.Length} file(s) erased"); - this.Append($"Container size: {originalSize:N0} -> {newSize:N0} bytes (Δ {newSize - originalSize:+#,#;-#,#;0})"); - } - - this.Append(""); - this.PreviewBlockMap(path, FormatRegistry.GetArchiveOps(formatId), wasMutated: error is null); - }); - }); - } - - /// - /// Scatters every allocation block across the volume, so the map shows the interleaving this - /// window exists to fix. - /// - /// - /// It asks first, and it names the file. Every other button here either improves the image or - /// leaves it as it was; this one deliberately makes it worse, and only a test wants that done to - /// a real image. - /// - private void OnScramble() { - if (this._imagePath is not { } path) return; - - var formatId = this._formatLabel.Text; - if (FormatRegistry.GetArchiveOps(formatId) is not IFilesystemScrambleable scrambler) { - this.Append($"Scramble not supported: {formatId} cannot scatter a volume in place."); - this.Append(""); - return; - } - - if (!int.TryParse(this._scrambleSeed.Text, out var seed)) { - this.Append($"Scramble aborted — '{this._scrambleSeed.Text}' is not a seed."); - this.Append(""); - return; - } - - var confirm = MessageBox.Show(this, - $"Fragment {Path.GetFileName(path)} on purpose?\n\n" - + "Every file's blocks are scattered across the volume. The contents are preserved " - + "exactly -- only the layout changes, and Defragment undoes it -- but this is a " - + "testing tool, not a repair.", - "Confirm Scramble", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - if (confirm != DialogResult.Yes) return; - - this.Append($"=== {DateTime.Now:HH:mm:ss} Scrambling {Path.GetFileName(path)} (seed {seed}) ==="); - - // Everything else is locked out while the layout is rewritten, then put back exactly as it was: - // which buttons apply to this image is a property of the format, not of what was just run. - var others = new[] { this._compact, this._purge, this._wipeEmpty, this._shrink, this._run }; - var wasEnabled = others.Select(b => b.Enabled).ToArray(); - foreach (var button in others) button.Enabled = false; - this._scramble.Enabled = false; - this._scrambleSeed.Enabled = false; - this._progress.Style = ProgressBarStyle.Marquee; - - Task.Run(() => { - var sw = Stopwatch.StartNew(); - Exception? error = null; - - // The same bridge the defragment run uses, so the map animates the scattering as it happens - // rather than jumping to the finished layout. - void OnProgress(DefragProgressEvent ev) => this.BeginInvoke(() => { - if (ev.BlockMap is not null) { - this._blockMap.BlockMap = ev.BlockMap; - this._blockMap.ImageSize = ev.ImageSize; - } - - this._blockMap.ReadHead = ev.CurrentReadOffset; - this._blockMap.WriteHead = ev.CurrentWriteOffset; - if (ev.Fraction >= 0) this._progress.Value = (int)(Math.Clamp(ev.Fraction, 0, 1) * 100); - }); - - try { - using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite); - scrambler.Scramble(stream, new ScrambleOptions { Seed = seed, OnProgress = OnProgress }); - } catch (Exception ex) { - error = ex; - } - sw.Stop(); - - this.Invoke(() => { - this._progress.Style = ProgressBarStyle.Blocks; - this._progress.Value = 100; - for (var i = 0; i < others.Length; ++i) others[i].Enabled = wasEnabled[i]; - this._scramble.Enabled = true; - this._scrambleSeed.Enabled = true; - - this.Append(error is not null - ? $"FAILED ({sw.ElapsedMilliseconds} ms): {error.GetType().Name}: {error.Message}" - : $"OK ({sw.ElapsedMilliseconds} ms) — the volume is fragmented; Defragment puts it back"); - this.Append(""); - this.PreviewBlockMap(path, FormatRegistry.GetArchiveOps(formatId), wasMutated: error is null); - }); - }); - } - - private void OnCompact() { - if (this._imagePath is not { } path) return; - - var minimal = this._minimalGeometry.Checked; - var formatId = this._formatLabel.Text; - - if (minimal) { - var confirm = MessageBox.Show(this, - "Minimal geometry rebuilds the container at the smallest size the format allows " - + "(e.g. a 1.44 MB FAT floppy collapses to a few KB).\n\n" - + "Contents are preserved, but the result may no longer be a standard, mountable image. Continue?", - "Compact — minimal geometry", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - if (confirm != DialogResult.Yes) return; - } - - this.Append($"=== {DateTime.Now:HH:mm:ss} Compacting {Path.GetFileName(path)}{(minimal ? " (minimal geometry)" : "")} ==="); - foreach (var button in new[] { this._compact, this._run, this._shrink, this._wipeEmpty, this._purge }) button.Enabled = false; - this._progress.Style = ProgressBarStyle.Marquee; - - Task.Run(() => { - var sw = Stopwatch.StartNew(); - Exception? error = null; - CompactOperation.CompactResult? result = null; - - try { - result = CompactOperation.Compact(path, new CompactOperation.CompactOptions { - Minimal = minimal, - Log = line => this.BeginInvoke(() => this.Append(" " + line)), - }); - } catch (Exception ex) { - error = ex; - } - sw.Stop(); - - this.Invoke(() => { - this._progress.Style = ProgressBarStyle.Blocks; - this._progress.Value = 100; - this._compact.Enabled = true; - this._run.Enabled = true; - - if (error is not null || result is null) { - this.Append($"FAILED ({sw.ElapsedMilliseconds} ms): {error?.GetType().Name}: {error?.Message}"); - } else { - var delta = result.NewSize - result.OriginalSize; - var pct = result.OriginalSize > 0 ? 100.0 * delta / result.OriginalSize : 0; - this.Append($"OK ({sw.ElapsedMilliseconds} ms) — steps: {(result.StepsRun.Count > 0 ? string.Join(", ", result.StepsRun) : "none")}"); - this.Append($"Container size: {FormatSize(result.OriginalSize)} -> {FormatSize(result.NewSize)} ({pct:+0.0;-0.0;0.0}%)"); - } - - this.Append(""); - this.PreviewBlockMap(path, FormatRegistry.GetArchiveOps(formatId), wasMutated: error is null); - }); - }); - } - - // ── Option readers ────────────────────────────────────────────────────────────────────────── - - private DefragMode SelectedMode() { - if (this._modePackEnd.Checked) return DefragMode.ConsolidateAtEnd; - if (this._modeFillHoles.Checked) return DefragMode.FillHolesLazy; - if (this._modeCarveHole.Checked) return DefragMode.CarveHole; - if (this._modeAscending.Checked) return DefragMode.AscendingOrder; - return DefragMode.ConsolidateAtStart; - } - - private MetadataPlacementProfile? SelectedMetadataPlacement() => this._metadataPlacement.SelectedIndex switch { - 1 => MetadataPlacementProfile.MetadataFirst, - 2 => MetadataPlacementProfile.DataFirst, - // Index 0 is the format default, which the optimizer expresses as null. - _ => null, - }; - - private MetadataZone SelectedMetadataZone() => this._metadataZone.SelectedIndex switch { - 1 => MetadataZone.Front, - 2 => MetadataZone.Back, - 3 => MetadataZone.Middle, - 4 => MetadataZone.BeforeContent, - _ => MetadataZone.Unchanged, - }; - - private static long ParseSize(string s) { - if (string.IsNullOrWhiteSpace(s)) return 0; - s = s.Trim().ToLowerInvariant(); - - long mult = 1; - if (s.EndsWith('k')) { mult = 1024L; s = s[..^1]; } - else if (s.EndsWith('m')) { mult = 1024L * 1024; s = s[..^1]; } - else if (s.EndsWith('g')) { mult = 1024L * 1024 * 1024; s = s[..^1]; } - - return long.TryParse(s, out var n) ? n * mult : 0; - } - - private static long ParseHoleAt(string s) - => string.IsNullOrWhiteSpace(s) || s.Trim().Equals("auto", StringComparison.OrdinalIgnoreCase) - ? -1 - : long.TryParse(s, out var n) ? n : -1; - - private static int ParseInterleaveStride(string s) - => string.IsNullOrWhiteSpace(s) ? 1 - : int.TryParse(s.Trim(), out var n) ? Math.Clamp(n, 1, 256) : 1; - - private void Append(string line) { - this._outputBox.AppendText(line + Environment.NewLine); - this._outputBox.SelectionStart = this._outputBox.Text.Length; - } - - private static string FormatSize(long bytes) => bytes switch { - < 1024 => $"{bytes} B", - < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", - < 1024 * 1024 * 1024 => $"{bytes / (1024.0 * 1024):F1} MB", - _ => $"{bytes / (1024.0 * 1024 * 1024):F2} GB", - }; - - // ── Mode probing ──────────────────────────────────────────────────────────────────────────── - - /// - /// Asks each mode whether the descriptor accepts it, without mutating anything. There is no way - /// to ask directly, so the descriptor is handed a stream that throws on first touch: reaching the - /// I/O means the mode was accepted, while throwing before any - /// I/O means it was not. - /// - private static List ProbeSupportedModes(IArchiveDefragmentable defragmentable) { - var supported = new List(); - - foreach (var mode in Enum.GetValues()) - try { - using var probe = new ProbeStream(); - defragmentable.Defragment(probe, new DefragOptions { Mode = mode }); - supported.Add(mode.ToString()); // Unlikely: the probe should have thrown first. - } catch (NotSupportedException) { - // Mode unsupported by the descriptor. - } catch (ProbeAbortException) { - supported.Add(mode.ToString()); - } catch { - // Anything else: assume unsupported rather than guess. - } - - return supported; - } - - private sealed class ProbeAbortException : Exception; - - private sealed class ProbeStream : Stream { - public override bool CanRead => true; - public override bool CanSeek => true; - public override bool CanWrite => true; - public override long Length => 1024; - public override long Position { get => 0; set => throw new ProbeAbortException(); } - public override void Flush() { } - public override int Read(byte[] buffer, int offset, int count) => throw new ProbeAbortException(); - public override long Seek(long offset, SeekOrigin origin) => throw new ProbeAbortException(); - public override void SetLength(long value) => throw new ProbeAbortException(); - public override void Write(byte[] buffer, int offset, int count) => throw new ProbeAbortException(); - } - - /// The block-map colour key. - private sealed class LegendStrip : OwnerDrawnControl { - private static readonly (Color Color, string Label)[] Swatches = [ - (Color.FromArgb(0xE6, 0x4A, 0x19), "Hot"), - (Color.FromArgb(0x42, 0xA5, 0xF5), "Normal"), - (Color.FromArgb(0x66, 0xBB, 0x6A), "Cold"), - (Color.FromArgb(0x90, 0xA4, 0xAE), "Frozen"), - (Color.FromArgb(0xDA, 0xA5, 0x20), "Directory"), - (Color.FromArgb(0xE0, 0xE0, 0xE0), "Free"), - (Color.FromArgb(0xC0, 0x30, 0x30), "Bad"), - (Color.FromArgb(0x70, 0x70, 0x70), "Reserved"), - ]; - - protected override void OnPaint(PaintEventArgs e) { - var g = e.Graphics; - var theme = DefaultTheme.Instance; - g.FillRectangle(theme.ControlBackground, new(0, 0, this.Width, this.Height)); - - var x = 0; - var y = 2; - foreach (var (color, label) in Swatches) { - var width = 10 + 4 + Math.Max(30, g.MeasureText(label, theme.DefaultFont).Width) + 12; - if (x + width > this.Width) { - x = 0; - y += 18; - if (y + 14 > this.Height) return; - } - - g.FillRectangle(color, new(x, y + 3, 10, 10)); - g.DrawText(label, theme.DefaultFont, theme.ControlText, new(x + 14, y, width - 18, 16), ContentAlignment.MiddleLeft); - x += width; - } - - // The two heads are lines rather than swatches, matching how they are drawn on the map. - if (x + 90 > this.Width) { - x = 0; - y += 18; - } - if (y + 14 > this.Height) return; - - g.DrawLine(Color.LimeGreen, x + 2, y + 2, x + 2, y + 14, 2); - g.DrawText("Read head", theme.DefaultFont, theme.ControlText, new(x + 8, y, 70, 16), ContentAlignment.MiddleLeft); - g.DrawLine(Color.Orange, x + 84, y + 2, x + 84, y + 14, 2); - g.DrawText("Write head", theme.DefaultFont, theme.ControlText, new(x + 90, y, 74, 16), ContentAlignment.MiddleLeft); - } - } -} diff --git a/Compression.NativeUI/Views/MainForm.Defragment.cs b/Compression.NativeUI/Views/MainForm.Defragment.cs new file mode 100644 index 000000000..1c80559de --- /dev/null +++ b/Compression.NativeUI/Views/MainForm.Defragment.cs @@ -0,0 +1,679 @@ +using Compression.Core.Layout; +using Compression.Lib.Layout; +using Compression.NativeUI.Controls; +using Compression.NativeUI.Maintenance; +using Compression.NativeUI.Theming; +using Compression.NativeUI.ViewModels; +using Compression.Registry; +using Compression.Registry.Layout; +using Hawkynt.NativeForms; +using Color = System.Drawing.Color; + +namespace Compression.NativeUI.Views; + +/// +/// The Defragment tab: a contextual "Disk Tools" tab that appears while there is something to +/// maintain — the open archive or image, or an archive file selected in a folder — and, while it is +/// selected, gives the whole client area to the block map. Every operation, defragmentation strategy, +/// layout option and view mode is a ribbon item; each is enabled exactly when the target supports it. +/// +/// +/// The tab holds one while it is open, so a nested archive is +/// extracted once and written back after each change, and the work itself goes through +/// . Leaving the tab hides the block view and shows the browser +/// again exactly as it was; nothing about it is rebuilt. +/// +internal sealed partial class MainForm { + + private readonly RibbonContextualTabGroup _diskTools = new("Disk Tools", Color.FromArgb(0x3A, 0x8E, 0x5C)) { Visible = false }; + private readonly DefragmentView _defragView = new() { Visible = false }; + private RibbonTab _defragTab = null!; + + private readonly Dictionary _verbToggles = []; + private readonly Dictionary _strategyToggles = []; + private readonly Dictionary _viewToggles = []; + private RibbonToggleButton _packAtEndToggle = null!; + private RibbonToggleButton _filesPanelToggle = null!; + private RibbonToggleButton _legendToggle = null!; + private RibbonButton _startButton = null!; + private RibbonButton _stopButton = null!; + private RibbonButton _analyzeButton = null!; + private RibbonButton _editProfilesButton = null!; + + // Ribbon fields, drawn by the ribbon: one row tall on every backend, and still usable from a + // collapsed group's popup. + private readonly RibbonSpinner _interleaveSpinner = new("Interleave") { + Minimum = MaintenanceInput.MinInterleave, Maximum = MaintenanceInput.MaxInterleave, Value = 1, FieldWidth = 56, + }; + private readonly RibbonComboBox _metadataZoneCombo = new("Metadata") { FieldWidth = 112 }; + private readonly RibbonComboBox _layoutProfileCombo = new("Profile") { FieldWidth = 112 }; + private readonly RibbonSpinner _holeSizeSpinner = new("Hole KiB") { Minimum = 1, Maximum = 1L << 32, Value = 64, Increment = 64, FieldWidth = 80 }; + private readonly RibbonComboBox _holePlacementCombo = new("Hole at") { FieldWidth = 80 }; + private readonly RibbonSpinner _holeOffsetSpinner = new("Offset KiB") { Minimum = 0, Maximum = 1L << 32, Value = 0, Increment = 64, FieldWidth = 80 }; + private readonly RibbonSpinner _seedSpinner = new("Seed") { Minimum = int.MinValue, Maximum = int.MaxValue, Value = 1, FieldWidth = 80 }; + private readonly RibbonComboBox _optimizeMethodCombo = new("Method") { FieldWidth = 112 }; + private readonly List _layoutProfileEntries = []; + + private readonly RibbonComboBox _targetCombo = new("Target") { FieldWidth = 170 }; + private readonly List _offeredTargets = []; + private MaintenanceTarget? _sessionTarget; + private bool _pendingPreferSelection; + private MaintenanceSession? _maintenanceSession; + private MaintenancePresenter? _presenter; + private MaintenanceVerb? _pendingVerb; + private CancellationTokenSource? _maintenanceCancellation; + private bool _maintenanceCommitStarted; + private bool _defragActive; + private bool _syncingDefragRibbon; + private int _analysisGeneration; + + /// The last analysis started, so a test can wait for the map it draws. + internal Task? AnalysisTask { get; private set; } + + /// The last operation started, completed once its outcome has been shown. + internal Task? MaintenanceTask { get; private set; } + + // ── construction ──────────────────────────────────────────────────────────────────────────── + + private void BuildDefragmentTab() { + this._defragTab = new RibbonTab("Defragment"); + this._defragTab.Groups.AddRange(this.OperationGroup(), this.StrategyGroup(), this.OptionsGroup(), this.ViewGroup()); + + this._ribbon.Tabs.Add(this._defragTab); + this._diskTools.Add(this._defragTab); + this._ribbon.ContextualTabGroups.Add(this._diskTools); + + this._ribbon.SelectedIndexChanged += (_, _) => this.OnRibbonTabChanged(); + this._model.MaintenanceRequested += (_, request) => this.OpenDefragmentTab(request.Verb, request.PreferSelection); + this._model.LocationChanged += (_, _) => this.UpdateDiskTools(); + CommandManager.RequerySuggested += (_, _) => this.UpdateDiskTools(); + this.FormClosing += this.OnClosingWhileMaintaining; + this.UpdateDiskTools(); + } + + /// + /// Wires the target field: the open volume by default, or a selected file whose content proved it + /// a container. Picking here is the only way the target changes while an image is being worked on; + /// selecting something else in the list only adds or removes the offer. The field is a small row of + /// the Options group, shown only while there is a choice to make — the header line names the + /// target always — so it costs the ribbon no width when there is nothing to choose. + /// + private void WireTargetField() => + this.Field(this._targetCombo, "The volume or container the operations work on.", () => { + var index = this._targetCombo.SelectedIndex; + if (index >= 0 && index < this._offeredTargets.Count && this._offeredTargets[index].Key != this._sessionTarget?.Key) + this.OpenSession(this._offeredTargets[index]); + }); + + private RibbonGroup OperationGroup() { + (MaintenanceVerb Verb, string Text, string Icon)[] verbs = [ + (MaintenanceVerb.Defragment, "Defragment", IconKeys.Defragment), + (MaintenanceVerb.Optimize, "Optimize", IconKeys.Analyze), + (MaintenanceVerb.Shrink, "Shrink", IconKeys.Defragment), + (MaintenanceVerb.Compact, "Compact", IconKeys.Defragment), + (MaintenanceVerb.WipeEmpty, "Clear", IconKeys.Remove), + (MaintenanceVerb.Purge, "Purge", IconKeys.Remove), + (MaintenanceVerb.Scramble, "Scramble", IconKeys.Defragment), + ]; + + this._startButton = this.RibbonAction("Start", IconKeys.Test, this.StartMaintenance, RibbonItemSize.Large, Keys.None, "Run the chosen operation"); + this._stopButton = this.RibbonAction("Stop", IconKeys.Remove, this.StopMaintenance, RibbonItemSize.Small, Keys.None, "Stop the running operation"); + this._analyzeButton = this.RibbonAction("Analyze", IconKeys.Refresh, this.AnalyzeTarget, RibbonItemSize.Small, Keys.None, "Read the image's layout again"); + + // Start leads; Stop fills the slot under Scramble, so the group needs no column of its own for it + // and the whole tab stays expanded at the default window width. + var group = new RibbonGroup("Operation"); + group.Items.Add(this._startButton); + foreach (var (verb, text, icon) in verbs) { + var toggle = this.RibbonToggle(text, icon, RibbonItemSize.Small, Keys.None, text, @checked: false, on => this.ChooseVerb(verb, on)); + this._verbToggles[verb] = toggle; + group.Items.Add(toggle); + } + + group.Items.Add(this._stopButton); + return group; + } + + private RibbonGroup StrategyGroup() { + (DefragStrategy Strategy, string Text)[] strategies = [ + (DefragStrategy.Consolidate, "Consolidate"), + (DefragStrategy.Defrag, "Defrag"), + (DefragStrategy.Reorder, "Re-order"), + (DefragStrategy.SortEntries, "Sort Entries"), + (DefragStrategy.CarveHole, "Carve Hole"), + ]; + + var group = new RibbonGroup("Defrag Mode"); + foreach (var (strategy, text) in strategies) { + var toggle = this.RibbonToggle(text, IconKeys.Defragment, RibbonItemSize.Small, Keys.None, text, @checked: false, on => this.ChooseStrategy(strategy, on)); + this._strategyToggles[strategy] = toggle; + group.Items.Add(toggle); + } + + this._packAtEndToggle = this.RibbonToggle("Pack at End", IconKeys.Defragment, RibbonItemSize.Small, Keys.None, "Consolidate at the back instead of the front", + @checked: false, on => this.WithPresenter(p => p.PackAtEnd = on)); + group.Items.Add(this._packAtEndToggle); + + this._holePlacementCombo.Items.AddRange(["End", "Offset"]); + this._holePlacementCombo.SelectedIndex = 0; + this.Field(this._holeSizeSpinner, "Size of the hole to carve, in KiB.", () => this.WithPresenter(p => p.HoleSizeText = HoleSizeText())); + this.Field(this._holePlacementCombo, "End: after the last live extent. Offset: at the offset beside.", + () => this.WithPresenter(p => p.HoleAtText = HoleAtText())); + this.Field(this._holeOffsetSpinner, "Where the hole starts, in KiB from the start of the image.", + () => this.WithPresenter(p => p.HoleAtText = HoleAtText())); + group.Items.AddRange(this._holeSizeSpinner, this._holePlacementCombo, this._holeOffsetSpinner); + return group; + } + + /// + /// The layout options of a defragmentation, then — below Edit Profiles, shown only while their + /// operation is picked — the one option Scramble, Optimize or Compact takes. Showing only the one + /// that applies keeps the tab narrow. + /// + private RibbonGroup OptionsGroup() { + // Short, because the field is narrow; the tooltip says what each placement means. + this._metadataZoneCombo.Items.AddRange(["Unchanged", "Front", "Back", "Middle", "Before data"]); + this._metadataZoneCombo.SelectedIndex = 0; + this.RefreshLayoutProfiles(); + + this.Field(this._interleaveSpinner, + $"Block interleave: 1 = contiguous, 2 = every other block, N = each file's Kth block at start + K×N. {MaintenanceInput.MinInterleave}–{MaintenanceInput.MaxInterleave}.", + () => this.WithPresenter(p => p.InterleaveText = InterleaveText())); + this.Field(this._metadataZoneCombo, "Where filesystem metadata and directory entries are placed: unchanged, at the front (fast access), at the back (data first), in the middle (least seeking) or just before the file content (read-ahead).", + () => this.WithPresenter(p => p.MetadataZone = this.SelectedMetadataZone())); + this.Field(this._layoutProfileCombo, "A zone-based layout template: files go into named byte ranges with per-zone sort orders.", + this.OnLayoutProfileChanged); + this._editProfilesButton = this.RibbonAction("Profiles…", IconKeys.Properties, () => { + new LayoutProfileEditor().ShowDialog(this); + this.RefreshLayoutProfiles(); + }, RibbonItemSize.Small, Keys.None, "Create, change or delete layout profiles"); + + this.Field(this._seedSpinner, "Seeds Scramble's shuffle. The same seed deals the same layout every run.", + () => this.WithPresenter(p => p.SeedText = SeedText())); + this.Field(this._optimizeMethodCombo, "How Optimize improves the container: compress (best compression, same format), repack (same entries, dead space dropped) or canonicalize (normal form). Only what the format offers is listed.", + () => this.WithPresenter(p => p.OptimizeMethod = this.SelectedOptimizeMethod())); + + this.WireTargetField(); + var group = new RibbonGroup("Options"); + group.Items.AddRange(this._interleaveSpinner, this._metadataZoneCombo, this._layoutProfileCombo, + this._editProfilesButton, this._seedSpinner, this._optimizeMethodCombo, this._targetCombo); + return group; + } + + private RibbonGroup ViewGroup() { + (BlockMapView View, string Text)[] views = [ + (BlockMapView.LinearBlocks, "Blocks"), + (BlockMapView.CircularPlatter, "Circle"), + (BlockMapView.CylinderStack, "3D Stack"), + ]; + + var group = new RibbonGroup("View"); + foreach (var (view, text) in views) { + var toggle = this.RibbonToggle(text, IconKeys.ViewImage, RibbonItemSize.Small, Keys.None, text, @checked: view == BlockMapView.LinearBlocks, _ => this.ChooseView(view)); + this._viewToggles[view] = toggle; + group.Items.Add(toggle); + } + + this._filesPanelToggle = this.RibbonToggle("Files", IconKeys.ViewText, RibbonItemSize.Small, Keys.None, "Show the files beside the map", @checked: true, + on => { if (!this._syncingDefragRibbon) this._defragView.FilesPanelVisible = on; }); + this._legendToggle = this.RibbonToggle("Legend", IconKeys.Properties, RibbonItemSize.Small, Keys.None, "Show the colour key under the map", @checked: true, + on => { if (!this._syncingDefragRibbon) this._defragView.LegendVisible = on; }); + // Analyze reads the map again, so it sits with the rest of what the map shows. + group.Items.AddRange(this._filesPanelToggle, this._legendToggle, this._analyzeButton); + return group; + } + + /// Wires a ribbon field: its tooltip, kept in , and what a change does. + private void Field(RibbonFieldItem field, string tip, Action changed) { + field.Tag = tip; + field.ToolTipText = tip; + void OnChanged(object? sender, EventArgs e) { + if (this._syncingDefragRibbon) return; + changed(); + this.SyncDefragmentRibbon(); + } + + switch (field) { + case RibbonSpinner spinner: spinner.ValueChanged += OnChanged; break; + case RibbonComboBox combo: combo.SelectedIndexChanged += OnChanged; break; + } + } + + // The presenter reads typed values; the fields can only hold valid ones, so these never fail to parse. + private string InterleaveText() => ((int)this._interleaveSpinner.Value).ToString(System.Globalization.CultureInfo.InvariantCulture); + private string HoleSizeText() => $"{(long)this._holeSizeSpinner.Value}k"; + private string HoleAtText() => this._holePlacementCombo.SelectedIndex == 1 + ? ((long)this._holeOffsetSpinner.Value * 1024).ToString(System.Globalization.CultureInfo.InvariantCulture) + : "auto"; + private string SeedText() => ((int)this._seedSpinner.Value).ToString(System.Globalization.CultureInfo.InvariantCulture); + + // ── the tab and the client area ───────────────────────────────────────────────────────────── + + /// Shows the contextual tab while there is something to maintain, and keeps the target offer current. + private void UpdateDiskTools() { + var running = this.MaintenanceRunning; + var visible = running || this._model.HasMaintenanceTarget || (this._defragActive && this._maintenanceSession is not null); + + if (!visible && this._ribbon.SelectedTab == this._defragTab) this._ribbon.SelectedIndex = 1; + this._diskTools.Visible = visible; + + // Only while idle: a running operation keeps its target and its offer whatever is selected. + if (this._defragActive && !running) this.RefreshTargets(); + } + + private bool MaintenanceRunning => this._presenter?.IsRunning == true || this._maintenanceCancellation is not null; + + /// + /// Re-evaluates what the Target field offers. The session keeps its target: a selection change only + /// adds or removes the offer of a selected container, and a selected target stays listed after the + /// selection moves off it. Only when the open volume the session works on is no longer open — Back, + /// a typed address — does the tab move to the volume that is open now. + /// + private void RefreshTargets() { + var targets = this._model.MaintenanceTargets().ToList(); + if (this._sessionTarget is { } current) { + var left = current.Kind == MaintenanceTargetKind.OpenVolume && targets.All(t => t.Key != current.Key) + && this._model.OpenVolumeMaintenanceTarget is not null; + if (left) { + this.OpenSession(DefaultTarget(targets, preferSelection: false)!); + return; + } + + if (targets.All(t => t.Key != current.Key)) targets.Insert(current.Kind == MaintenanceTargetKind.OpenVolume ? 0 : targets.Count, current); + } else if (DefaultTarget(targets, preferSelection: false) is { } first) { + this.OpenSession(first); + return; + } + + this.OfferTargets(targets); + } + + /// The open volume, unless the request came from the selection and a selected container is proven. + private static MaintenanceTarget? DefaultTarget(IReadOnlyList targets, bool preferSelection) + => (preferSelection ? targets.FirstOrDefault(t => t.Kind == MaintenanceTargetKind.Selected) : null) + ?? targets.FirstOrDefault(t => t.Kind == MaintenanceTargetKind.OpenVolume) + ?? targets.FirstOrDefault(); + + private void OfferTargets(List targets) { + this._syncingDefragRibbon = true; + try { + if (!this._offeredTargets.Select(t => t.Key).SequenceEqual(targets.Select(t => t.Key))) { + this._offeredTargets.Clear(); + this._offeredTargets.AddRange(targets); + this._targetCombo.Items.Clear(); + this._targetCombo.Items.AddRange(targets.Select(t => t.Label)); + } + + this._targetCombo.SelectedIndex = this._offeredTargets.FindIndex(t => t.Key == this._sessionTarget?.Key); + } finally { + this._syncingDefragRibbon = false; + } + + this.SyncDefragmentRibbon(); + } + + /// + /// Selects the Defragment tab, preselecting when the target supports it. + /// aims it at a proven selected container instead of the open volume. + /// + internal void OpenDefragmentTab(MaintenanceVerb? verb, bool preferSelection = false) { + this.UpdateDiskTools(); + if (!this._diskTools.Visible) return; + + this._pendingVerb = verb; + this._pendingPreferSelection = preferSelection; + if (this._ribbon.SelectedTab != this._defragTab) { + this._ribbon.SelectedIndex = this._ribbon.Tabs.IndexOf(this._defragTab); + return; + } + + // Already on the tab: a command aimed at the selection is an explicit choice, so it retargets. + if (preferSelection && !this.MaintenanceRunning && this._model.SelectedMaintenanceTarget is { } selected && selected.Key != this._sessionTarget?.Key) + this.OpenSession(selected); + this.ApplyPendingVerb(); + } + + private void OnRibbonTabChanged() { + var onTab = this._ribbon.SelectedTab == this._defragTab; + if (onTab == this._defragActive) return; + + this._defragActive = onTab; + if (onTab) { + // A session still running an operation is kept, whatever is selected now: its file is in use. + if (!this.MaintenanceRunning || this._maintenanceSession is null) { + var target = DefaultTarget(this._model.MaintenanceTargets(), this._pendingPreferSelection); + if (target is not null && target.Key != this._sessionTarget?.Key) this.OpenSession(target); + } + this._pendingPreferSelection = false; + this.ApplyPendingVerb(); + } else if (this._maintenanceCancellation is null) { + this.CloseSession(); + } + + // The browser is hidden, not torn down, so it comes back exactly as it was left. + this._split.Visible = !onTab; + this._defragView.Visible = onTab; + this.LayoutChildren(); + this.UpdateDiskTools(); + } + + private void OpenSession(MaintenanceTarget target) { + this.CloseSession(); + this._defragView.Clear(); + + this._maintenanceSession = this._model.OpenMaintenanceSession(target); + if (this._maintenanceSession is { } session) { + this._sessionTarget = target; + this._presenter = new MaintenancePresenter(session.ImagePath, session.FormatId) { + InterleaveText = this.InterleaveText(), + HoleSizeText = this.HoleSizeText(), + HoleAtText = this.HoleAtText(), + SeedText = this.SeedText(), + MetadataZone = this.SelectedMetadataZone(), + }; + this.OnLayoutProfileChanged(); + var display = FormatRegistry.GetById(session.FormatId)?.DisplayName is { Length: > 0 } name ? name : session.FormatId; + this._defragView.TargetText = $"{session.DisplayName} — {display} · {MaintenanceInput.FormatSize(new FileInfo(session.ImagePath).Length)}"; + this._defragView.Log($"{session.DisplayName} — {display}"); + this.AnalyzeTarget(); + } else { + this._defragView.TargetText = ""; + } + + var targets = this._model.MaintenanceTargets().ToList(); + if (this._sessionTarget is { } current && targets.All(t => t.Key != current.Key)) targets.Add(current); + this.OfferTargets(targets); + } + + private void CloseSession() { + this._presenter = null; + this._sessionTarget = null; + this._maintenanceSession?.Dispose(); + this._maintenanceSession = null; + } + + private void ApplyPendingVerb() { + if (this._pendingVerb is { } verb && this._presenter is { } presenter && presenter.Capabilities.Verb(verb).Supported) + presenter.Verb = verb; + this._pendingVerb = null; + this.SyncDefragmentRibbon(); + } + + // ── choices ───────────────────────────────────────────────────────────────────────────────── + + private void WithPresenter(Action change) { + if (this._syncingDefragRibbon || this._presenter is not { } presenter) return; + change(presenter); + this.SyncDefragmentRibbon(); + } + + /// The operations behave as radio buttons: clicking one picks it, clicking it again keeps it. + private void ChooseVerb(MaintenanceVerb verb, bool on) => this.WithPresenter(p => { + if (on) p.Verb = verb; + }); + + private void ChooseStrategy(DefragStrategy strategy, bool on) => this.WithPresenter(p => { + if (on) p.Strategy = strategy; + }); + + private void ChooseView(BlockMapView view) { + if (this._syncingDefragRibbon) return; + this._defragView.Map.ViewMode = view; + this.SyncDefragmentRibbon(); + } + + private OptimizeMethod SelectedOptimizeMethod() + => this._presenter?.Capabilities.OptimizeMethods is { } methods && this._optimizeMethodCombo.SelectedIndex is var i && i >= 0 && i < methods.Count + ? methods[i] + : OptimizeMethod.Compress; + + private MetadataZone SelectedMetadataZone() => this._metadataZoneCombo.SelectedIndex switch { + 1 => MetadataZone.Front, + 2 => MetadataZone.Back, + 3 => MetadataZone.Middle, + 4 => MetadataZone.BeforeContent, + _ => MetadataZone.Unchanged, + }; + + private void RefreshLayoutProfiles() { + var index = this._layoutProfileCombo.SelectedIndex; + var previous = index > 0 && index < this._layoutProfileEntries.Count ? this._layoutProfileEntries[index]?.FilePath : null; + + this._syncingDefragRibbon = true; + try { + this._layoutProfileEntries.Clear(); + var names = new List { "(none)" }; + this._layoutProfileEntries.Add(null); + try { + foreach (var entry in LayoutProfileStore.List()) { + names.Add($"{entry.Name} [{(entry.Origin == ProfileOrigin.Builtin ? "Built-in" : "User")}]"); + this._layoutProfileEntries.Add(entry); + } + } catch { + // A broken profile folder leaves "(none)"; the editor reports what is wrong with it. + } + + var keep = this._layoutProfileEntries.FindIndex(e => e is not null && string.Equals(e.FilePath, previous, StringComparison.OrdinalIgnoreCase)); + this._layoutProfileCombo.Items.Clear(); + this._layoutProfileCombo.Items.AddRange(names); + this._layoutProfileCombo.SelectedIndex = Math.Max(0, keep); + } finally { + this._syncingDefragRibbon = false; + } + + this.OnLayoutProfileChanged(); + } + + private void OnLayoutProfileChanged() { + if (this._presenter is not { } presenter) return; + + var index = this._layoutProfileCombo.SelectedIndex; + if (index <= 0 || index >= this._layoutProfileEntries.Count || this._layoutProfileEntries[index] is not { } entry) { + presenter.LayoutProfile = null; + return; + } + + try { + presenter.LayoutProfile = LayoutProfileStore.Load(entry); + } catch (Exception ex) { + presenter.LayoutProfile = null; + MessageBox.Show(this, $"Failed to load layout profile '{entry.Name}':\n{ex.Message}", "Layout profile", MessageBoxButtons.OK, MessageBoxIcon.Error); + this._layoutProfileCombo.SelectedIndex = 0; + } + } + + // ── keeping the ribbon honest ─────────────────────────────────────────────────────────────── + + /// + /// Enables, checks and explains every item from the presenter: an item is enabled only when the + /// target supports it and the current choices make it apply, and its tooltip says why when not. + /// + private void SyncDefragmentRibbon() { + if (this._syncingDefragRibbon) return; + + this._syncingDefragRibbon = true; + try { + var p = this._presenter; + var caps = p?.Capabilities; + var running = p?.IsRunning == true || this._maintenanceCancellation is not null; + var idle = p is not null && !running; + var defrag = p?.Verb == MaintenanceVerb.Defragment; + const string NoTarget = "Open or select an image to maintain."; + + foreach (var (verb, toggle) in this._verbToggles) { + var capability = caps?.Verb(verb) ?? Capability.No(NoTarget); + toggle.Enabled = idle && capability.Supported; + toggle.Checked = p?.Verb == verb; + toggle.ToolTipText = $"{toggle.Text}: {capability.Reason}"; + } + + foreach (var (strategy, toggle) in this._strategyToggles) { + var capability = caps?.Strategy(strategy) ?? Capability.No(NoTarget); + toggle.Enabled = idle && defrag && capability.Supported; + toggle.Checked = defrag && p!.Strategy == strategy; + toggle.ToolTipText = $"{toggle.Text}: {(capability.Supported && !defrag ? "Applies to Defragment." : capability.Reason)}"; + } + + var consolidate = defrag && p!.Strategy == DefragStrategy.Consolidate; + var endChoosable = caps is not null && caps.PackAtEnd.Supported && caps.PackAtStart.Supported; + this._packAtEndToggle.Enabled = idle && consolidate && endChoosable; + this._packAtEndToggle.Checked = consolidate && p!.EngineMode == DefragMode.ConsolidateAtEnd; + this._packAtEndToggle.ToolTipText = $"Pack at End: {(caps is null ? NoTarget : !consolidate ? "Applies to Consolidate." : caps.PackAtEnd.Reason)}"; + + var carve = defrag && p!.Strategy == DefragStrategy.CarveHole; + foreach (var field in new RibbonFieldItem[] { this._holeSizeSpinner, this._holePlacementCombo, this._holeOffsetSpinner }) { + field.Visible = carve; + field.Enabled = idle; + } + this._holeOffsetSpinner.Enabled = idle && this._holePlacementCombo.SelectedIndex == 1; + + // The layout options shape extent moves; a directory sort moves nothing. + var extents = defrag && p!.MovesExtents; + Option(this._interleaveSpinner, idle && extents, caps?.Interleave, extents, appliesTo: "extent moves"); + Option(this._metadataZoneCombo, idle && extents, caps?.MetadataZone, extents, appliesTo: "extent moves"); + Option(this._layoutProfileCombo, idle && extents, caps?.LayoutProfile, extents, appliesTo: "extent moves"); + this._editProfilesButton.Enabled = idle && extents && caps?.LayoutProfile.Supported == true; + + var scramble = p?.Verb == MaintenanceVerb.Scramble; + this._seedSpinner.Visible = scramble; + Option(this._seedSpinner, idle && scramble, scramble ? Capability.Yes("Seeds Scramble's shuffle.") : null, scramble, appliesTo: "Scramble"); + var optimize = p?.Verb == MaintenanceVerb.Optimize; + this._optimizeMethodCombo.Visible = optimize; + var methods = caps?.OptimizeMethods ?? []; + if (!this._optimizeMethodCombo.Items.Select(i => i).SequenceEqual(methods.Select(m => m.ToString()))) { + this._optimizeMethodCombo.Items.Clear(); + this._optimizeMethodCombo.Items.AddRange(methods.Select(m => m.ToString())); + } + this._optimizeMethodCombo.SelectedIndex = p is null ? -1 : methods.ToList().IndexOf(p.OptimizeMethod); + this._optimizeMethodCombo.Enabled = idle && optimize && methods.Count > 1; + this._optimizeMethodCombo.ToolTipText = $"Method: {(caps is null ? NoTarget : caps.Verb(MaintenanceVerb.Optimize).Reason)} {this._optimizeMethodCombo.Tag}"; + + var blocker = p?.StartBlocker ?? (p is null ? NoTarget : null); + this._startButton.Enabled = idle && blocker is null; + this._startButton.ToolTipText = blocker ?? $"{MaintenancePresenter.Label(p!.Verb!.Value)} {this._maintenanceSession?.DisplayName}"; + this._stopButton.Enabled = running && p?.CanCancel == true && !this._maintenanceCommitStarted && this._maintenanceCancellation is { IsCancellationRequested: false }; + this._analyzeButton.Enabled = idle; + + // Seed and Method never show together, so with Edit Profiles the target makes at most three rows. + this._targetCombo.Visible = this._offeredTargets.Count > 1; + this._targetCombo.Enabled = !running && this._offeredTargets.Count > 1; + this._targetCombo.ToolTipText = this._sessionTarget is null + ? "Open or select an image to maintain." + : running ? "An operation is running on this target." : $"Working on {this._sessionTarget.Label}. {this._targetCombo.Tag}"; + foreach (var (view, toggle) in this._viewToggles) toggle.Checked = this._defragView.Map.ViewMode == view; + this._filesPanelToggle.Checked = this._defragView.FilesPanelVisible; + this._legendToggle.Checked = this._defragView.LegendVisible; + } finally { + this._syncingDefragRibbon = false; + } + } + + /// Enables an option field and explains it: supported, not applicable, or refused. + private static void Option(RibbonFieldItem field, bool applies, Capability? capability, bool relevant, string appliesTo = "Defragment") { + field.Enabled = applies && capability?.Supported == true; + var reason = capability is null ? "Open or select an image to maintain." + : !relevant ? $"Applies to {appliesTo}." + : capability.Value.Reason; + field.ToolTipText = $"{field.Text}: {reason} {field.Tag}"; + } + + // ── running ───────────────────────────────────────────────────────────────────────────────── + + private void AnalyzeTarget() { + if (this._presenter is not { } presenter || presenter.IsRunning) return; + + var generation = ++this._analysisGeneration; + this._defragView.StatusText = $"Reading the layout of {this._maintenanceSession?.DisplayName}…"; + this.AnalysisTask = Task.Run(presenter.Analyze).ContinueWith(t => this.BeginInvoke(() => { + if (generation != this._analysisGeneration || this._presenter != presenter) return; + this._defragView.ShowSnapshot(t.IsCompletedSuccessfully ? t.Result : BlockMapSnapshot.Empty($"The layout could not be read: {t.Exception?.GetBaseException().Message}")); + }), TaskScheduler.Default); + } + + private void StartMaintenance() { + if (this._presenter is not { } presenter || this._maintenanceSession is not { } session) return; + if (presenter.StartBlocker is { } blocker) { + this._model.StatusText = blocker; + return; + } + + if (presenter.ConfirmationFor() is { } question + && MessageBox.Show(this, question, $"Confirm {presenter.Verb}", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) + return; + + ++this._analysisGeneration; // a pending analysis must not paint over the live run + this._maintenanceCancellation = new(); + this._maintenanceCommitStarted = false; + this._defragView.ProgressValue = 0; + + var run = presenter.RunAsync( + progress => this.BeginInvoke(() => this.OnMaintenanceProgress(progress)), + line => this.BeginInvoke(() => this._defragView.Log(line)), + this._maintenanceCancellation.Token); + this.SyncDefragmentRibbon(); + this.MaintenanceTask = run.ContinueWith(t => this.BeginInvoke(() => this.OnMaintenanceCompleted(presenter, session, t.Result)), TaskScheduler.Default); + } + + private void OnMaintenanceProgress(MaintenanceProgress progress) { + if (progress.CommitStarted) this._maintenanceCommitStarted = true; + this._defragView.Apply(progress); + this.SyncDefragmentRibbon(); + } + + private void OnMaintenanceCompleted(MaintenancePresenter presenter, MaintenanceSession session, MaintenanceOutcome outcome) { + this._maintenanceCancellation?.Dispose(); + this._maintenanceCancellation = null; + this._maintenanceCommitStarted = false; + this._defragView.ProgressValue = 100; + + // Re-listing the archive rewrites the status line, so the outcome is reported after it. + if (outcome.Mutated) session.NotifyMutated(); + + var name = session.DisplayName; + this._model.StatusText = outcome.Kind switch { + MaintenanceOutcomeKind.Succeeded => $"{presenter.Verb} finished on {name}: {outcome.Summary}", + MaintenanceOutcomeKind.Refused => $"{presenter.Verb} refused on {name}; the image was left unchanged. {outcome.Summary}", + MaintenanceOutcomeKind.Cancelled => $"{presenter.Verb} stopped on {name}. {outcome.Summary}", + _ => $"{presenter.Verb} failed on {name}: {outcome.Summary}", + }; + + if (outcome.Kind == MaintenanceOutcomeKind.Refused) + MessageBox.Show(this, $"{outcome.Summary}\n\nThe image was left unchanged.", $"{presenter.Verb} not supported", MessageBoxButtons.OK, MessageBoxIcon.Information); + else if (outcome.Kind == MaintenanceOutcomeKind.Failed) + MessageBox.Show(this, outcome.Summary, $"{presenter.Verb} failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + + if (this._defragActive && this._presenter == presenter) this.AnalyzeTarget(); + else if (!this._defragActive && this._maintenanceSession == session) this.CloseSession(); + + this.UpdateDiskTools(); + this.SyncDefragmentRibbon(); + } + + private void StopMaintenance() { + if (this._presenter is not { } presenter || this._maintenanceCancellation is not { IsCancellationRequested: false } cancellation) return; + if (this._maintenanceCommitStarted) return; + + if (!presenter.IsStaged + && MessageBox.Show(this, + $"Stop {presenter.Verb}?\n\nThis operation moves data in place. Moves already completed are not rolled back; it stops at the next safe " + + "boundary, so the layout may be partly changed although the contents stay intact.", + "Stop in-place maintenance", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) + return; + + this._defragView.Log(presenter.IsStaged + ? "Stop requested — the staged copy will be discarded; the image stays as it is." + : "Stop requested — moves already completed stay where they are."); + cancellation.Cancel(); + this.SyncDefragmentRibbon(); + } + + private void OnClosingWhileMaintaining(object? sender, FormClosingEventArgs e) { + if (e.Cancel || this._maintenanceCancellation is null) return; + + // Closing mid-run would cut an operation off between two writes. Ask it to stop instead; the + // window can close once it has. + e.Cancel = true; + if (!this._maintenanceCancellation.IsCancellationRequested && this._presenter?.CanCancel == true) this._maintenanceCancellation.Cancel(); + this._model.StatusText = "An operation is still running; it stops at the next safe point, then the window can close."; + } +} diff --git a/Compression.NativeUI/Views/MainForm.Ribbon.cs b/Compression.NativeUI/Views/MainForm.Ribbon.cs index f4d6deeb0..fc39e8784 100644 --- a/Compression.NativeUI/Views/MainForm.Ribbon.cs +++ b/Compression.NativeUI/Views/MainForm.Ribbon.cs @@ -8,8 +8,9 @@ namespace Compression.NativeUI.Views; /// /// The command surface: an Office-style ribbon in place of a menu bar and toolbar. Every command the /// shell has lives here once — the Quick Access Toolbar for navigation, File for archives as files, -/// Home for everyday file work, View for panes and layout, Tools for the specialist windows, and an -/// Archive Tools tab that appears only while the shell is inside an archive. +/// Home for everyday file work, View for panes and layout, Tools for the specialist windows, an +/// Archive Tools tab that appears only while the shell is inside an archive, and a Disk Tools tab +/// (Defragment) that appears while there is an image to maintain. /// /// /// Shortcut keys are registered on the ribbon items and dispatched form-wide. Keys that belong to the @@ -39,6 +40,7 @@ private void BuildRibbon() { this._ribbon.Tabs.Add(archive); this._archiveTools.Add(archive); this._ribbon.ContextualTabGroups.Add(this._archiveTools); + this.BuildDefragmentTab(); this._ribbon.SelectedIndex = 1; // Home, as in Explorer this._ribbon.PreferredHeightChanged += (_, _) => this.LayoutChildren(); @@ -119,7 +121,8 @@ private RibbonTab ToolsTab() { this.RibbonCommand("Analyze Entry", IconKeys.Analyze, this._model.AnalyzeCommand, RibbonItemSize.Small, Keys.None, "Analyze the selected entry"), this.RibbonAction("Reverse Engineer", IconKeys.Analyze, () => new ReverseEngineerWindow().Show(), RibbonItemSize.Small, Keys.None, "Work out an unknown format")), Group("Disks and Images", - this.RibbonAction("Maintenance", IconKeys.Defragment, this.OpenMaintenance, RibbonItemSize.Large, Keys.None, "Optimize, shrink, defragment, purge or wipe an image"), + this.RibbonCommand("Maintenance", IconKeys.Defragment, this._model.MaintenanceCommand, RibbonItemSize.Large, Keys.Control | Keys.Shift | Keys.D, + "Open the Defragment tab: defragment, optimize, shrink, compact, clear or purge the open or selected image (Ctrl+Shift+D)"), this.RibbonAction("Partitions", IconKeys.Create, this.OpenPartitionEditor, RibbonItemSize.Small, Keys.None, "Edit MBR and GPT partition tables"), this.RibbonAction("Mount", IconKeys.Open, this.OpenMountWindow, RibbonItemSize.Small, Keys.None, "Mount a filesystem image")), Group("Performance", @@ -137,13 +140,6 @@ private RibbonTab ArchiveTab() { this.RibbonCommand("Add Files", IconKeys.Add, this._model.AddFilesCommand, RibbonItemSize.Large, Keys.None, "Add files to the archive"), this.RibbonCommand("Test", IconKeys.Test, this._model.TestCommand, RibbonItemSize.Small, Keys.Control | Keys.T, "Test integrity (Ctrl+T)")), Group("Maintenance", - this.RibbonCommand("Compact", IconKeys.Defragment, this._model.CompactEntryCommand, RibbonItemSize.Large, Keys.None, "Defragment, optimize and shrink in one pass"), - this.RibbonCommand("Defragment", IconKeys.Defragment, this._model.DefragmentEntryCommand, RibbonItemSize.Small), - this.RibbonCommand("Optimize", IconKeys.Analyze, this._model.OptimizeEntryCommand, RibbonItemSize.Small), - this.RibbonCommand("Shrink", IconKeys.Defragment, this._model.ShrinkEntryCommand, RibbonItemSize.Small), - this.RibbonCommand("Purge", IconKeys.Remove, this._model.PurgeEntryCommand, RibbonItemSize.Small), - this.RibbonCommand("Wipe Free Space", IconKeys.Remove, this._model.WipeEntryCommand, RibbonItemSize.Small), - this.RibbonCommand("Scramble", IconKeys.Defragment, this._model.ScrambleEntryCommand, RibbonItemSize.Small), this.RibbonCommand("Reconfigure", IconKeys.Properties, this._model.ReconfigureEntryCommand, RibbonItemSize.Small))); return tab; } diff --git a/Compression.NativeUI/Views/MainForm.cs b/Compression.NativeUI/Views/MainForm.cs index 10f33428c..f5d25c13a 100644 --- a/Compression.NativeUI/Views/MainForm.cs +++ b/Compression.NativeUI/Views/MainForm.cs @@ -117,7 +117,8 @@ public MainForm(IEnumerable mountBackends, IMountLaunch this._contentSplit.Panel2.Controls.Add(this._preview); // Added in one place, in z-order: the drop overlay sits above the panes it covers. - this.Controls.AddRange(this._ribbon, this._breadcrumbBar, this._split, this._dropOverlay, this._status); + // The Defragment tab's block view takes the panes' place while that tab is selected. + this.Controls.AddRange(this._ribbon, this._breadcrumbBar, this._split, this._defragView, this._dropOverlay, this._status); this.DragOver += this.OnDragOver; this.DragLeave += (_, _) => this.SetDropOverlay(visible: false, ""); @@ -382,7 +383,7 @@ private void BuildEntryContextMenu() { var maintenance = new ToolStripMenuItem("&Maintenance") { Image = Images.Icon(IconKeys.Defragment) }; maintenance.DropDownItems.AddRange([ Tip(this.Item("&Compact (defrag + optimize + shrink)...", IconKeys.Defragment, Keys.None, this._model.CompactEntryCommand), - "One pass to the smallest valid container holding the same contents. Tick 'Minimal geometry' in the window for the bare-minimum (non-standard) rebuild."), + "One pass to the smallest valid container holding the same contents. Minimal Geometry on the Defragment tab gives the bare-minimum (non-standard) rebuild."), Tip(this.Item("&Reconfigure (geometry/options)...", IconKeys.Properties, Keys.None, this._model.ReconfigureEntryCommand), "Change an existing image's geometry/options after creation (e.g. FAT cluster size or root entries, NTFS MFT record size). Contents preserved byte-for-byte."), new ToolStripSeparator(), @@ -395,7 +396,7 @@ private void BuildEntryContextMenu() { new ToolStripSeparator(), Tip(this.Item("&Purge...", IconKeys.Remove, Keys.None, this._model.PurgeEntryCommand), "Erase ALL live data, leaving a valid empty container."), - Tip(this.Item("&Wipe Empty...", IconKeys.Remove, Keys.None, this._model.WipeEntryCommand), + Tip(this.Item("C&lear Free Space...", IconKeys.Remove, Keys.None, this._model.WipeEntryCommand), "Overwrite only unused space (free clusters, slack, deleted entries). Live data untouched."), Tip(this.Item("Scra&mble...", IconKeys.Defragment, Keys.None, this._model.ScrambleEntryCommand), "Scatter every block of every file across the volume — fragmentation on purpose, so Defragment has something real to work against. Content preserved; asks first."), @@ -463,6 +464,8 @@ private void LayoutChildren() { var contentWidth = width - this._split.SplitterDistance - this._split.SplitterWidth; this._contentSplit.SplitterDistance = Math.Max(this._contentSplit.Panel1MinSize, contentWidth - PreviewPaneWidth); } + this._defragView.Bounds = this._split.Bounds; + this._defragView.SizeFilesPanelOnce(); this._dropOverlay.Bounds = this._split.Bounds; this._status.Bounds = new(0, this.ClientSize.Height - StatusHeight, width, StatusHeight); } @@ -699,19 +702,6 @@ private void SetDropOverlay(bool visible, string message) { // ── Window commands ───────────────────────────────────────────────────────────────────────── - private void OpenMaintenance() { - var preselected = this._model.HasArchive ? this._model.ArchivePath : null; - var window = preselected is not null ? new DefragmentWindow(preselected) : new DefragmentWindow(); - - // Re-list whenever the maintenance window mutates the archive we have open. - window.ArchiveMutated += path => { - if (this._model.HasArchive && string.Equals(path, this._model.ArchivePath, StringComparison.OrdinalIgnoreCase)) - this._model.ReloadArchiveInPlace(); - }; - - window.Show(); - } - private void OpenPartitionEditor() { var preselected = this._model.HasArchive ? this._model.ArchivePath : null; if (preselected is not null && File.Exists(preselected)) { diff --git a/Compression.Registry/DefragRebuilder.cs b/Compression.Registry/DefragRebuilder.cs index 3f8bf4ca3..db00bdfe8 100644 --- a/Compression.Registry/DefragRebuilder.cs +++ b/Compression.Registry/DefragRebuilder.cs @@ -176,8 +176,8 @@ public static void Rebuild( /// classification emitted here is a listing-order proxy only. Honest /// mtime-based classification requires the pre-defrag snapshot built from /// on the descriptor's - /// output (see the WPF - /// DefragmentWindow.PreviewBlockMap method for the canonical + /// output (see the shell's + /// BlockMapSnapshot.Read method for the canonical /// implementation). /// private static DefragProgressEvent BuildScanEvent( @@ -210,7 +210,7 @@ private static DefragProgressEvent BuildScanEvent( /// access to entry mtimes (its input is just (name, bytes) tuples), so this /// proxy is the best the rebuild path can do; UI consumers wanting honest /// mtime-based classification should use the pre-defrag snapshot path - /// (DefragmentWindow.PreviewBlockMap) which has access to + /// (BlockMapSnapshot.Read in the shell) which has access to /// . /// /// diff --git a/Compression.Tests/NativeUI/HeadlessBackend.cs b/Compression.Tests/NativeUI/HeadlessBackend.cs index 980520013..e45331903 100644 --- a/Compression.Tests/NativeUI/HeadlessBackend.cs +++ b/Compression.Tests/NativeUI/HeadlessBackend.cs @@ -81,7 +81,7 @@ public Size MeasureText(string text, Font font) { // rather than a file of empty overrides each is a proxy that answers every call with default. public IButtonPeer CreateButton() => Stub(); public ICheckBoxPeer CreateCheckBox() => Stub(); - public IComboBoxPeer CreateComboBox() => Stub(); + public IComboBoxPeer CreateComboBox() => new RecordingComboBoxPeer(); public IGroupBoxPeer CreateGroupBox() => Stub(); public ILabelPeer CreateLabel() => Stub(); public ILinkLabelPeer CreateLinkLabel() => Stub(); diff --git a/Compression.Tests/NativeUI/MainFormTests.Defragment.cs b/Compression.Tests/NativeUI/MainFormTests.Defragment.cs new file mode 100644 index 000000000..c07b0cbad --- /dev/null +++ b/Compression.Tests/NativeUI/MainFormTests.Defragment.cs @@ -0,0 +1,520 @@ +using System; +using System.IO; +using System.Linq; +using Compression.Core.Layout; +using Compression.Lib; +using Compression.Registry; +using Compression.NativeUI; +using Compression.NativeUI.Controls; +using Compression.NativeUI.Navigation; +using Compression.NativeUI.ViewModels; +using Compression.NativeUI.Views; +using Hawkynt.NativeForms; +using NUnit.Framework; + +namespace Compression.Tests.NativeUI; + +/// The Defragment tab, driven through the assembled shell against real images. +public sealed partial class MainFormTests { + private static readonly TimeSpan Patience = TimeSpan.FromMinutes(2); + + /// A scratch folder holding the maintenance fixtures, removed afterwards. + private static void WithImages(Action body) { + var root = MaintenanceFixtures.Scratch(); + UserSettings.PathOverride = Path.Combine(root, "settings.json"); + FormatRegistration.EnsureInitialized(); + try { + body(root); + } finally { + UserSettings.PathOverride = null; + try { Directory.Delete(root, recursive: true); } catch { } + } + } + + private static RibbonContextualTabGroup DiskTools(MainForm shell) => Field(shell, "_diskTools"); + private static RibbonTab DefragTab(MainForm shell) => Field(shell, "_defragTab"); + private static DefragmentView DefragView(MainForm shell) => Field(shell, "_defragView"); + private static T Item(MainForm shell, string text) where T : RibbonItem + => DefragTab(shell).Groups.SelectMany(g => g.Items.Cast()).OfType().Single(i => i.Text == text); + private static RibbonToggleButton Toggle(MainForm shell, string text) => Item(shell, text); + + /// Opens , selects the Defragment tab and waits for the first map. + private static void OpenOnDefragmentTab(MainForm shell, string image) { + shell.OpenArchive(image); + RibbonItemNamed(shell, "Maintenance").PerformClick(); + Assert.That(Field(shell, "_ribbon").SelectedTab, Is.SameAs(DefragTab(shell)), "Maintenance opens the Defragment tab"); + Settle(shell); + } + + /// Waits for whatever the tab started — an operation, then the analysis it triggers. + private static void Settle(MainForm shell) { + Assert.That(shell.MaintenanceTask?.Wait(Patience) ?? true, Is.True, "the operation finished"); + Assert.That(shell.AnalysisTask?.Wait(Patience) ?? true, Is.True, "the analysis finished"); + } + + // ── the tab comes and goes with a target ──────────────────────────────────────────────────── + + [Test] + public void GivenNoImage_WhenTheShellStarts_ThenTheDiskToolsTabIsHiddenAndMaintenanceIsDisabled() { + WithImages(_ => WithShell(shell => { + Assert.That(DiskTools(shell).Visible, Is.False); + Assert.That(RibbonItemNamed(shell, "Maintenance").Enabled, Is.False, "nothing to maintain"); + })); + } + + [Test] + public void GivenAFatImage_WhenOpenedAndThenLeftForAFolder_ThenTheTabAppearsAndGoes() { + WithImages(root => WithShell(shell => { + var model = Field(shell, "_model"); + + shell.OpenArchive(MaintenanceFixtures.FragmentedFat(root)); + Assert.That(DiskTools(shell).Visible, Is.True, "an open FAT image can be maintained"); + + model.NavigateTo(Location.Folder(Path.Combine(root))); + model.SelectedEntries.Clear(); + CommandManager.InvalidateRequerySuggested(); + Assert.That(DiskTools(shell).Visible, Is.False, "a folder with nothing selected has nothing to maintain"); + })); + } + + [Test] + public void GivenAPartitionedDisk_WhenOpened_ThenNoMaintenanceIsOffered() { + WithImages(root => WithShell(shell => { + shell.OpenArchive(MaintenanceFixtures.PartitionedDisk(root)); + + Assert.That(Field(shell, "_model").Format, Is.EqualTo("PartitionedDisk")); + Assert.That(DiskTools(shell).Visible, Is.False, "a partitioned disk is read-only as a whole"); + Assert.That(RibbonItemNamed(shell, "Maintenance").Enabled, Is.False); + })); + } + + // ── the target ────────────────────────────────────────────────────────────────────────────── + + private static void Select(MainViewModel model, string name) { + model.SelectedEntries.Clear(); + model.SelectedEntries.Add(model.Entries.First(e => e.Name == name)); + CommandManager.InvalidateRequerySuggested(); + } + + private static RibbonComboBox TargetCombo(MainForm shell) => Field(shell, "_targetCombo"); + + /// + /// A plain file named f19.bin inside a FAT volume is not a BIN/CUE image. Its name used to + /// make it the target — "0 regions", an empty files panel, nearly everything disabled — instead of + /// the volume the user had open. + /// + [Test] + public void GivenAPlainBinFileSelectedInsideAFatVolume_WhenTheTabOpens_ThenTheVolumeIsTheTargetWithEveryFatOperation() { + WithImages(root => WithShell(shell => { + var model = Field(shell, "_model"); + shell.OpenArchive(MaintenanceFixtures.FatWithPlainBinFiles(root)); + Select(model, "f19.bin"); + + Assert.That(model.SelectedMaintenanceTarget, Is.Null, "a .bin name with no container content proves nothing"); + + RibbonItemNamed(shell, "Maintenance").PerformClick(); + Settle(shell); + + Assert.Multiple(() => { + Assert.That(TargetCombo(shell).Items, Is.EqualTo(new[] { "Open volume (disk.img)" })); + Assert.That(TargetCombo(shell).SelectedIndex, Is.EqualTo(0)); + Assert.That(TargetCombo(shell).Visible, Is.False, "with nothing to choose, the header line names the target and the field takes no room"); + Assert.That(DefragView(shell).TargetText, Does.StartWith("disk.img — ").And.Contain("FAT")); + Assert.That(DefragView(shell).StatusText, Does.StartWith("Real on-disk layout")); + foreach (var operation in new[] { "Defragment", "Shrink", "Clear", "Scramble" }) + Assert.That(Toggle(shell, operation).Enabled, Is.True, operation); + Assert.That(Toggle(shell, "Sort Entries").Enabled, Is.True); + }); + })); + } + + [TestCase("fake.img")] + [TestCase("notes.txt")] + public void GivenAnEntryWhoseOnlyClaimIsItsName_WhenSelected_ThenItIsNotOfferedAsATarget(string name) { + WithImages(root => WithShell(shell => { + var model = Field(shell, "_model"); + shell.OpenArchive(MaintenanceFixtures.ZipWithNestedImage(root)); + Select(model, name); + + Assert.That(model.SelectedMaintenanceTarget, Is.Null); + Assert.That(model.MaintenanceTargets().Select(t => t.Kind), Is.EqualTo(new[] { MaintenanceTargetKind.OpenVolume })); + })); + } + + [Test] + public void GivenARealFatImageInsideAZip_WhenSelected_ThenTheTabOffersItAndPickingItMakesItTheTarget() { + WithImages(root => WithShell(shell => { + var model = Field(shell, "_model"); + shell.OpenArchive(MaintenanceFixtures.ZipWithNestedImage(root)); + Select(model, "inner.img"); + + Assert.That(model.SelectedMaintenanceTarget?.FormatId, Is.EqualTo("Fat"), "its content is a FAT boot sector"); + + RibbonItemNamed(shell, "Maintenance").PerformClick(); + Settle(shell); + Assert.Multiple(() => { + Assert.That(TargetCombo(shell).Items, Is.EqualTo(new[] { "Open volume (nested.zip)", "Selected: inner.img (Fat)" })); + Assert.That(TargetCombo(shell).SelectedIndex, Is.EqualTo(0), "the open volume is the default"); + Assert.That(TargetCombo(shell).Visible, Is.True, "a choice to make shows the field"); + Assert.That(Toggle(shell, "Defragment").Enabled, Is.False, "the ZIP has no extents to move"); + }); + + TargetCombo(shell).SelectedIndex = 1; + Settle(shell); + Assert.Multiple(() => { + Assert.That(DefragView(shell).TargetText, Does.StartWith("inner.img — ")); + Assert.That(Toggle(shell, "Defragment").Enabled, Is.True, "the nested FAT image moves extents"); + Assert.That(Toggle(shell, "Sort Entries").Enabled, Is.True); + }); + })); + } + + [Test] + public void GivenAChosenTarget_WhenTheSelectionChangesOnTheTab_ThenTheTargetStaysAsChosen() { + WithImages(root => WithShell(shell => { + var model = Field(shell, "_model"); + shell.OpenArchive(MaintenanceFixtures.ZipWithNestedImage(root)); + Select(model, "inner.img"); + model.DefragmentEntryCommand.Execute(null); // the context menu: aimed at the selection + Settle(shell); + Assume.That(DefragView(shell).TargetText, Does.StartWith("inner.img — ")); + Toggle(shell, "Sort Entries").PerformClick(); + + Select(model, "notes.txt"); + Settle(shell); + + Assert.Multiple(() => { + Assert.That(DefragView(shell).TargetText, Does.StartWith("inner.img — "), "a selection change never retargets silently"); + Assert.That(TargetCombo(shell).Items, Does.Contain("Selected: inner.img (Fat)"), "the chosen target stays listed"); + Assert.That(Toggle(shell, "Sort Entries").Checked, Is.True, "the configured operation is kept"); + }); + })); + } + + /// + /// The owner wants interleave and the view modes as directly visible ribbon items, so no group of + /// the tab may fold into a drop-down at the default 1180 px width — neither in the plain case nor + /// in the widest one (a second target offered while Optimize shows its method). + /// + [Test] + public void GivenTheDefaultWindowWidth_WhenTheDefragmentTabIsShown_ThenNoGroupIsCollapsed() { + WithImages(root => WithShell(shell => { + var ribbon = Field(shell, "_ribbon"); + void AssertExpanded(string state) { + ribbon.PerformLayout(); + Assert.That(ribbon.Width, Is.EqualTo(1180), "the shell's default client width"); + var collapsed = DefragTab(shell).Groups.Where(g => g.IsCollapsed).Select(g => g.Text).ToList(); + Assert.That(collapsed, Is.Empty, state); + } + + OpenOnDefragmentTab(shell, MaintenanceFixtures.FragmentedFat(root)); + AssertExpanded("a FAT volume, one target"); + + var model = Field(shell, "_model"); + shell.OpenArchive(MaintenanceFixtures.ZipWithNestedImage(root)); + Select(model, "inner.img"); + Settle(shell); + Toggle(shell, "Optimize").PerformClick(); + Assume.That(TargetCombo(shell).Visible, Is.True); + AssertExpanded("a ZIP with a nested image offered and Optimize's method shown"); + })); + } + + // ── the client area ───────────────────────────────────────────────────────────────────────── + + [Test] + public void GivenTheBrowser_WhenTheDefragmentTabIsEnteredAndLeft_ThenTheBlockViewTakesThePanesPlaceAndTheBrowserReturnsAsItWas() { + WithImages(root => WithShell(shell => { + var split = Field(shell, "_split"); + var ribbon = Field(shell, "_ribbon"); + var model = Field(shell, "_model"); + shell.OpenArchive(MaintenanceFixtures.FragmentedFat(root)); + split.Panel1Collapsed = true; + var (distance, location, bounds) = (split.SplitterDistance, model.CurrentLocation, split.Bounds); + + OpenOnDefragmentTab(shell, model.ArchivePath); + var view = DefragView(shell); + Assert.Multiple(() => { + Assert.That(split.Visible, Is.False, "the browser gives up the client area"); + Assert.That(view.Visible, Is.True); + Assert.That(view.Bounds, Is.EqualTo(bounds), "the block view gets the whole client area the panes had"); + Assert.That(view.Map.BlockMap, Is.Not.Null.And.Not.Empty, "the map is drawn on entry"); + }); + + ribbon.SelectedIndex = 1; + Assert.Multiple(() => { + Assert.That(view.Visible, Is.False); + Assert.That(split.Visible, Is.True); + Assert.That(split.Panel1Collapsed, Is.True, "a folded navigation pane stays folded"); + Assert.That(split.SplitterDistance, Is.EqualTo(distance)); + Assert.That(model.CurrentLocation, Is.EqualTo(location), "the browser is where it was left"); + }); + })); + } + + // ── what is offered ───────────────────────────────────────────────────────────────────────── + + [Test] + public void GivenAFatImage_WhenOnTheTab_ThenEachItemFollowsTheRegistryProfileAndSaysWhy() { + WithImages(root => WithShell(shell => { + OpenOnDefragmentTab(shell, MaintenanceFixtures.FragmentedFat(root)); + + Assert.Multiple(() => { + Assert.That(Toggle(shell, "Defragment").Enabled, Is.True); + Assert.That(Toggle(shell, "Defragment").Checked, Is.True, "the first supported operation is preselected"); + Assert.That(Toggle(shell, "Shrink").Enabled, Is.True); + Assert.That(Toggle(shell, "Clear").Enabled, Is.True); + var profile = MaintenanceCapabilities.Describe("Fat")!; + var optimizes = profile.Supports(MaintenanceCapability.Compress) || profile.Supports(MaintenanceCapability.Repack) || profile.Supports(MaintenanceCapability.Canonicalize); + Assert.That(Toggle(shell, "Optimize").Enabled, Is.EqualTo(optimizes)); + Assert.That(Toggle(shell, "Optimize").ToolTipText, Does.StartWith("Optimize: "), "says why either way"); + Assert.That(Toggle(shell, "Sort Entries").Enabled, Is.True, "FAT sorts directory entries (#440)"); + Assert.That(Item(shell, "Start").Enabled, Is.True); + }); + })); + } + + [Test] + public void GivenAZip_WhenOnTheTab_ThenDefragmentIsNotOfferedButOptimizeAndPurgeAre() { + WithImages(root => WithShell(shell => { + OpenOnDefragmentTab(shell, MaintenanceFixtures.Zip(root)); + + Assert.Multiple(() => { + Assert.That(Toggle(shell, "Defragment").Enabled, Is.False); + Assert.That(Toggle(shell, "Optimize").Enabled, Is.True); + Assert.That(Toggle(shell, "Optimize").Checked, Is.True); + Assert.That(Item(shell, "Method").Visible, Is.True, "Optimize offers its methods"); + Assert.That(Item(shell, "Method").Items, Is.Not.Empty); + Assert.That(Toggle(shell, "Purge").Enabled, Is.True); + Assert.That(Toggle(shell, "Consolidate").Enabled, Is.False, "no defrag mode applies to an archive"); + }); + })); + } + + [Test] + public void GivenDefragment_WhenAnotherOperationIsPicked_ThenTheDefragModesAndLayoutOptionsGreyOut() { + WithImages(root => WithShell(shell => { + OpenOnDefragmentTab(shell, MaintenanceFixtures.FragmentedFat(root)); + var interleave = Field(shell, "_interleaveSpinner"); + Assert.That((Toggle(shell, "Consolidate").Enabled, interleave.Enabled), Is.EqualTo((true, true))); + + Toggle(shell, "Shrink").PerformClick(); + + Assert.Multiple(() => { + Assert.That(Toggle(shell, "Shrink").Checked, Is.True); + Assert.That(Toggle(shell, "Defragment").Checked, Is.False, "operations are a radio group"); + foreach (var mode in new[] { "Consolidate", "Defrag", "Re-order", "Carve Hole" }) { + Assert.That(Toggle(shell, mode).Enabled, Is.False, mode); + Assert.That(Toggle(shell, mode).ToolTipText, Does.Contain("Applies to Defragment"), mode); + } + Assert.That(interleave.Enabled, Is.False); + }); + + Toggle(shell, "Shrink").PerformClick(); + Assert.That(Toggle(shell, "Shrink").Checked, Is.True, "clicking the picked operation keeps it picked"); + })); + } + + [Test] + public void GivenCarveHole_WhenPickedAndUnpicked_ThenItsSizeAndOffsetAppearOnlyWhilePicked() { + WithImages(root => WithShell(shell => { + OpenOnDefragmentTab(shell, MaintenanceFixtures.FragmentedFat(root)); + var fields = new RibbonFieldItem[] { + Field(shell, "_holeSizeSpinner"), Field(shell, "_holePlacementCombo"), Field(shell, "_holeOffsetSpinner"), + }; + Assert.That(fields.Select(f => f.Visible), Is.All.False); + + Toggle(shell, "Carve Hole").PerformClick(); + Assert.That(fields.Select(f => f.Visible), Is.All.True); + + Toggle(shell, "Re-order").PerformClick(); + Assert.That(fields.Select(f => f.Visible), Is.All.False); + })); + } + + [Test] + public void GivenCarveHole_WhenTheHoleIsPlacedAtTheEndOrAtAnOffset_ThenTheOffsetFieldFollowsAndTheEngineGetsTheBytes() { + WithImages(root => WithShell(shell => { + OpenOnDefragmentTab(shell, MaintenanceFixtures.FragmentedFat(root)); + Toggle(shell, "Carve Hole").PerformClick(); + var placement = Field(shell, "_holePlacementCombo"); + var offset = Field(shell, "_holeOffsetSpinner"); + var presenter = Field(shell, "_presenter"); + Field(shell, "_holeSizeSpinner").Value = 128; + + Assert.That((offset.Enabled, presenter.BuildDefragOptions().HoleAt), Is.EqualTo((false, -1L)), "End: no offset, auto placement"); + + placement.SelectedIndex = 1; + offset.Value = 256; + var options = presenter.BuildDefragOptions(); + Assert.That((offset.Enabled, options.HoleSize, options.HoleAt), Is.EqualTo((true, 128L * 1024, 256L * 1024))); + })); + } + + /// + /// The interleave is a spinner over 1–256, so a value outside the range cannot be entered: it is + /// clamped, and Start stays available. The text-level boundaries are covered by the presenter tests. + /// + [TestCase(0, 1)] + [TestCase(1, 1)] + [TestCase(256, 256)] + [TestCase(257, 256)] + public void GivenTheInterleaveSpinner_WhenSet_ThenItClampsToOneTo256AndTheEngineGetsThatStride(int typed, int expected) { + WithImages(root => WithShell(shell => { + OpenOnDefragmentTab(shell, MaintenanceFixtures.FragmentedFat(root)); + var spinner = Field(shell, "_interleaveSpinner"); + spinner.Value = 7; // away from the boundary, so every case is a change + + spinner.Value = typed; + + var presenter = Field(shell, "_presenter"); + Assert.Multiple(() => { + Assert.That(spinner.Value, Is.EqualTo((decimal)expected)); + Assert.That(presenter.BuildDefragOptions().InterleaveStride, Is.EqualTo(expected)); + Assert.That(Item(shell, "Start").Enabled, Is.True); + }); + })); + } + + [Test] + public void GivenTheViewToggles_WhenEachIsClicked_ThenTheBlockMapSwitchesAndTheOthersRelease() { + WithImages(root => WithShell(shell => { + OpenOnDefragmentTab(shell, MaintenanceFixtures.FragmentedFat(root)); + var map = DefragView(shell).Map; + + foreach (var (text, view) in new[] { ("Circle", BlockMapView.CircularPlatter), ("3D Stack", BlockMapView.CylinderStack), ("Blocks", BlockMapView.LinearBlocks) }) { + Toggle(shell, text).PerformClick(); + Assert.That(map.ViewMode, Is.EqualTo(view), text); + Assert.That(new[] { "Blocks", "Circle", "3D Stack" }.Count(t => Toggle(shell, t).Checked), Is.EqualTo(1), text); + } + + Toggle(shell, "Files").PerformClick(); + Assert.That(DefragView(shell).FilesPanelVisible, Is.False); + })); + } + + /// + /// The view is hidden while the ribbon is built, so asking a child whether it is visible answers + /// no. Syncing the toggles from that answer once switched the colour key off for good. + /// + [Test] + public void GivenTheDefragmentTab_WhenEntered_ThenTheColourKeyIsShownAndItsToggleHidesIt() { + WithImages(root => WithShell(shell => { + OpenOnDefragmentTab(shell, MaintenanceFixtures.FragmentedFat(root)); + var legend = Field(DefragView(shell), "_legend"); + + Assert.That((Toggle(shell, "Legend").Checked, legend.Visible), Is.EqualTo((true, true))); + + Toggle(shell, "Legend").PerformClick(); + Assert.That((Toggle(shell, "Legend").Checked, legend.Visible), Is.EqualTo((false, false))); + })); + } + + [Test] + public void GivenAContextMenuVerb_WhenRun_ThenTheDefragmentTabOpensWithThatOperationPicked() { + WithImages(root => WithShell(shell => { + var model = Field(shell, "_model"); + shell.OpenArchive(MaintenanceFixtures.FragmentedFat(root)); + + model.ShrinkEntryCommand.Execute(null); + Settle(shell); + + Assert.That(Field(shell, "_ribbon").SelectedTab, Is.SameAs(DefragTab(shell))); + Assert.That(Toggle(shell, "Shrink").Checked, Is.True); + })); + } + + /// + /// GTK draws a native combo box at its natural height whatever bounds it is handed, which cut the + /// bottom off its text in a ribbon row. Every input on the tab is therefore a ribbon field — drawn + /// by the ribbon and always one stacked row — and none is a hosted native control. + /// + [Test] + public void GivenTheDefragmentTab_WhenItsInputsAreCollected_ThenEachIsAOneRowRibbonFieldAndNoneIsAHostedControl() { + WithImages(root => WithShell(shell => { + OpenOnDefragmentTab(shell, MaintenanceFixtures.FragmentedFat(root)); + var items = DefragTab(shell).Groups.SelectMany(g => g.Items.Cast()).ToList(); + + Assert.Multiple(() => { + Assert.That(items.OfType(), Is.Empty, "a hosted native control ignores its row height on GTK"); + var fields = items.OfType().ToList(); + Assert.That(fields.Select(f => f.Text), Is.SupersetOf(new[] { "Interleave", "Metadata", "Profile", "Hole KiB", "Hole at", "Offset KiB" })); + Assert.That(fields.Select(f => f.ItemSize), Is.All.EqualTo(RibbonItemSize.Small), "one stacked row each"); + }); + })); + } + + // ── running ───────────────────────────────────────────────────────────────────────────────── + + [Test] + public void GivenAFragmentedFat_WhenStartIsClicked_ThenItIsDefragmentedInPlaceWithSizeAndContentsUnchanged() { + WithImages(root => WithShell(shell => { + var image = MaintenanceFixtures.FragmentedFat(root); + var size = new FileInfo(image).Length; + OpenOnDefragmentTab(shell, image); + Assume.That(DefragView(shell).StatusText, Does.Contain("fragmented file")); + + Item(shell, "Start").PerformClick(); + Settle(shell); + + var model = Field(shell, "_model"); + Assert.Multiple(() => { + Assert.That(model.StatusText, Does.StartWith("Defragment finished"), model.StatusText); + Assert.That(new FileInfo(image).Length, Is.EqualTo(size), "total size unchanged"); + var after = MaintenanceFixtures.ReadFatFiles(image); + foreach (var (name, data) in MaintenanceFixtures.FatFiles) + Assert.That(after[name], Is.EqualTo(data), $"{name} byte-identical"); + Assert.That(DefragView(shell).StatusText, Does.Contain("no fragmentation"), "the map is read again afterwards"); + Assert.That(DiskTools(shell).Visible, Is.True, "the tab stays with its image after the reload"); + }); + })); + } + + [Test] + public void GivenAnUnsortedFat_WhenSortEntriesIsStarted_ThenTheRootIsSortedInPlaceAndTheLayoutOptionsDoNotApply() { + WithImages(root => WithShell(shell => { + var image = MaintenanceFixtures.UnsortedFat(root); + var size = new FileInfo(image).Length; + OpenOnDefragmentTab(shell, image); + + Toggle(shell, "Sort Entries").PerformClick(); + Assert.Multiple(() => { + Assert.That(Toggle(shell, "Sort Entries").Checked, Is.True); + Assert.That(Field(shell, "_interleaveSpinner").Enabled, Is.False, "a sort moves no data, so interleave does not apply"); + Assert.That(Field(shell, "_interleaveSpinner").ToolTipText, Does.Contain("extent moves")); + }); + + Item(shell, "Start").PerformClick(); + Settle(shell); + + var order = MaintenanceFixtures.RootOrder(image); + Assert.Multiple(() => { + Assert.That(Field(shell, "_model").StatusText, Does.StartWith("Defragment finished")); + Assert.That(order, Is.EqualTo(MaintenanceFixtures.NameOrder(order))); + Assert.That(new FileInfo(image).Length, Is.EqualTo(size)); + var after = MaintenanceFixtures.ReadFatFiles(image); + foreach (var (name, data) in MaintenanceFixtures.UnsortedFatFiles) + Assert.That(after[name], Is.EqualTo(data), name); + }); + })); + } + + [Test] + public void GivenARequestTheFormatRefuses_WhenStarted_ThenItIsReportedAndTheImageIsByteIdentical() { + WithImages(root => WithShell(shell => { + var image = MaintenanceFixtures.FragmentedFat(root); + var before = File.ReadAllBytes(image); + OpenOnDefragmentTab(shell, image); + + Toggle(shell, "Carve Hole").PerformClick(); + Field(shell, "_holeSizeSpinner").Value = 1024 * 1024; // 1 GiB on a 1.44 MB floppy + Item(shell, "Start").PerformClick(); + Settle(shell); + + Assert.Multiple(() => { + Assert.That(Field(shell, "_model").StatusText, Does.Contain("refused").And.Contain("unchanged")); + Assert.That(File.ReadAllBytes(image), Is.EqualTo(before)); + }); + })); + } +} diff --git a/Compression.Tests/NativeUI/MainFormTests.cs b/Compression.Tests/NativeUI/MainFormTests.cs index 1204abc12..648d26e2a 100644 --- a/Compression.Tests/NativeUI/MainFormTests.cs +++ b/Compression.Tests/NativeUI/MainFormTests.cs @@ -28,7 +28,7 @@ namespace Compression.Tests.NativeUI; /// [TestFixture] [NonParallelizable] -public sealed class MainFormTests { +public sealed partial class MainFormTests { private const BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; /// @@ -207,6 +207,7 @@ public void GivenViewAsText_WhenTheRibbonIsBuilt_ThenEnterIsShownButNotRegistere [TestCase("New Folder", Keys.Control | Keys.Shift | Keys.N)] [TestCase("Select All", Keys.Control | Keys.A)] [TestCase("Preview Pane", Keys.Alt | Keys.P)] + [TestCase("Maintenance", Keys.Control | Keys.Shift | Keys.D)] public void GivenACommand_WhenTheRibbonIsBuilt_ThenItCarriesItsShortcut(string label, Keys expected) { WithShell(shell => { Assert.That(RibbonItemNamed(shell, label).ShortcutKeys, Is.EqualTo(expected)); diff --git a/Compression.Tests/NativeUI/MaintenanceFixtures.cs b/Compression.Tests/NativeUI/MaintenanceFixtures.cs new file mode 100644 index 000000000..b59315c47 --- /dev/null +++ b/Compression.Tests/NativeUI/MaintenanceFixtures.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using Compression.Core.DiskImage; +using Compression.Lib; +using Compression.Registry; + +namespace Compression.Tests.NativeUI; + +/// Real images for the Defragment tab tests: a fragmented FAT floppy, a ZIP and a partitioned disk. +internal static class MaintenanceFixtures { + /// The files on the FAT fixture, by name, so a test can prove each survived byte for byte. + public static readonly IReadOnlyDictionary FatFiles = new Dictionary { + ["ALPHA.BIN"] = Pattern(23_000, 0x11), + ["BRAVO.BIN"] = Pattern(41_500, 0x22), + ["CHARLIE.TXT"] = Pattern(9_100, 0x33), + ["DELTA.DAT"] = Pattern(57_000, 0x44), + ["ECHO.LOG"] = Pattern(3_300, 0x55), + }; + + /// + /// The same contents under lowercase 8.3 names, as Linux writes them: a short entry with the + /// lowercase flags set and no long name, which FAT's layout walker reports in capitals. + /// + public static readonly IReadOnlyDictionary LowercaseFatFiles + = FatFiles.ToDictionary(f => f.Key.ToLowerInvariant(), f => f.Value); + + /// A 1.44 MB FAT12 floppy whose files are scattered on purpose, so defragmenting moves something. + public static string FragmentedFat(string folder, string name = "volume.img", IReadOnlyDictionary? files = null) { + var writer = new FileSystem.Fat.FatWriter(); + foreach (var (file, data) in files ?? FatFiles) writer.AddFile(file, data); + var path = Path.Combine(folder, name); + File.WriteAllBytes(path, writer.Build()); + + FormatRegistration.EnsureInitialized(); + using var image = File.Open(path, FileMode.Open, FileAccess.ReadWrite); + ((IFilesystemScrambleable)FormatRegistry.GetArchiveOps("Fat")!).Scramble(image, new ScrambleOptions { Seed = 0x5EED }); + return path; + } + + /// Files written to the root out of name order, long names and 8.3 names mixed. + public static readonly IReadOnlyDictionary UnsortedFatFiles = new Dictionary { + ["Zulu long file name.txt"] = Pattern(6_000, 0x61), + ["alpha.txt"] = Pattern(1_200, 0x62), + ["Mike.bin"] = Pattern(9_000, 0x63), + ["BETA.TXT"] = Pattern(700, 0x64), + }; + + /// A FAT12 floppy whose root directory is out of name order. + public static string UnsortedFat(string folder, string name = "unsorted.img") { + var writer = new FileSystem.Fat.FatWriter(); + foreach (var (file, data) in UnsortedFatFiles) writer.AddFile(file, data); + var path = Path.Combine(folder, name); + File.WriteAllBytes(path, writer.Build()); + return path; + } + + /// The root directory's file names in the order the directory holds them. + public static List RootOrder(string path) { + using var stream = File.OpenRead(path); + var reader = new FileSystem.Fat.FatReader(stream, leaveOpen: true); + return [.. reader.Entries.Where(e => !e.IsDirectory && !e.Name.Contains('/')).Select(e => e.Name)]; + } + + public static List NameOrder(IEnumerable names) + => [.. names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ThenBy(n => n, StringComparer.Ordinal)]; + + /// A FAT floppy holding plain data files named *.bin — a name BIN/CUE claims, a content nothing does. + public static string FatWithPlainBinFiles(string folder, string name = "disk.img") { + var writer = new FileSystem.Fat.FatWriter(); + for (var i = 1; i <= 20; ++i) writer.AddFile($"f{i}.bin", Pattern(3_000 + i * 97, i)); + var path = Path.Combine(folder, name); + File.WriteAllBytes(path, writer.Build()); + return path; + } + + /// + /// A ZIP holding a real FAT image (inner.img), a file that only looks like one by its name + /// (fake.img, plain bytes) and a text file. + /// + public static string ZipWithNestedImage(string folder, string name = "nested.zip") { + var inner = new FileSystem.Fat.FatWriter(); + inner.AddFile("INNER.TXT", Pattern(5_000, 0x71)); + var path = Path.Combine(folder, name); + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + using (var stream = archive.CreateEntry("inner.img", CompressionLevel.NoCompression).Open()) stream.Write(inner.Build()); + using (var stream = archive.CreateEntry("fake.img").Open()) stream.Write(Pattern(40_000, 0x72)); + using (var writer = new StreamWriter(archive.CreateEntry("notes.txt").Open())) writer.Write("notes"); + return path; + } + + public static string Zip(string folder, string name = "bundle.zip") { + var path = Path.Combine(folder, name); + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + using (var writer = new StreamWriter(archive.CreateEntry("readme.txt").Open())) writer.Write(new string('z', 4000)); + using (var writer = new StreamWriter(archive.CreateEntry("docs/notes.txt").Open())) writer.Write("notes"); + return path; + } + + /// A whole-disk image: an MBR with one FAT partition starting at 1 MiB. + public static string PartitionedDisk(string folder, string name = "disk.img") { + const long mib = 1024 * 1024; + var writer = new FileSystem.Fat.FatWriter(); + writer.AddFile("HELLO.TXT", "hello"u8.ToArray()); + var fat = writer.Build(); + var length = (fat.Length + 511) / 512 * 512; + var disk = new byte[mib + length + mib]; + using (var stream = new MemoryStream(disk, writable: true)) + new PartitionEditor(stream).AddPartition(mib, length, PartitionType.Fat16Lba, "data"); + fat.CopyTo(disk, (int)mib); + + var path = Path.Combine(folder, name); + File.WriteAllBytes(path, disk); + return path; + } + + /// Every file on the FAT image, read back through the descriptor. + public static Dictionary ReadFatFiles(string path) { + var ops = FormatRegistry.GetArchiveOps("Fat")!; + using var stream = File.OpenRead(path); + var names = ops.List(stream, password: null).Where(e => !e.IsDirectory).Select(e => e.Name).ToList(); + return names.ToDictionary(n => n, n => ArchiveOperations.ExtractEntry(path, n, password: null)); + } + + public static string Scratch() { + var root = Path.Combine(Path.GetTempPath(), "cwb-defrag-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static byte[] Pattern(int length, int seed) { + var data = new byte[length]; + for (var i = 0; i < length; ++i) data[i] = (byte)((i * 31 + seed + i / 251) & 0xFF); + return data; + } +} diff --git a/Compression.Tests/NativeUI/MaintenancePresenterTests.cs b/Compression.Tests/NativeUI/MaintenancePresenterTests.cs new file mode 100644 index 000000000..4ce2712a0 --- /dev/null +++ b/Compression.Tests/NativeUI/MaintenancePresenterTests.cs @@ -0,0 +1,356 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using Compression.Lib; +using Compression.NativeUI.Maintenance; +using Compression.Registry; +using NUnit.Framework; + +namespace Compression.Tests.NativeUI; + +/// +/// The Defragment tab's logic without the tab: what each real format is said to support, how typed +/// values are read, how the choices map onto the engine, and that a run keeps the defrag invariants +/// or refuses with the image untouched. +/// +[TestFixture] +public sealed class MaintenancePresenterTests { + private string _root = null!; + + [SetUp] + public void SetUp() { + FormatRegistration.EnsureInitialized(); + this._root = MaintenanceFixtures.Scratch(); + } + + [TearDown] + public void TearDown() { + try { Directory.Delete(this._root, recursive: true); } catch { } + } + + // ── capabilities ──────────────────────────────────────────────────────────────────────────── + + [Test] + public void GivenFat_WhenCapabilitiesAreAsked_ThenExtentDefragmentSortAndTheirModesAreOffered() { + var caps = TargetCapabilities.For("Fat"); + + Assert.Multiple(() => { + Assert.That(caps.Verb(MaintenanceVerb.Defragment).Supported, Is.True); + Assert.That(caps.Verb(MaintenanceVerb.Shrink).Supported, Is.True); + Assert.That(caps.Verb(MaintenanceVerb.WipeEmpty).Supported, Is.True); + Assert.That(caps.Verb(MaintenanceVerb.Scramble).Supported, Is.True); + foreach (var strategy in new[] { DefragStrategy.Consolidate, DefragStrategy.Defrag, DefragStrategy.Reorder, DefragStrategy.CarveHole, DefragStrategy.SortEntries }) + Assert.That(caps.Strategy(strategy).Supported, Is.True, strategy.ToString()); + Assert.That(caps.PackAtStart.Supported && caps.PackAtEnd.Supported, Is.True); + Assert.That(caps.Interleave.Supported, Is.True, "FAT's planner honours block interleave"); + Assert.That(caps.MetadataZone.Supported, Is.True); + Assert.That(caps.LayoutProfile.Supported, Is.False, "FAT does not declare layout templates, so the profile is not offered"); + }); + } + + [TestCase("Fat", true)] + [TestCase("ExFat", true)] + [TestCase("Ntfs", false)] + [TestCase("Zip", false)] + public void GivenAFormat_WhenSortEntriesIsAsked_ThenOnlyFatAndExFatOfferIt(string format, bool offered) { + var sort = TargetCapabilities.For(format).Strategy(DefragStrategy.SortEntries); + + Assert.That(sort.Supported, Is.EqualTo(offered)); + if (!offered) Assert.That(sort.Reason, Does.Contain("sorting directory entries")); + } + + [Test] + public void GivenZip_WhenCapabilitiesAreAsked_ThenDefragmentIsNotOfferedButOptimizeIsWithItsMethods() { + var caps = TargetCapabilities.For("Zip"); + var profile = MaintenanceCapabilities.Describe("Zip")!; + + Assert.Multiple(() => { + Assert.That(caps.Verb(MaintenanceVerb.Defragment).Supported, Is.False, "an archive has no free-space layout to defragment (#432)"); + Assert.That(caps.Verb(MaintenanceVerb.Optimize).Supported, Is.True); + Assert.That(caps.OptimizeMethods, Is.Not.Empty); + Assert.That(caps.OptimizeMethods.Contains(OptimizeMethod.Compress), Is.EqualTo(profile.Supports(MaintenanceCapability.Compress))); + Assert.That(caps.OptimizeMethods.Contains(OptimizeMethod.Repack), Is.EqualTo(profile.Supports(MaintenanceCapability.Repack))); + Assert.That(caps.Verb(MaintenanceVerb.Scramble).Supported, Is.False); + Assert.That(Enum.GetValues().Any(s => caps.Strategy(s).Supported), Is.False); + }); + } + + [Test] + public void GivenAPartitionedDisk_WhenCapabilitiesAreAsked_ThenNothingIsOffered() { + var caps = TargetCapabilities.For("PartitionedDisk"); + + Assert.That(caps.AnySupported, Is.False, "a partitioned disk is read-only; each volume is maintained on its own"); + Assert.That(caps.Verbs.Values.All(v => v.Reason.Length > 0), Is.True, "every refusal says why"); + } + + /// + /// The tab is a view of the registry's maintenance profile: for every registered format, each + /// ribbon answer must be exactly what says. + /// + [Test] + public void GivenEveryRegisteredFormat_WhenTheTabAsks_ThenItAgreesWithTheRegistryProfile() { + var mismatches = new System.Collections.Generic.List(); + foreach (var descriptor in FormatRegistry.All) { + var profile = MaintenanceCapabilities.Describe(descriptor); + var caps = TargetCapabilities.For(descriptor.Id); + void Check(string what, bool tab, bool registry) { + if (tab != registry) mismatches.Add($"{descriptor.Id} {what}: tab {tab}, registry {registry}"); + } + + Check("Defragment", caps.Verb(MaintenanceVerb.Defragment).Supported, + profile.Supports(MaintenanceCapability.DefragmentExtents) || profile.Supports(MaintenanceCapability.SortDirectoryEntries)); + Check("Optimize", caps.Verb(MaintenanceVerb.Optimize).Supported, + profile.Supports(MaintenanceCapability.Compress) || profile.Supports(MaintenanceCapability.Repack) || profile.Supports(MaintenanceCapability.Canonicalize)); + Check("Shrink", caps.Verb(MaintenanceVerb.Shrink).Supported, profile.Supports(MaintenanceCapability.Shrink)); + Check("Purge", caps.Verb(MaintenanceVerb.Purge).Supported, profile.Supports(MaintenanceCapability.Purge)); + Check("Clear", caps.Verb(MaintenanceVerb.WipeEmpty).Supported, profile.Supports(MaintenanceCapability.WipeUnused)); + Check("Compact", caps.Verb(MaintenanceVerb.Compact).Supported, profile.Supports(MaintenanceCapability.Compact)); + Check("Scramble", caps.Verb(MaintenanceVerb.Scramble).Supported, profile.Supports(MaintenanceCapability.Scramble)); + Check("Sort Entries", caps.Strategy(DefragStrategy.SortEntries).Supported, profile.Supports(MaintenanceCapability.SortDirectoryEntries)); + Check("Carve Hole", caps.Strategy(DefragStrategy.CarveHole).Supported, + profile.Supports(MaintenanceCapability.DefragmentExtents) && profile.Supports(DefragFeature.CarveHole)); + Check("Interleave", caps.Interleave.Supported, + profile.Supports(MaintenanceCapability.DefragmentExtents) && profile.Supports(DefragFeature.Interleave)); + } + + Assert.That(mismatches, Is.Empty); + } + + // ── typed values ──────────────────────────────────────────────────────────────────────────── + + [TestCase("0", false)] + [TestCase("1", true)] + [TestCase("2", true)] + [TestCase("256", true)] + [TestCase("257", false)] + [TestCase("-1", false)] + [TestCase("abc", false)] + [TestCase("1.5", false)] + [TestCase("", false)] + [TestCase(" 16 ", true)] + public void GivenAnInterleave_WhenParsed_ThenOnlyOneTo256IsAccepted(string text, bool valid) { + var parsed = MaintenanceInput.ParseInterleave(text); + + Assert.That(parsed.IsValid, Is.EqualTo(valid), parsed.Error); + if (valid) Assert.That(parsed.Value, Is.EqualTo(int.Parse(text.Trim()))); + } + + [TestCase("524288", 524288L)] + [TestCase("64k", 64L * 1024)] + [TestCase("64m", 64L * 1024 * 1024)] + [TestCase("1g", 1L << 30)] + [TestCase("1G", 1L << 30)] + public void GivenAHoleSize_WhenParsed_ThenSuffixesScale(string text, long expected) + => Assert.That(MaintenanceInput.ParseSize(text).Value, Is.EqualTo(expected)); + + [TestCase("0")] + [TestCase("")] + [TestCase("m")] + [TestCase("-5")] + [TestCase("ten")] + [TestCase("99999999999999999g")] + public void GivenAnUnusableHoleSize_WhenParsed_ThenItIsRefused(string text) + => Assert.That(MaintenanceInput.ParseSize(text).IsValid, Is.False); + + [TestCase("auto", -1L, true)] + [TestCase("", -1L, true)] + [TestCase("4096", 4096L, true)] + [TestCase("-4096", 0L, false)] + [TestCase("start", 0L, false)] + public void GivenAHoleOffset_WhenParsed_ThenAutoOrAnOffsetIsAccepted(string text, long expected, bool valid) { + var parsed = MaintenanceInput.ParseHoleAt(text); + Assert.That(parsed.IsValid, Is.EqualTo(valid)); + if (valid) Assert.That(parsed.Value, Is.EqualTo(expected)); + } + + // ── choices onto the engine ───────────────────────────────────────────────────────────────── + + [TestCase("Consolidate", false, DefragMode.ConsolidateAtStart)] + [TestCase("Consolidate", true, DefragMode.ConsolidateAtEnd)] + [TestCase("Defrag", false, DefragMode.FillHolesLazy)] + [TestCase("Reorder", false, DefragMode.AscendingOrder)] + [TestCase("CarveHole", false, DefragMode.CarveHole)] + public void GivenAStrategy_WhenOptionsAreBuilt_ThenItMapsOntoItsEngineMode(string strategy, bool packAtEnd, DefragMode expected) { + var presenter = new MaintenancePresenter(MaintenanceFixtures.FragmentedFat(this._root), "Fat") { + Verb = MaintenanceVerb.Defragment, Strategy = Enum.Parse(strategy), PackAtEnd = packAtEnd, + }; + + Assert.That(presenter.BuildDefragOptions().Mode, Is.EqualTo(expected)); + } + + [Test] + public void GivenCarveHole_WhenOptionsAreBuilt_ThenTheTypedSizeAndOffsetReachTheEngine() { + var presenter = new MaintenancePresenter(MaintenanceFixtures.FragmentedFat(this._root), "Fat") { + Verb = MaintenanceVerb.Defragment, Strategy = DefragStrategy.CarveHole, HoleSizeText = "64k", HoleAtText = "8192", InterleaveText = "3", + }; + + var options = presenter.BuildDefragOptions(); + Assert.That((options.HoleSize, options.HoleAt, options.InterleaveStride), Is.EqualTo((64L * 1024, 8192L, 3))); + } + + [Test] + public void GivenAnOptionTheFormatRefuses_WhenOptionsAreBuilt_ThenItStaysAtItsDefault() { + var presenter = new MaintenancePresenter(MaintenanceFixtures.FragmentedFat(this._root), "Fat") { + Verb = MaintenanceVerb.Defragment, LayoutProfile = new() { Name = "ignored" }, + }; + + Assert.That(presenter.BuildDefragOptions().LayoutTemplate, Is.Null, "a disabled option must not reach the engine to be refused or ignored"); + } + + [TestCase("0")] + [TestCase("257")] + [TestCase("x")] + public void GivenAnInvalidInterleave_WhenDefragmentIsPicked_ThenStartIsBlockedWithTheReason(string stride) { + var presenter = new MaintenancePresenter(MaintenanceFixtures.FragmentedFat(this._root), "Fat") { Verb = MaintenanceVerb.Defragment, InterleaveText = stride }; + + Assert.That(presenter.StartBlocker, Does.Contain("Interleave")); + } + + [Test] + public void GivenAnInvalidInterleave_WhenAnotherOperationIsPicked_ThenItDoesNotBlockStart() { + var presenter = new MaintenancePresenter(MaintenanceFixtures.FragmentedFat(this._root), "Fat") { Verb = MaintenanceVerb.WipeEmpty, InterleaveText = "0" }; + + Assert.That(presenter.StartBlocker, Is.Null, "interleave only applies to Defragment"); + } + + // ── the files panel ───────────────────────────────────────────────────────────────────────── + + /// + /// Linux writes a lowercase 8.3 name as a short entry with the lowercase flags; FAT's layout walker + /// reports it in capitals while the lister reports it in lowercase. The fragment counts were looked + /// up by the listed name and so came out as an em dash for every file. + /// + [TestCase(false)] + [TestCase(true)] + public void GivenAFragmentedFat_WhenAnalyzed_ThenEveryFileShowsTheRunsTheLayoutMapGivesIt(bool lowercase) { + var files = lowercase ? MaintenanceFixtures.LowercaseFatFiles : MaintenanceFixtures.FatFiles; + var path = MaintenanceFixtures.FragmentedFat(this._root, files: files); + System.Collections.Generic.Dictionary expected; + using (var stream = File.OpenRead(path)) + expected = BlockMapSnapshot.CountRunsByOwner([.. ((IFilesystemExtentMap)FormatRegistry.GetArchiveOps("Fat")!).EnumerateExtents(stream)]); + + var snapshot = new MaintenancePresenter(path, "Fat").Analyze(); + + Assert.Multiple(() => { + Assert.That(snapshot.Rows.Select(r => r.Name), Is.EquivalentTo(files.Keys)); + foreach (var row in snapshot.Rows) { + var runs = expected.Single(e => string.Equals(e.Key, row.Name, StringComparison.OrdinalIgnoreCase)).Value; + Assert.That(row.FragmentsDisplay, Is.EqualTo(runs.ToString("N0")), row.Name); + } + Assert.That(snapshot.Rows.Count(r => int.Parse(r.FragmentsDisplay) > 1), Is.GreaterThan(0), "the fixture is fragmented"); + }); + } + + [Test] + public void GivenLayoutOwners_WhenLookedUpByListedName_ThenExactWinsAndCaseOrSlashDifferencesMatchOnlyWhenUnambiguous() { + var lookup = OwnerLookup.From([("BIG.BIN", 3), ("/docs/Note.txt", 2), ("a.txt", 1), ("A.TXT", 5)]); + + Assert.Multiple(() => { + Assert.That(lookup.TryGetValue("big.bin", out var big) ? big : -1, Is.EqualTo(3), "case differs only"); + Assert.That(lookup.TryGetValue("docs/note.txt", out var note) ? note : -1, Is.EqualTo(2), "leading slash and case"); + Assert.That(lookup.TryGetValue("A.TXT", out var exact) ? exact : -1, Is.EqualTo(5), "an exact match wins"); + Assert.That(lookup.TryGetValue("A.txt", out _), Is.False, "two owners differ only by case: no guess"); + Assert.That(lookup.TryGetValue("missing.bin", out _), Is.False); + }); + } + + // ── the run log ───────────────────────────────────────────────────────────────────────────── + + [TestCase("SUCCEEDED (31 ms) — Defragmentation complete — 46 moves, 0 bytes staged in memory", 20)] + [TestCase("short", 20)] + [TestCase("averyveryverylongwordwithoutanyspacesatallthatmustbebroken", 10)] + [TestCase("", 10)] + public void GivenALogLine_WhenWrappedToAWidth_ThenNoRowIsWiderAndNothingIsLost(string line, int columns) { + static int Measure(string s) => s.Length; // one unit per character + + var rows = global::Compression.NativeUI.Controls.LogView.Wrap(line, columns, Measure); + + Assert.Multiple(() => { + Assert.That(rows.All(r => Measure(r) <= columns), Is.True, string.Join(" | ", rows)); + Assert.That(string.Concat(rows).Replace(" ", ""), Is.EqualTo(line.Replace(" ", "")), "only spaces at a break may go"); + }); + } + + // ── running ───────────────────────────────────────────────────────────────────────────────── + + [Test] + public void GivenAFragmentedFat_WhenDefragmented_ThenSizeAndEveryFileAreUnchangedAndTheFilesAreContiguous() { + var path = MaintenanceFixtures.FragmentedFat(this._root); + var sizeBefore = new FileInfo(path).Length; + var presenter = new MaintenancePresenter(path, "Fat") { Verb = MaintenanceVerb.Defragment, Strategy = DefragStrategy.Consolidate }; + Assume.That(presenter.Analyze().Status, Does.Contain("fragmented file"), "the fixture must start fragmented"); + + var outcome = presenter.RunAsync(_ => { }, _ => { }, CancellationToken.None).Result; + + Assert.Multiple(() => { + Assert.That(outcome.Kind, Is.EqualTo(MaintenanceOutcomeKind.Succeeded), outcome.Summary); + Assert.That(new FileInfo(path).Length, Is.EqualTo(sizeBefore), "defragmenting never changes the image size"); + var after = MaintenanceFixtures.ReadFatFiles(path); + foreach (var (name, data) in MaintenanceFixtures.FatFiles) + Assert.That(after[name], Is.EqualTo(data), name); + Assert.That(presenter.Analyze().Status, Does.Contain("no fragmentation")); + }); + } + + [Test] + public void GivenAnUnsortedFat_WhenSortEntriesRuns_ThenTheRootIsInNameOrderAndSizeAndEveryFileAreUnchanged() { + var path = MaintenanceFixtures.UnsortedFat(this._root); + var size = new FileInfo(path).Length; + Assume.That(MaintenanceFixtures.RootOrder(path), Is.Not.EqualTo(MaintenanceFixtures.NameOrder(MaintenanceFixtures.RootOrder(path))), "the fixture starts unsorted"); + var presenter = new MaintenancePresenter(path, "Fat") { Verb = MaintenanceVerb.Defragment, Strategy = DefragStrategy.SortEntries, InterleaveText = "0" }; + + var outcome = presenter.RunAsync(_ => { }, _ => { }, CancellationToken.None).Result; + + var order = MaintenanceFixtures.RootOrder(path); + Assert.Multiple(() => { + Assert.That(outcome.Kind, Is.EqualTo(MaintenanceOutcomeKind.Succeeded), outcome.Summary); + Assert.That(order, Is.EqualTo(MaintenanceFixtures.NameOrder(order)), "the root is in name order"); + Assert.That(new FileInfo(path).Length, Is.EqualTo(size)); + var after = MaintenanceFixtures.ReadFatFiles(path); + foreach (var (name, data) in MaintenanceFixtures.UnsortedFatFiles) + Assert.That(after[name], Is.EqualTo(data), name); + Assert.That(presenter.CanCancel, Is.False, "a directory sort is one pass, not a move that can stop part-way"); + }); + } + + /// fsck.fat accepts the sorted volume. Needs WSL with dosfstools; skipped otherwise. + [Test, Category("ExternalFsInterop")] + public void GivenAnUnsortedFat_WhenSortEntriesRuns_ThenFsckFatAcceptsTheVolume() { + if (!FsInteropToolbox.WslAvailable || !FsInteropToolbox.WslHasTool("fsck.fat")) + Assert.Ignore("needs WSL with fsck.fat (dosfstools)"); + var path = MaintenanceFixtures.UnsortedFat(this._root); + var presenter = new MaintenancePresenter(path, "Fat") { Verb = MaintenanceVerb.Defragment, Strategy = DefragStrategy.SortEntries }; + Assume.That(presenter.RunAsync(_ => { }, _ => { }, CancellationToken.None).Result.Kind, Is.EqualTo(MaintenanceOutcomeKind.Succeeded)); + + var (stdOut, stdErr, exitCode) = FsInteropToolbox.RunWsl($"fsck.fat -n -V {FsInteropToolbox.WinToWsl(path)}"); + + Assert.That(exitCode, Is.EqualTo(0), $"fsck.fat rejected the sorted volume:\n{stdOut}\n{stdErr}"); + } + + [Test] + public void GivenAHoleLargerThanTheVolume_WhenCarved_ThenTheRunIsRefusedAndTheImageIsByteIdentical() { + var path = MaintenanceFixtures.FragmentedFat(this._root); + var before = File.ReadAllBytes(path); + var presenter = new MaintenancePresenter(path, "Fat") { Verb = MaintenanceVerb.Defragment, Strategy = DefragStrategy.CarveHole, HoleSizeText = "1g" }; + + var outcome = presenter.RunAsync(_ => { }, _ => { }, CancellationToken.None).Result; + + Assert.Multiple(() => { + Assert.That(outcome.Kind, Is.EqualTo(MaintenanceOutcomeKind.Refused), outcome.Summary); + Assert.That(outcome.Mutated, Is.False); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "a refusal leaves the image exactly as it was"); + }); + } + + [Test] + public void GivenAnUnsupportedOperation_WhenRun_ThenItIsRefusedWithoutTouchingTheImage() { + var path = MaintenanceFixtures.Zip(this._root); + var before = File.ReadAllBytes(path); + var presenter = new MaintenancePresenter(path, "Zip") { Verb = MaintenanceVerb.Defragment }; + + var outcome = presenter.RunAsync(_ => { }, _ => { }, CancellationToken.None).Result; + + Assert.That((outcome.Kind, File.ReadAllBytes(path).SequenceEqual(before)), Is.EqualTo((MaintenanceOutcomeKind.Refused, true))); + } +} diff --git a/Compression.Tests/NativeUI/RecordingPeers.cs b/Compression.Tests/NativeUI/RecordingPeers.cs index 5a4f8beca..39b205906 100644 --- a/Compression.Tests/NativeUI/RecordingPeers.cs +++ b/Compression.Tests/NativeUI/RecordingPeers.cs @@ -175,4 +175,27 @@ public void SetTopMost(bool topMost) { } public void SetWindowState(FormWindowState state) { } } +/// +/// A combo box peer that keeps its items and selection. It has to be a real class: the interface +/// takes its items as a , which a dispatch proxy cannot box. +/// +internal sealed class RecordingComboBoxPeer : RecordingControlPeer, IComboBoxPeer { + private int _selectedIndex = -1; + + public string[] Items { get; private set; } = []; + + public event EventHandler? SelectionChanged; + public event EventHandler? DropDownOpened; + public event EventHandler? DropDownClosed; + + public void SetItems(ReadOnlySpan items, int selectedIndex) { + this.Items = items.ToArray(); + this._selectedIndex = selectedIndex; + } + + public void SetSelectedIndex(int index) => this._selectedIndex = index; + public int GetSelectedIndex() => this._selectedIndex; + public void SetDroppedDown(bool droppedDown) { } +} + #pragma warning restore CS0067 diff --git a/README.md b/README.md index 4889577a9..087f4475c 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,18 @@ Power-user navigation includes: - **Registered file-type filters** — the Open dialog exposes an all-formats filter plus generated per-format filters. +**Maintenance** has its own contextual ribbon tab, *Disk Tools → Defragment* (Tools → Maintenance, +Ctrl+Shift+D), shown while an image or archive that supports any maintenance operation is open or +selected. While it is selected the block map takes the whole client area — linear blocks, a circular +platter or a 3-D stack of platters, with an optional files panel and colour key — and the ribbon picks +the operation (Defragment, Optimize, Shrink, Compact, Clear free space, Purge, Scramble), the +defragmentation mode (Consolidate at the start or end, Defrag by filling holes, Re-order into ascending +blocks, Carve a hole of a chosen size and offset, or Sort Entries — directories sorted by name in place, +on FAT and exFAT), the block interleave (1–256), metadata placement, layout profile and, for Optimize, +whether to compress, repack or canonicalize. Each item is enabled only where the format supports it and says why when it is not; +a request the format refuses leaves the image byte-identical. Leaving the tab returns the browser +exactly as it was. + The Binary Analysis wizard walks progressively deeper through an unknown binary: - **Scan Results** — registered magic-byte signatures with offsets and confidence. diff --git a/build/NativeForms.ref b/build/NativeForms.ref index a7f5ea30d..2c5b0b52e 100644 --- a/build/NativeForms.ref +++ b/build/NativeForms.ref @@ -1 +1 @@ -69bf60fa8cbc2c1f781082c41729f316085ca1fe +1e1438506f6240c3eb7f3d616cce9ea374b84d61 diff --git a/docs/screenshots/archive-browser.png b/docs/screenshots/archive-browser.png index a08143529..b122a5b70 100644 Binary files a/docs/screenshots/archive-browser.png and b/docs/screenshots/archive-browser.png differ diff --git a/docs/screenshots/maintenance.png b/docs/screenshots/maintenance.png index bbe18b308..ddd2d329d 100644 Binary files a/docs/screenshots/maintenance.png and b/docs/screenshots/maintenance.png differ diff --git a/screenshots/main-window.png b/screenshots/main-window.png index a08143529..b122a5b70 100644 Binary files a/screenshots/main-window.png and b/screenshots/main-window.png differ