From 05e134f2f52640ec265f5a636cb0c144a62bf862 Mon Sep 17 00:00:00 2001 From: nextestudios <47460003+nextestudios@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:07:18 -0300 Subject: [PATCH] feat: RAR in the Compress format picker (through the user's WinRAR) Choices can carry a disabled reason; the RAR format shows greyed out with the reason unless the service finds the user's WinRAR (IArchiveService.GetCreationAvailability). Test covers both cases with an injected locator. README/guides/changelog updated. Closes #258 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 2 + CHANGELOG.md | 2 + README.md | 2 +- README.pt-BR.md | 2 +- docs/GUIDE.md | 2 +- docs/GUIDE.pt-BR.md | 2 +- docs/ui-redesign.md | 2 +- .../AppController.Choices.cs | 4 +- .../AppController.Compress.cs | 10 ++++- .../ShellAndCompressJourneyTests.cs | 44 ++++++++++++++++--- 10 files changed, 59 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 401173e..444f6ac 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -3,6 +3,8 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the release workflow uses the section matching the tag and fails if either is missing. ## [Unreleased] +### What's new +- **Create RAR** (#258): in Compress, **Formato** gained **RAR**, created by the **WinRAR you already have installed** (ControlFS checks `Rar.exe` and its signature and never bundles, downloads or reimplements RAR, which is a proprietary format). Without WinRAR the option appears greyed out saying to install it. The archive is written to a temporary file and only appears at the end, overwriting nothing; ControlFS opens the RAR it creates. All items must be in the same folder, and there's no password or volumes. ### Fixes - **The app no longer freezes after copying files:** the engine reported progress for every 80 KB block and each report redrew the screen on the UI thread; on a large copy the UI queue filled up and the app stopped responding during and right after the copy. The engine now limits its reports and the UI receives at most about 10 updates per second, always the latest one (#257). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c71382..d5ae9cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` **nos dois arquivos**. O workflow de release usa a seção da tag e falha se faltar alguma. ## [Unreleased] +### Novidades +- **Criar RAR** (#258): em Compactar, o **Formato** ganhou **RAR**, criado pelo **WinRAR que você já tem instalado** (o ControlFS confere o `Rar.exe` e a assinatura dele e nunca embute, baixa nem reimplementa o RAR, que é um formato proprietário). Sem o WinRAR, a opção aparece apagada dizendo para instalá-lo. O arquivo é gravado num temporário e só aparece no fim, sem sobrescrever nada; o ControlFS abre o RAR criado. Todos os itens precisam estar na mesma pasta, e não há senha nem volumes. ### Correções - **O app não trava mais depois de copiar arquivos:** o motor relatava o andamento a cada bloco de 80 KB e cada relato redesenhava a tela na thread de interface; numa cópia grande a fila da interface enchia e o app parava de responder durante e logo depois da cópia. Agora o motor limita os relatos e a interface recebe no máximo cerca de 10 atualizações por segundo, sempre a mais recente (#257). diff --git a/README.md b/README.md index 64d387c..71aa9a0 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Buttons follow **physical position**, so a Nintendo layout doesn't flip confirm **Archives** - **Browse and extract** ZIP (including ZIP64 and AES), 7z, RAR4/RAR5, TAR, TAR.GZ and GZ without unpacking first; **split volumes** (`.7z.001`, `.part1.rar`, `.z01`) open from any part; encrypted archives with password; several archives at once, each into its own folder; **integrity test** -- **Create** ZIP, TAR.GZ and 7z from marked items (RAR can't be created: proprietary) ([matrix](docs/archive-support.md)) +- **Create** ZIP, TAR.GZ and 7z from marked items, and **RAR** when WinRAR is installed on your PC (ControlFS runs your own WinRAR; RAR is proprietary, so it never bundles or reimplements it) ([matrix](docs/archive-support.md)) **Previews** - **Images** (JPG, PNG, GIF, BMP, WebP) with zoom, pan and next/previous; **text** (logs, notes, configs, code) with **light editing**; **PDF** without Edge diff --git a/README.pt-BR.md b/README.pt-BR.md index a7635d9..aa60536 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -99,7 +99,7 @@ Os botões seguem a **posição física**, então um controle Nintendo não inve **Compactados** - **Navegar e extrair** ZIP (inclusive ZIP64 e AES), 7z, RAR4/RAR5, TAR, TAR.GZ e GZ sem descompactar antes; **volumes divididos** (`.7z.001`, `.part1.rar`, `.z01`) abrem a partir de qualquer parte; compactados com senha; vários de uma vez, cada um na sua pasta; **teste de integridade** -- **Criar** ZIP, TAR.GZ e 7z a partir dos itens marcados (RAR não pode ser criado: formato proprietário) ([matriz](docs/archive-support.md)) +- **Criar** ZIP, TAR.GZ e 7z a partir dos itens marcados, e **RAR** quando o WinRAR está instalado no seu PC (o ControlFS usa o seu próprio WinRAR; o RAR é proprietário, então ele nunca embute nem reimplementa esse formato) ([matriz](docs/archive-support.md)) **Visualização** - **Imagens** (JPG, PNG, GIF, BMP, WebP) com zoom, deslocamento e anterior/próxima; **texto** (logs, notas, configurações, código) com **edição leve**; **PDF** sem o Edge diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 28f553c..03f279d 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -219,7 +219,7 @@ Blocked entries (unsafe names like `../`, links, reserved Windows names) show a ## Compressing -Mark items with West (or focus one) → North → **Compress…**. Choose the name (on-screen keyboard), the **Formato** (format) — activating it opens a picker with ZIP, TAR.GZ and 7z (smaller, slower to create; opens in 7-Zip and in the current Windows 11 File Explorer), each with a line about it and the current one marked — and the **Compressão** (compression level, also a picker), then **Compress**. Choosing in a picker returns to the same window; Back leaves the value unchanged. The archive is written to a temporary file and only appears when finished; an existing file is never overwritten (the name gets "(2)"). Links and junctions inside folders are skipped and listed in the result. RAR can't be created (proprietary format). +Mark items with West (or focus one) → North → **Compress…**. Choose the name (on-screen keyboard), the **Formato** (format) — activating it opens a picker with ZIP, TAR.GZ, 7z (smaller, slower to create; opens in 7-Zip and in the current Windows 11 File Explorer) and RAR (created by the WinRAR you already have installed; without WinRAR it shows greyed out and says so — ControlFS never bundles or downloads anything RAR; all items must be in the same folder), each with a line about it and the current one marked — and the **Compressão** (compression level, also a picker), then **Compress**. Choosing in a picker returns to the same window; Back leaves the value unchanged. The archive is written to a temporary file and only appears when finished; an existing file is never overwritten (the name gets "(2)"). Links and junctions inside folders are skipped and listed in the result. RAR can't be created (proprietary format). ## Operations center diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index 8695d8a..7d4a698 100644 --- a/docs/GUIDE.pt-BR.md +++ b/docs/GUIDE.pt-BR.md @@ -219,7 +219,7 @@ Entradas bloqueadas (nomes inseguros como `../`, links, nomes reservados do Wind ## Compactando -Marque itens com Oeste (ou foque um) → Norte → **Compactar…**. Escolha o nome (teclado virtual), o **Formato** — ativá-lo abre um seletor com ZIP, TAR.GZ e 7z (menor e mais lento para criar; abre no 7-Zip e no Explorador do Windows 11 atual), cada um com uma linha sobre ele e o atual marcado — e a **Compressão** (nível, também um seletor), e depois **Compactar**. Escolher no seletor volta à mesma janela; Voltar deixa o valor como estava. O arquivo é gravado num temporário e só aparece quando termina; um arquivo existente nunca é sobrescrito (o nome ganha "(2)"). Links e junctions dentro das pastas são ignorados e listados no resultado. RAR não pode ser criado (formato proprietário). +Marque itens com Oeste (ou foque um) → Norte → **Compactar…**. Escolha o nome (teclado virtual), o **Formato** — ativá-lo abre um seletor com ZIP, TAR.GZ, 7z (menor e mais lento para criar; abre no 7-Zip e no Explorador do Windows 11 atual) e RAR (criado pelo WinRAR que você já tem instalado; sem o WinRAR aparece apagado e diz o motivo — o ControlFS nunca inclui nem baixa nada do RAR; todos os itens precisam estar na mesma pasta), cada um com uma linha sobre ele e o atual marcado — e a **Compressão** (nível, também um seletor), e depois **Compactar**. Escolher no seletor volta à mesma janela; Voltar deixa o valor como estava. O arquivo é gravado num temporário e só aparece quando termina; um arquivo existente nunca é sobrescrito (o nome ganha "(2)"). Links e junctions dentro das pastas são ignorados e listados no resultado. RAR não pode ser criado (formato proprietário). ## Central de operações diff --git a/docs/ui-redesign.md b/docs/ui-redesign.md index 3772d54..944995f 100644 --- a/docs/ui-redesign.md +++ b/docs/ui-redesign.md @@ -181,7 +181,7 @@ Inventory (every modal type; the render report proves the rule for the marked on | Option picker (#261: Formato, Compressão, Tema, Destaque, Ordenar por, Legendas, Tamanho, Modo…) | Medium | every choice and its description always visible (no description area, no per-focus growth); the current one is marked | Screens `p1`/`p1b` (group `picker-theme`, width and height), `p3`/`p3b` (`picker-format`), all pickers same width (`picker-width`), `p1c`/`p3c` (the modal underneath returns at its size) | | Menu (Início), item actions, path menu, drive/tab/operation menus, folder picker menu, "Locais" | Medium (with grid) / Compact | reserved description area; body scrolls | Screens `4`/`4a` (group `menu-app`), `m1`/`m1b` (`menu-actions`) | | Configurações | Medium | reserved description area; tiles and rows measure bold | Screens `4b`–`4e` (group `settings`: tile, long row, long value, wrapped label) | -| Compactar (ZIP / TAR.GZ / 7z) | Standard | `LineReserve`: identical for every format and compression | Screens `q1`, `q1b`, `q1c`, `p3c` (group `compress`, width and height) | +| Compactar (ZIP / TAR.GZ / 7z / RAR — RAR only enabled with the user's WinRAR, #258) | Standard | `LineReserve`: identical for every format and compression | Screens `q1`, `q1b`, `q1c`, `p3c` (group `compress`, width and height) | | Extrair (summary) | Standard | `LineReserve` for the destination | Screens `m3`/`m3b` (group `extract-summary`) | | Confirmations (delete, exit, run, terminal, unmount, discard…) | Compact | content only; long names wrap | Screens `m2`/`m2b`/`q2` (group `confirm-delete`, width) | | Results, conflict, batch, history and operation details, errors, disk usage, "Continuar o vídeo?" | Standard (Compact when short) | content only | Screens `m5b`, `m5c`, `m7` | diff --git a/src/ControlFS.Application/AppController.Choices.cs b/src/ControlFS.Application/AppController.Choices.cs index ed1c8ee..36814b2 100644 --- a/src/ControlFS.Application/AppController.Choices.cs +++ b/src/ControlFS.Application/AppController.Choices.cs @@ -5,7 +5,7 @@ namespace ControlFS.Application; /// Uma alternativa de um valor com várias opções (#261): o valor, o texto e, opcionalmente, uma linha que explica. -public sealed record Choice(T Value, string Label, string? Description = null); +public sealed record Choice(T Value, string Label, string? Description = null, string? DisabledReason = null); /// /// Seletor de opções (#261): um padrão só para todo valor que tem várias alternativas escondidas (formato e compressão de @@ -60,7 +60,7 @@ internal void ShowChoicePicker(string label, T current, IReadOnlyList EqualityComparer.Default.Equals(c.Value, current))); var items = choices.Select((choice, i) => new MenuItem(choice.Label, () => ApplyChoice(choice.Value, onPick), - Detail: choice.Description, Icon: i == index ? ActionIcon.RadioOn : ActionIcon.RadioOff, Value: i == index ? "atual" : null)).ToList(); + choice.DisabledReason, choice.Description, i == index ? ActionIcon.RadioOn : ActionIcon.RadioOff, Value: i == index ? "atual" : null)).ToList(); PushModal(new MenuModal(label, items) { Icon = icon, Subtitle = context, PickerCurrent = index, FocusIndex = index }); } diff --git a/src/ControlFS.Application/AppController.Compress.cs b/src/ControlFS.Application/AppController.Compress.cs index e21602a..d77759a 100644 --- a/src/ControlFS.Application/AppController.Compress.cs +++ b/src/ControlFS.Application/AppController.Compress.cs @@ -65,7 +65,7 @@ void Refill() Refill(); }), icon: ActionIcon.Rename); // Formato e compressão têm várias alternativas escondidas: abrem o seletor (#261) em vez de alternar às cegas. - var format = ChoiceOption("Formato", () => plan.Format, CompressFormats, picked => + var format = ChoiceOption("Formato", () => plan.Format, AvailableCompressFormats(), picked => { plan = plan with { Format = picked }; Refill(); @@ -167,8 +167,16 @@ private void OnCompressionCompleted(OperationItem item, CompressPlan plan, Opera new(CompressionFormat.Zip, "ZIP", "abre em qualquer Windows"), new(CompressionFormat.TarGZip, "TAR.GZ", "comum em Linux/macOS"), new(CompressionFormat.SevenZip, "7z", "menor; abre no 7-Zip e no Explorador do Windows 11 atual (mais lento para criar)"), + new(CompressionFormat.Rar, "RAR", "abre no WinRAR; criado pelo WinRAR que você já tem instalado (o ControlFS não inclui nem baixa nada do RAR)"), ]; + /// + /// Os formatos como o seletor os mostra agora: o RAR só fica disponível se o serviço achar o WinRAR do usuário; sem ele + /// aparece desativado com o motivo (docs/decisions/0011). Os demais formatos sempre estão disponíveis. + /// + private IReadOnlyList> AvailableCompressFormats() => + [.. CompressFormats.Select(c => _archives.GetCreationAvailability(c.Value) is { IsAvailable: false } none ? c with { DisabledReason = none.Reason ?? "Indisponível neste PC." } : c)]; + internal static IReadOnlyList> StrengthChoices { get; } = [ new(CompressionStrength.Fast, "rápida", "Comprime menos e termina antes."), diff --git a/tests/ControlFS.UnitTests/Application/ShellAndCompressJourneyTests.cs b/tests/ControlFS.UnitTests/Application/ShellAndCompressJourneyTests.cs index 590b842..c20cb55 100644 --- a/tests/ControlFS.UnitTests/Application/ShellAndCompressJourneyTests.cs +++ b/tests/ControlFS.UnitTests/Application/ShellAndCompressJourneyTests.cs @@ -5,6 +5,7 @@ using ControlFS.Core.Models; using ControlFS.Core.Policies; using ControlFS.Infrastructure.Archives; +using ControlFS.Infrastructure.Archives.Creation; using ControlFS.UnitTests.Support; namespace ControlFS.UnitTests.Application; @@ -23,10 +24,11 @@ private sealed class FakeShell : IShellService public void RevealInExplorer(string path) => Calls.Add(("reveal", path)); } - private (Driver Driver, FakeShell Shell) Boot() + /// falso: o WinRAR "instalado" para o teste; null: sem WinRAR (não depende da máquina). + private (Driver Driver, FakeShell Shell) Boot(RarTool? rar = null) { var shell = new FakeShell(); - var app = new AppController(new TestFileSystem(_tmp.Path), new ArchiveService(), shell: shell); + var app = new AppController(new TestFileSystem(_tmp.Path), new ArchiveService(rarLocator: () => rar), shell: shell); app.Start(); return (new Driver(app), shell); } @@ -153,14 +155,14 @@ public void Format_picker_lists_every_format_marks_the_current_and_returns_to_th var picker = await d.WaitMenu(); Assert.True(picker.IsPicker); Assert.Equal("Formato", picker.Title); - Assert.Equal(["ZIP", "TAR.GZ", "7z"], picker.Items.Select(i => i.Label)); + Assert.Equal(["ZIP", "TAR.GZ", "7z", "RAR"], picker.Items.Select(i => i.Label)); Assert.All(picker.Items, i => Assert.False(string.IsNullOrEmpty(i.Detail))); Assert.Equal(0, picker.FocusIndex); Assert.Equal((ActionIcon.RadioOn, "atual"), (picker.Items[0].Icon, picker.Items[0].Value)); Assert.All(picker.Items.Skip(1), i => Assert.Equal((ActionIcon.RadioOff, (string?)null), (i.Icon, i.Value))); - Assert.Contains("opção 1 de 3, selecionada", d.App.DescribeFocus().Item, StringComparison.Ordinal); + Assert.Contains("opção 1 de 4, selecionada", d.App.DescribeFocus().Item, StringComparison.Ordinal); d.Press(InputAction.NavigateDown); - Assert.Contains("opção 2 de 3", d.App.DescribeFocus().Item, StringComparison.Ordinal); + Assert.Contains("opção 2 de 4", d.App.DescribeFocus().Item, StringComparison.Ordinal); Assert.DoesNotContain("selecionada", d.App.DescribeFocus().Item, StringComparison.Ordinal); // Voltar deixa tudo como estava. @@ -183,6 +185,38 @@ public void Format_picker_lists_every_format_marks_the_current_and_returns_to_th Assert.Contains(dialog.Lines, l => l is { Label: "Compressão", Value: "normal" }); }); + [Fact] + public void Rar_is_listed_disabled_with_the_reason_without_winrar_and_selectable_with_it() => UiContext.Run(async () => + { + File.WriteAllText(_tmp.Sub("x.txt"), "x"); + + // Sem WinRAR: a alternativa aparece, desativada, dizendo o que fazer; escolher não muda o formato. + var (d, _) = Boot(); + d.Press(InputAction.Confirm); + await d.FocusItem("x.txt"); + d.Press(InputAction.OpenContextMenu); + await d.ChooseMenu("Compactar…"); + var dialog = await d.WaitDialog("Compactar"); + d.ChooseOption(dialog, "Formato: ZIP"); + var picker = await d.WaitMenu(); + var rar = picker.Items.Single(i => i.Label == "RAR"); + Assert.False(rar.IsEnabled); + Assert.Contains("WinRAR", rar.DisabledReason, StringComparison.Ordinal); + Assert.All(picker.Items.Where(i => i.Label != "RAR"), i => Assert.True(i.IsEnabled)); + d.Press(InputAction.Back); + Assert.Equal("Formato: ZIP", dialog.Options.First(o => o.Label.StartsWith("Formato", StringComparison.Ordinal)).Label); + + // Com o WinRAR do usuário: dá para escolher e o arquivo ganha a extensão .rar. + var (d2, _) = Boot(new RarTool("C:\\WinRAR\\Rar.exe")); + d2.Press(InputAction.Confirm); + await d2.FocusItem("x.txt"); + d2.Press(InputAction.OpenContextMenu); + await d2.ChooseMenu("Compactar…"); + var dialog2 = await d2.WaitDialog("Compactar"); + await d2.PickOption(dialog2, "Formato", "RAR"); + Assert.Contains(dialog2.Lines, l => l.Label == "Arquivo" && l.Value.EndsWith(".rar", StringComparison.Ordinal)); + }); + [Fact] public void Compress_name_is_typed_with_the_virtual_keyboard_and_validated() => UiContext.Run(async () => {