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
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