diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..6b8681e --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,312 @@ +# Handoff — Windows COM-backend verification (`feat/com-backend`) + +**Date:** 2026-05-29 · **Branch:** `feat/com-backend` · **Host:** Windows 11 on **ARM64**, App Installer `Microsoft.DesktopAppInstaller 1.29.140.0` (Arm64), winget `v1.29.140-preview`. + +This was a human-in-the-loop Windows verification run against `WINDOWS-TESTING.md`. A human drove the +interactive TUI; the agent handled builds, non-interactive checks, diagnosis, and fixes. + +> **⚠️ If you are running on WSL / Linux:** you **cannot** run the Windows verification here. +> Native AOT codegen can't cross-compile from Linux, and the WinGet COM server + installs need +> Windows. On Linux you CAN: read/edit source, run the cross-platform build/tests +> (`dotnet build -f net10.0` / `dotnet test`), run the spike's Linux trim-analysis +> (`spikes/ComBackendSpike/SPIKE-RESULTS.md` → "Reproducing on Linux"), and **prepare** the `#17` +> fix — but the actual pass/fail confirmation resumes on the Windows host. Don't check Windows-only +> boxes from Linux. + +--- + +## 🚨 HEADLINE FINDING (session 2) — the COM backend does NOT run under Native AOT + +**`new PackageManager()` throws `0x80073D54` (`APPMODEL_ERROR_NO_PACKAGE`) in the AOT build**, so +`SelectBackend` (Program.cs) catches it and **silently falls back to the CLI backend**. The +"COM backend unavailable…" stderr note is immediately painted over by the TUI, so it's invisible. +**Proven** by the COM-only `V` (Verify) action reporting *"Verify is only available on the COM +backend"* on a default launch. + +Implications: +- **P0 item 3 (default = COM) FAILS.** The shipped AOT app never runs COM. +- **Most "P0 on COM" passes were actually the CLI backend** (which also yields structured + search/list/details — indistinguishable in the UI). They validate the UI + CLI path, not COM. +- **`#17` (missing Tags/Support/Docs) is a symptom, not a bug** — those are COM-only fields, `null` + on CLI by design. Not a composite-catalog bug (the earlier hypothesis is withdrawn). +- Every COM-only P1 feature (Verify, real version picker, install preview, live progress) would also + have silently been CLI/absent. + +Evidence (all reproducible right now, post-reboot): +- AOT build: activation FAILS on both MTA main thread and MTA threadpool thread. +- **Same source built non-AOT (JIT, self-contained x64): activation SUCCEEDS** (3 catalogs) — even + with the server warmed by JIT moments earlier. ⇒ AOT-specific, not server state, not apartment. +- A **clean AOT spike** (verified no `coreclr.dll`) also FAILS — so it's not app-vs-spike either. + +Tried, did NOT fix: CsWinRT AOT optimizer (`Microsoft.Windows.CsWinRT 2.2.0` + `CsWinRTAotOptimizerEnabled=Auto`); +`app.manifest` with Win10 `supportedOS` + `longPathAware`; warming the OOP server with a JIT process first. +(All reverted — tree is clean.) + +**Caveat / open contradiction:** EARLY in the session (before heavy COM-diagnostic abuse + a reboot), +an AOT spike DID activate COM (3 catalogs + the AOT `foreach` signature). So AOT-COM is not +categorically impossible on this machine. The current deterministic failure may be entangled with +COM-server/AppModel state the abuse+reboot left in an odd condition, OR a genuine AOT activation bug +the early run happened to avoid. **The machine state is compromised — a clean read is needed.** + +**DECISIVE NEXT EXPERIMENT (do this first, on a clean reboot):** before launching the TUI or running +any winget/COM command, run the AOT build's diagnostic: +`winget-tui-sharp.exe --comdiag` (the publish-dir binary still has this flag; snippet in the appendix +to re-add after a rebuild). +- **Activates (catalogs = 3)** → it was transient state; AOT-COM works; **redo the real P0/P1 + verification on COM** (prior passes were CLI). +- **Still fails** → genuine AOT activation bug. Avenues: `Microsoft.Windows.CsWinRT` **3.x** (AOT-first, + but may conflict with the projection's bundled WinRT.Runtime 2.2.0); `Microsoft.WindowsPackageManager.InProcCom` + (bundle the COM server in-process, ~100 MB); or **ship the Windows/COM build non-AOT** (JIT/ReadyToRun — + confirmed to activate COM). File an upstream CsWinRT/WinGet issue with the `--comdiag` output. + +--- + +## TL;DR + +- **P0: 8/9 nominally "pass" but on the CLI backend; item 3 (default = COM) actually FAILS** — see the + headline finding. The genuine, COM-independent passes: AOT publish + no-InvalidCastException + the + UI/CLI behaviors. The AOT-publish toolchain works; the COM backend does not activate under AOT. +- **P1: blocked on the COM-activation issue** — every COM-only feature needs COM to actually run. + Two **quality fixes applied & committed** (detail-load debounce, msstore contrast) — compile-clean, + still need runtime verification once COM works. +- **P2:** not started. +- **A temporary `--comdiag` diagnostic** (apartment + activation probe) was used and reverted from + source; the current publish-dir AOT binary still contains it for the clean-reboot retest. Snippet + to re-add is in the appendix. + +--- + +## How to BUILD on Windows (non-obvious — ARM64 host) + +Plain `dotnet publish` fails at the ILC native-link step (`MSB3073 … link.exe … code 123`, +`'vswhere.exe' is not recognized`). ILC 10.0.8 calls a bare `vswhere.exe` not on PATH. Run the +publish inside the VS Dev Shell for the x64 cross-target **with the VS Installer dir on PATH**: + +```powershell +$installer = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" +$root = & "$installer\vswhere.exe" -latest -products * -property installationPath +Import-Module (Join-Path $root "Common7\Tools\Microsoft.VisualStudio.DevShell.dll") +Enter-VsDevShell -VsInstallPath $root -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=arm64" | Out-Null +$env:PATH = "$installer;$env:PATH" # Enter-VsDevShell does NOT add this; ILC needs bare vswhere +dotnet publish -c Release -f net10.0-windows10.0.26100.0 -r win-x64 +``` + +- `dotnet build` (managed) and `dotnet run` do NOT need the Dev Shell — only AOT `publish` (native link) does. +- Intermittent publish exit 1 at link/copy = a still-running `winget-tui-sharp.exe` holding the output exe; stop it first. +- Output exe: `bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\winget-tui-sharp.exe`. +- A clean AOT build = **~23.3 MB** exe, **no `coreclr.dll`** (true AOT). `.pdb` ~69 MB is symbols only. + +--- + +## Results so far + +### P0 — ✅ 9/9 (all boxes checked in WINDOWS-TESTING.md) +| # | Item | Result | +|---|------|--------| +| 1 | AOT publish, native exe, no `coreclr.dll` | ✅ 23.3 MB, coreclr.dll absent | +| 2 | No `InvalidCastException` (search/list/upgrades/show) | ✅ clean everywhere | +| 3 | Default backend = COM, full IDs, fast | ✅ no fallback note; structured | +| 4 | Flag precedence `--mock > --cli > --com > default` | ✅ verified both decision points | +| 5 | Search returns catalog results + version/source cols | ✅ 18–19 results, winget+msstore | +| 6 | Installed tab + correct versions | ✅ 248 packages | +| 7 | Upgrades tab (subset, Available col) | ✅ | +| 8 | Details panel (publisher/desc/homepage/license/notes) | ✅ | +| 9 | Source filter `f` cycles All/winget/msstore, re-queries | ✅ functional (+ cosmetic notes below) | + +Also verified non-interactively: the **spike** (`spikes/ComBackendSpike/`) AOT-publishes (3.85 MB) and +activates COM, returning 3 catalogs + 3 matches for "powertoys" with full property access — re-confirming +the indexed pattern on this host. + +### P1 — partial +| Item | Result | +|------|--------| +| #17 Richer detail panel (Tags/ProductCode/FamilyName/Support/Docs) | ❌ **CONFIRMED BUG** (see below). Absent-field omission half ✅. | +| #11 Core ops (install/version/upgrade/uninstall/batch) | ⬜ not tested | +| #12 Install preview dialog (`i`) | ⬜ not tested | +| #13 Version picker (`I`) | ⬜ not tested | +| #14 Download-only (`d`) | ⬜ not tested | +| #15 Advanced install (`A`) | ⬜ not tested | +| #16 Verify install (`V`) | ⬜ not tested | +| #18 Live progress bar | ⬜ not tested | +| #19 Cancellation (`Esc`) | ⬜ not tested | + +**Chosen test package for the destructive P1 ops: `ajeetdsouza.zoxide`** (small, native arm64, silent install/uninstall). + +### P2 — not started (#20). One observation already addressed by the debounce fix (below). + +--- + +## Code changes made this session (UNCOMMITTED working-tree edits unless you committed) + +1. **`src/App.cs` — detail-load debounce (REAL FIX, keep).** `DetailLoadDebounceMs = 200`; `OnSelectedRowChanged` + now `await Task.Delay(DetailLoadDebounceMs, ct)` before the backend `ShowAsync`. Prevents a fast list-scroll + from firing a COM detail fetch per row (which throttled/wedged the COM server → 30–60s detail stalls). + Each selection change cancels `_detailCts`, so passed-over rows never hit the backend. **Compiles; NOT yet + verified at runtime on Windows.** +2. **`src/App.cs` — msstore Source-cell contrast fix (REAL FIX, keep).** In `ApplyColumnStyles`'s Source + `ColorGetter`, removed the `Focus`/selected-row foreground override so a selected msstore row (whose + background is `Theme.Accent`) no longer renders Accent-on-Accent (invisible). Color-coding kept for + non-selected rows. **Compiles; NOT yet verified at runtime.** +3. **`spikes/ComBackendSpike/Program.cs` — metadata probe (DIAGNOSTIC, keep).** Step 5 now probes + `GetCatalogPackageMetadata()` fields. This is what proved the COM data for `#17` exists (see below). + A composite-path Step 6 was attempted and reverted (it threw `E_ILLEGAL_STATE_CHANGE`; see note in file). +4. **`Program.cs` — `--comshow` diagnostic was added and REVERTED.** It's gone from the tree; the snippet to + re-add it is in the appendix below. +5. **`WINDOWS-TESTING.md`** — P0 boxes checked with notes; `#17` marked ❌ with detail. +6. **Memory files** under `~/.claude/projects/.../memory/` — build-env + COM-wedge lessons. + +--- + +## `#17` richer detail panel — RESOLVED as a symptom of the headline finding (not a bug) + +> **UPDATE (session 2):** the composite-catalog hypothesis below is **WITHDRAWN.** The fields never +> render because the app is on the **CLI fallback** (COM didn't activate under AOT — see the headline +> finding), and Tags/Support/Documentation are COM-only (`null` on CLI by design). The spike evidence +> below still stands (the COM data exists and is readable), so once COM activation is fixed, `#17` +> should resolve with no further code change. The original (now-moot) analysis is kept for context: + +**Symptom:** None of these fields ever render in the detail panel, for any package, including +`Microsoft.PowerToys` which definitely has them. + +**Evidence gathered:** +- `winget show --id Microsoft.PowerToys` (via the app's `--dump show`) shows the manifest HAS: + Publisher Support Url, Documentation (Wiki), and 10 Tags. +- The **spike** (`--query powertoys`, Step 5) fetched `Microsoft.PowerToys`'s metadata from a **single + `winget`-catalog** connect and got: `meta.Publisher` ✓, `meta.PublisherSupportUrl` = the URL ✓, + `meta.Tags` = 10 items (foreach even works) ✓, `meta.Documentations` = 1 item (indexed access works) ✓, + `ProductCodes`/`PackageFamilyNames` = 0 (legitimately — PowerToys is a `burn` installer, not MSI/MSIX). +- So the **COM data exists and is readable** via the exact indexed pattern `ComBackend` uses. +- The detail PANEL renders all five fields conditionally (verified in `DetailPanel.SetDetail`), and + `MergeContext`/`EnsureDetailHint` (`Models.cs`) do NOT touch them (they're `init`-only). +- Therefore **`ComBackend.ShowAsync` must be returning Tags/SupportUrl/Documentation as null/empty.** + +**Leading hypothesis:** the difference between the working spike and the app is that the spike used a +**single-catalog** connect, while `ComBackend.ShowAsync` resolves the package via `FindByIdAsync` over a +**composite `All` catalog** (`CreateCompositePackageCatalog` of winget+msstore, +`RemotePackagesFromRemoteCatalogs`). Suspect: a composite-catalog package's +`DefaultInstallVersion.GetCatalogPackageMetadata()` returns the scalar fields (Publisher/Description/ +Homepage/License/ReleaseNotes — which the user DOES see) but not the richer ones. NOT yet confirmed — +the puzzle is why scalar `PublisherSupportUrl` would differ from scalar `Publisher` on the same `meta`. + +**Decisive next step (Windows, healthy COM):** re-add the `--comshow` diagnostic (appendix) and run +`winget-tui-sharp.exe --comshow Microsoft.PowerToys` ONCE. +- If `Tags = `, `SupportUrl = `, `Docs = ` → confirmed: fix `ShowAsync` to fetch metadata + via the package's own source (e.g. resolve/connect the single source catalog the package came from, or + re-find by id in a single-source catalog) before `GetCatalogPackageMetadata()`. +- If they're populated → bug is in the TUI render/cache path, not `ShowAsync` — investigate + `OnSelectedRowChanged` / `DetailCache` / `DetailPanel` instead. + +Relevant code: `src/ComBackend.cs` `ShowAsync` (~L162), `FindByIdAsync` (~L806), `StringVector`/`DocLinks` +helpers; `src/DetailPanel.cs` `SetDetail` (L60–135); `src/App.cs` `OnSelectedRowChanged` (~L535). + +--- + +## Cosmetic follow-ups found (not blockers) + +- **Source column blank under single-source filter** (`f` → winget): in `All` mode rows show `winget`, + but filtered-to-winget rows show a blank Source cell (and the detail Source line is then omitted via + MergeContext). Low value (you already filtered to that source); root-cause needs live COM. Deferred. +- **Search box doesn't auto-open on the Search tab** — pressing `1` then needing `/` to type. UX polish: + auto-focus the filter input when switching to Search with an empty query. Deferred (user-requested nicety). +- **Source column clipped at narrow terminal width** — `ExpandLastColumn`; reappears when widened. Acceptable. + +--- + +## The COM-server wedge (READ before running ANY COM diagnostic) + +The agent spawned ~8 short-lived processes that each `new PackageManager()`; several **crashed mid-COM-op** +(an `E_ILLEGAL_STATE_CHANGE` in a flawed composite spike). After that, COM activation fails **everywhere** +(both background tasks AND the user's interactive session) with: +`COMException 0x80073D54` = Win32 `APPMODEL_ERROR_NO_PACKAGE` (15700). winget CLI still works. + +**Recovery (lightest first):** +1. `Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe` then retry. +2. If still wedged, **reboot** (reliable fix for a wedged WinGet OOP COM server). + +**Lesson (also in memory):** do NOT spawn many rapid/short-lived COM-activating processes, and never let +one crash mid-COM-operation. For introspection prefer ONE long-lived process or the interactive TUI. The +in-app version of this same hammering (fast scroll → per-row detail fetch) is what the **debounce fix** +addresses. + +--- + +## Resume plan (next Windows session) + +**STEP 0 — settle the headline finding FIRST (everything else depends on it).** On a **clean reboot**, +before launching the TUI or running any winget/COM command, run the AOT diagnostic: +`bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\winget-tui-sharp.exe --comdiag` +(the publish-dir binary still has `--comdiag`; if you rebuilt, re-add it from the appendix). +- **`activation OK; catalogs = 3`** → AOT-COM works on a clean machine; the session's failures were + transient state from the diagnostic abuse. Proceed to re-verify P0/P1 **on COM** (prior passes were CLI). +- **`activation FAILED … APPMODEL_ERROR_NO_PACKAGE`** → genuine AOT activation bug. Pursue, in order of + cost: (a) try `Microsoft.Windows.CsWinRT` **3.x** overriding the bundled WinRT.Runtime 2.2.0; (b) the + `InProcCom` package (~100 MB, in-proc server); (c) ship the COM/Windows build **non-AOT** (JIT confirmed + working) — the cleanest pragmatic option if AOT activation can't be made reliable; (d) file upstream with + the `--comdiag` output (AOT fails / JIT works, same machine, MTA, server warm). + +**Once COM actually activates:** +1. Re-run the full P0 checklist confirming COM is active (e.g. `V` shows a real verify dialog, not the + "COM-only" message; detail panel shows Tags/Support/Docs on PowerToys). +2. **Verify the two applied quality fixes** on the COM build: + - Debounce: hold ↓ to scroll the list fast — detail panel should NOT freeze for tens of seconds; + settles within ~0.2s on the row you stop on. + - Contrast: highlight the msstore row in a powertoys search — Source cell text readable when selected. +3. **Re-check `#17`** — with COM live it should now show Tags/Support/Documentation (the spike proved the + data is there). No code fix expected beyond the activation fix. +4. **Run P1** with `ajeetdsouza.zoxide` (non-destructive dialog checks first — `i` preview→Cancel, + `I` version list→Cancel, `A` options→Cancel, `V` positive — then real install/download/uninstall/upgrade, + progress bar, `Esc` cancellation; finally batch upgrade). +5. **P2 (#20):** shared-`PackageManager` thread-agility (watch RPC_E_WRONG_THREAD); unhealthy-source + `All` + (break msstore, confirm `f`→winget recovers); pinning (`p`/`P`, needs winget on PATH); AOT vs CLI binary + size; optional arm64. +6. Update `WINDOWS-TESTING.md` boxes; commit; remove any `--comshow`/`--comdiag` diagnostic before final commit. + +### `--comdiag` (apartment + activation probe) — re-add to Program.cs after `using WingetTuiSharp;` +```csharp +#if WINGET_COM +if (args.Length > 0 && args [0] is "--comdiag") +{ + Console.WriteLine ($"main thread apartment = {System.Threading.Thread.CurrentThread.GetApartmentState ()}"); + try { var pm = new Microsoft.Management.Deployment.PackageManager (); Console.WriteLine ($"main-thread activation OK; catalogs = {pm.GetPackageCatalogs ().Count}"); } + catch (Exception ex) { Console.WriteLine ($"main-thread activation FAILED: 0x{(uint)ex.HResult:X8} {ex.Message}"); } + await System.Threading.Tasks.Task.Run (() => { + Console.WriteLine ($"threadpool apartment = {System.Threading.Thread.CurrentThread.GetApartmentState ()}"); + try { var pm = new Microsoft.Management.Deployment.PackageManager (); Console.WriteLine ($"threadpool activation OK; catalogs = {pm.GetPackageCatalogs ().Count}"); } + catch (Exception ex) { Console.WriteLine ($"threadpool activation FAILED: 0x{(uint)ex.HResult:X8} {ex.Message}"); } + }); + return; +} +#endif +``` +Quick JIT-vs-AOT check (no Dev Shell needed for the JIT build): +`dotnet publish -c Release -f net10.0-windows10.0.26100.0 -r win-x64 -p:PublishAot=false --self-contained true -o bin\jit-x64-test` then run `bin\jit-x64-test\winget-tui-sharp.exe --comdiag`. + +--- + +## Appendix — the `--comshow` diagnostic to re-add to `Program.cs` + +Insert right after `using WingetTuiSharp;` (gated so it only affects the `--comshow` arg). Build the +win-x64 AOT exe (Dev Shell) and run it from an interactive, COM-healthy session. + +```csharp +#if WINGET_COM +// TEMP DIAGNOSTIC (remove before commit): dump exactly what ComBackend.ShowAsync returns. +if (args.Length > 1 && args [0] is "--comshow") +{ + ComBackend be = new (); + PackageDetail? d = await be.ShowAsync (args [1], CancellationToken.None); + if (d is null) { Console.WriteLine ("ShowAsync returned null"); return; } + Console.WriteLine ($"Name = {d.Name}"); + Console.WriteLine ($"Publisher = {d.Publisher}"); + Console.WriteLine ($"Homepage = {d.Homepage}"); + Console.WriteLine ($"License = {d.License}"); + Console.WriteLine ($"RelNotesUrl = {d.ReleaseNotesUrl}"); + Console.WriteLine ($"SupportUrl = {d.SupportUrl}"); + Console.WriteLine ($"Tags = {(d.Tags is null ? "" : string.Join (" | ", d.Tags))}"); + Console.WriteLine ($"Docs = {(d.Documentation is null ? "" : string.Join (" | ", d.Documentation.Select (x => $"{x.Label}:{x.Url}")))}"); + Console.WriteLine ($"ProductCodes= {(d.ProductCodes is null ? "" : string.Join (" | ", d.ProductCodes))}"); + Console.WriteLine ($"FamilyNames = {(d.PackageFamilyNames is null ? "" : string.Join (" | ", d.PackageFamilyNames))}"); + return; +} +#endif +``` diff --git a/WINDOWS-TESTING.md b/WINDOWS-TESTING.md index c729b62..9860430 100644 --- a/WINDOWS-TESTING.md +++ b/WINDOWS-TESTING.md @@ -26,15 +26,39 @@ For quick iteration without AOT: `dotnet run -f net10.0-windows10.0.26100.0`. ## P0 — Foundational COM runtime (must pass first) -- [ ] **AOT publish succeeds** and produces a native exe; `coreclr.dll` is absent (true AOT, not self-contained). -- [ ] **No `InvalidCastException` anywhere at runtime.** The whole backend uses indexed `Materialize` instead of `foreach` over projected collections (the spike's AOT rule). Exercise search/list/upgrades/show and confirm none throw the spike's original cast error. -- [ ] **Default backend = COM** on the Windows build with no flags (not CLI). Sanity: search is fast/structured, IDs are never truncated with `…`. -- [ ] **Flag selection** works: `--cli`, `--com`, `--mock` pick the right backend; **`--cli` wins when both `--cli` and `--com` are passed** (precedence `--mock > --cli > --com > default`). -- [ ] **Search** (`/`) returns real catalog results with version + source columns. -- [ ] **Installed** tab lists installed packages with correct installed versions. -- [ ] **Upgrades** tab shows only packages with an available update, with the Available column populated. -- [ ] **Details** panel: selecting a row fetches metadata (publisher, description, homepage, license, release-notes URL). -- [ ] **Source filter** (`f`) cycles All / winget / msstore and re-queries correctly. +> ### 🚨 CRITICAL FINDING — the AOT build cannot activate the COM backend +> **`new PackageManager()` throws `0x80073D54` (`APPMODEL_ERROR_NO_PACKAGE`) in the Native-AOT +> build**, so `SelectBackend` catches it and **silently falls back to the CLI backend** (the +> "COM backend unavailable…" stderr note is painted over by the TUI redraw, so it's invisible). +> Confirmed by the COM-only `V` (Verify) action reporting *"Verify is only available on the COM +> backend"* in the default launch. +> +> - The **same source built non-AOT (JIT, self-contained x64) activates COM fine** (3 catalogs), +> even with the server warmed by JIT moments earlier. So the failure is **AOT-specific**, not +> server state, not apartment (both threads are MTA). +> - **Did NOT fix it:** CsWinRT AOT optimizer (`Microsoft.Windows.CsWinRT` + `CsWinRTAotOptimizerEnabled=Auto`); +> an `app.manifest` with Win10 `supportedOS` + `longPathAware`; warming the server first. +> - **Caveat:** EARLY in the session (before heavy COM-diagnostic abuse + a reboot) an AOT *spike* +> DID activate COM (3 catalogs + the AOT foreach signature), so AOT-COM is not categorically +> impossible here — the current deterministic failure may be entangled with COM-server/AppModel +> state, OR a genuine AOT activation bug the early run avoided. Machine state is compromised. +> - **DECISIVE NEXT EXPERIMENT (clean state):** fresh reboot → as the *very first* COM activity, +> run the AOT build's `--comdiag` (binary in the publish dir still has it). Activates → transient +> state, redo the real verification on COM. Still fails → genuine AOT bug → CsWinRT 3.x / upstream +> issue / `InProcCom` / ship the COM build non-AOT (JIT confirmed working). +> - **Consequence for the results below:** items marked ✅ that "passed on COM" were almost +> certainly exercising the **CLI** backend (which also yields structured search/list/details, so +> it was indistinguishable). They validate the UI + CLI path, NOT the COM backend. See HANDOFF.md. + +- [x] **AOT publish succeeds** and produces a native exe; `coreclr.dll` is absent (true AOT, not self-contained). *(23.3 MB exe; verified coreclr.dll absent. On an ARM64 host the publish must run inside `Enter-VsDevShell -DevCmdArguments "-arch=x64 -host_arch=arm64"` with the VS Installer dir on PATH — ILC 10.0.8 calls bare `vswhere.exe`.)* +- [x] **No `InvalidCastException` anywhere at runtime.** The whole backend uses indexed `Materialize` instead of `foreach` over projected collections (the spike's AOT rule). Exercise search/list/upgrades/show and confirm none throw the spike's original cast error. *(Clean across search, installed, upgrades, and details. Spike re-confirmed the indexed pattern on this host.)* +- [ ] ❌ **FAILS — Default backend is NOT COM.** The AOT build can't activate `PackageManager`, so the default silently runs the **CLI** backend (proven: `V` reports "only available on the COM backend"). See the critical-finding banner above. *(The fast/structured search and full IDs we saw are the CLI backend's output, which also looks structured — they did not prove COM was active.)* +- [x] **Flag selection** works: `--cli`, `--com`, `--mock` pick the right backend; **`--cli` wins when both `--cli` and `--com` are passed** (precedence `--mock > --cli > --com > default`). *(`--mock --cli --com` → 10 mock pkgs; `--cli --com` → CLI backend, V reports COM-only on real packages.)* +- [x] **Search** (Search tab `1`, then `/`) returns real catalog results with version + source columns. *(18 results for "powertoys" across winget + msstore.)* +- [x] **Installed** tab lists installed packages with correct installed versions. *(248 packages; PowerToys 0.98.1 etc., correlated via COM LocalCatalogs composite.)* +- [x] **Upgrades** tab shows only packages with an available update, with the Available column populated. +- [x] **Details** panel: selecting a row fetches metadata (publisher, description, homepage, license, release-notes URL). *(Verified on Fefedu973.UniversalSearchSuggestions: publisher, MIT license, homepage + release-notes URLs, description all populated.)* +- [x] **Source filter** (`f`) cycles All / winget / msstore and re-queries correctly. *(Functional pass. Cosmetic follow-ups: msstore Source cell unreadable when row highlighted — Accent-on-Accent contrast bug; Source column blank under single-source filter; per-row detail fetch stalled 30–60s for later rows under winget filter — logged to P2 perf watch.)* ## P1 — Operations + the two new features @@ -77,10 +101,36 @@ Operations (pick a small, safe package to install/uninstall, e.g. a CLI tool): - [ ] Deliberately break an install (e.g. delete a file from the install dir) and confirm `V` reports the **Issues** outcome with the failing check. - [ ] (CLI backend, `--cli`) `V` reports "Verify is only available on the COM backend" rather than erroring. +**Repair** (`R` — COM-only, via `RepairPackageAsync`): + +- [ ] `R` on a healthy installed package (Installed/Upgrades) confirms, then repairs: the status bar shows a determinate bar advancing through a **Repairing** phase, ending "Done" (or "(reboot required)"). +- [ ] **Verify → Repair flow**: break an install, `V` → **Issues** outcome → the result dialog offers **Repair** / **Close**; choosing **Repair** runs the repair **without a second confirm** and the install is restored (`V` again reports Ok). +- [ ] A package whose installer has no repair behavior reports the friendly **"{name} doesn't support repair."** (`NoApplicableRepairer`), not a raw HRESULT. +- [ ] **Esc during a repair** cancels cooperatively ("Cancelled"); the one-op-at-a-time gate blocks starting a second op mid-repair. +- [ ] (CLI backend, `--cli`) `R` shows the neutral **"Repair is only available on the COM backend."** (not a red error), and the detail-panel `R Repair install` action is still listed. +- [ ] `R` is **not** offered in Search mode (the selected package may not be installed). + **Richer detail panel** (COM): -- [ ] The detail panel for a package shows the extra manifest fields when present: **Tags**, **Product code**, **Family name**, a clickable **Support** link, and **Documentation** links — in addition to the existing fields. Verify the links open. -- [ ] Packages without these fields don't render empty rows (the lines are omitted when absent). +- [ ] ⚠️ **Not a bug — symptom of the AOT COM-activation failure.** The extra manifest fields (**Tags**, **Product code**, **Family name**, **Support**, **Documentation**) never render because the app is running on the **CLI** backend (COM didn't activate — see the critical-finding banner), and these fields are COM-only (`null` on CLI by design, per `Models.cs`). The spike proved the COM data exists and is readable via the backend's indexed pattern, so once COM activation is fixed this should work. Re-test after the COM-on-AOT issue is resolved. +- [x] Packages without these fields don't render empty rows (the lines are omitted when absent). *(Confirmed — absent fields cleanly omitted.)* +- [ ] **New enrichment fields (COM, all conditional).** Once COM activates, confirm these render when present and are omitted when blank: **Author**, **Copyright**, **Privacy** (link), **Purchase** (link), **Installation notes** (paragraph section after Description), and for installed packages **Scope** + **Installed to** (location). These come from `CatalogPackageMetadata` (Author/Copyright/PrivacyUrl/PurchaseUrl/InstallationNotes) and `PackageVersionInfo.GetMetadata(InstalledScope/InstalledLocation)`. A package with none of them should look exactly as before. + +**Dynamic sources** (`f`) — COM via `GetPackageCatalogs()`, CLI via `winget source list`: + +- [ ] On a stock machine, `f` still cycles **All → Winget → MsStore → All** (the discovered list matches the two predefined sources). +- [ ] **Add a custom source** (`winget source add -n contoso `), relaunch, and confirm `f` now includes **contoso** in the cycle and filtering to it scopes the list/search to that source. (This is the whole point of going dynamic — verify it on both COM and `--cli`.) +- [ ] With a source selected that no longer exists (remove it while the app holds it selected, then refresh), the filter resets to **All** rather than erroring. + +**Backend badge + version** (`PackageManager.Version`): + +- [ ] The top-right header shows the live backend + winget version, e.g. **`COM · winget 1.x`** (or `CLI · winget 1.x` / `Mock backend`). On the AOT build this is the quickest confirmation of whether COM actually activated or silently fell back to CLI. +- [ ] The **Help** dialog (`?`) leads with a matching `Backend: …` line. + +**Search match hint + result cap** (COM): + +- [ ] Search a term that matches a package by **tag/moniker/command** (not its name) — the detail panel shows a dim `↳ matched on tag` footnote. A normal name/id match shows **no** such line. +- [ ] A very broad search (e.g. a single common letter) caps at **1000** rows and the status reads `1000+ matches — refine your search to narrow` instead of flooding the table. **Live progress bar** (the headline feature — also tests `.Progress` delegate marshaling under AOT, the one CCW-callback unknown): @@ -96,6 +146,15 @@ Operations (pick a small, safe package to install/uninstall, e.g. a CLI tool): - [ ] **Batch upgrade + Esc**: the in-flight item cancels and the remaining queue stops. - [ ] **One-op-at-a-time guard**: triggering a second operation while one is running is ignored (no second progress bar, no crash). +## P1.5 — Upstream parity ports (ported from `shanselman/winget-tui`, June 2026) + +Three behavioural changes ported from upstream. Logic is unit-tested (`tests/AppBehaviorTests.cs`), +but these paths need a real terminal / real winget to confirm end-to-end. + +- [ ] **Click-to-sort column headers** (upstream `66d464c4`). With the mouse, **click the `Name`, `Id`, or `Version` header** to sort by that column (ascending); **click the same header again** to reverse direction (the `↑`/`↓` arrow in the header should flip). Clicking the marker, **`Available`, or `Source`** header is a **no-op**. Verify in both **Installed** and **Upgrades** tabs (column indices differ between them). Keyboard `S` cycling must still work unchanged. *(Mouse `ScreenToCell` header detection can't be exercised from Linux unit tests.)* +- [ ] **Truncated-id upgrade falls back to name** (upstream `fd9e9dbe`). Find an Upgrades row whose **id is truncated with `…`** (winget does this to long ids in tabular output). Press **`u`**: instead of the old "Cannot upgrade: id was truncated" block, you should get a confirm reading **"Upgrade ? (id was truncated by winget — matching by name)"**, and on confirm the upgrade should actually run (CLI backend retries `--name --exact`). Needs the **`--cli`** backend (COM ids are never truncated). Confirm a non-truncated row still shows the plain "Upgrade ?" prompt. +- [ ] **Contextual empty-state message** (upstream `#228`). When the list is empty, the message should match the reason: **Upgrades + 📌-hide (`UnpinnedOnly`) → "No unpinned packages with upgrades found."**; Upgrades + 📌-only → "No pinned packages with upgrades found."; Upgrades + all → "All packages are up to date!"; an active local filter that hides everything → 'No packages match "".'. Toggle the pin filter (`P`) in Upgrades and type a non-matching filter (`/`) to exercise each. + ## P2 — Review-flagged real-Windows concerns & measurements - [ ] **Shared `PackageManager` thread-agility.** The backend reuses one `PackageManager` across operations invoked from background/threadpool (MTA) threads. Watch for `RPC_E_WRONG_THREAD` or intermittent COM errors under rapid search/typing or back-to-back ops. **If seen → switch to a fresh `PackageManager` per operation** (currently shared as a perf choice). *(Open from review pass 2.)* @@ -105,6 +164,7 @@ Operations (pick a small, safe package to install/uninstall, e.g. a CLI tool): - [ ] **CLI-backend cancel** (`--cli`, then Esc mid-install): confirm it stops watching but does **not** kill `winget.exe` (the install continues) — documented, lower priority. - [ ] **Measure the AOT binary size** of the COM (Windows) build and compare to the CLI/mock build, to budget the COM backend's cost. *(Open spike question.)* - [ ] **(Optional) win-arm64**: repeat the P0 smoke on an arm64 host or arm64 cross-target. +- [ ] **Terminal.Gui bump `2.4.3-develop.9` → `2.4.7-develop.1`.** Spot-check the Windows-only input/render fixes that landed in the 2.4.4 release line (can't be verified from Linux): (a) type a **non-ASCII search query** (e.g. accented chars / IME) into `/` search and confirm it renders correctly (Windows VT input encoding fix #5453); (b) **paste** a Unicode string into search via bracketed paste and confirm no mojibake (clipboard fixes #5449/#5451); (c) **resize the terminal** mid-use and confirm no garbled frame at the wrong dimensions (#5461). No app code changed — these are upstream fixes the bump picks up for free. --- diff --git a/WingetTuiSharp.csproj b/WingetTuiSharp.csproj index 29245d9..11d119a 100644 --- a/WingetTuiSharp.csproj +++ b/WingetTuiSharp.csproj @@ -53,7 +53,7 @@ prereleases; this is intentionally a moving target since the project's purpose is to evaluate parity against current Terminal.Gui behavior. --> - + diff --git a/docs/superpowers/specs/2026-06-12-repair-feature-design.md b/docs/superpowers/specs/2026-06-12-repair-feature-design.md new file mode 100644 index 0000000..1b7c69e --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-repair-feature-design.md @@ -0,0 +1,193 @@ +# Repair feature — design + +- **Date:** 2026-06-12 +- **Status:** Approved (design); pending implementation plan +- **Branch:** `feat/com-backend` + +## Summary + +Add a **Repair** action that re-runs a package's installer in repair mode to fix a +damaged/corrupt install, backed by the WinGet COM `PackageManager.RepairPackageAsync` +API (contract 11). Repair complements the existing **Verify** action: Verify *detects* +a broken install (`CheckInstalledStatus`); Repair *fixes* it. Today the app only does +the detection half. + +## Motivation + +`RepairPackageAsync` is the one genuinely *missing capability* in our COM surface (the +other gaps are enrichments). It pairs naturally with Verify — when Verify reports the +`Issues` outcome, the obvious next step is to repair — and it reuses the app's existing +operation/progress infrastructure almost entirely. + +## Goals + +- A standalone **`R`** action to repair the selected installed package. +- When **Verify** finds issues, offer **Repair** directly from its result dialog. +- Determinate progress, Esc-to-cancel, and status reporting consistent with + install/upgrade/uninstall. + +## Non-goals + +- **No advanced repair-options dialog.** Repair runs as a plain confirm → silent + repair. (Repair exposes far fewer meaningful knobs than install; this matches the + Upgrade/Uninstall confirm-then-run pattern.) +- **No CLI repair.** Repair is **COM-only**, like Verify. The CLI backend reports it + unavailable; the mock synthesizes it for Linux dev iteration. (We deliberately do + *not* shell `winget repair`, even though that command exists.) +- No source/scope selection, no batch repair, no "repair all". + +## UX design + +### Trigger + +Two entry points, sharing one `RunRepair(Package)` core so confirmation isn't doubled: + +1. **Standalone `R` key** — handled in `App.OnKeyDown`, active only in **Installed** + and **Upgrades** modes (repair acts on installed packages; not offered in Search). + `R` is currently unbound (`r` is refresh). Calls `AskRepair(p)`, which gates on + `CanRepair`, shows the **confirm** dialog, then `RunRepair(p)`. +2. **Verify → Repair offer** — `App.ShowVerifyResult` currently shows the verify result + via `MessageBox.Query(..., "_OK")`. When the outcome is `VerifyOutcome.Issues`, it + instead offers `"_Repair"` and `"_Close"`. Choosing Repair calls `RunRepair(p)` + **directly, with no second confirm** — the user is already in a dialog reporting the + broken install and explicitly clicked Repair, which is itself the confirmation. This + path is only reachable on COM (Verify is COM-only), so the `CanRepair` gate is moot. + +This splits responsibilities cleanly: +- `AskRepair(p)` = `CanRepair` gate (neutral message if false) + confirm + `RunRepair(p)`. +- `RunRepair(p)` = `GuardTruncatedId(p, "repair")` + `RunOperation(...)`. + +### Availability gate + +`AskRepair` checks `IBackend.CanRepair` **before** confirming. If false, it shows a +neutral (non-error) status message *"Repair is only available on the COM backend."* — +mirroring exactly how `V` degrades on the CLI backend. This means the `R` action and the +detail-panel "Actions" entry are always visible, but gracefully no-op with an +explanation on backends that can't repair. + +### Confirm + run + +On a repair-capable backend, `AskRepair(p)`: + +1. Gate on `CanRepair` (neutral message if false). +2. `Confirm("Repair", $"Repair {p.Name}? This re-runs the installer's repair to fix a + damaged install.")`. +3. `RunRepair(p)`. + +`RunRepair(p)`: + +1. `GuardTruncatedId(p, "repair")` — a winget-truncated id can't be matched, same guard + as install/uninstall. +2. `RunOperation($"Repairing {p.Name}", (prog, ct) => _state.Backend.RepairAsync(p.Id, prog, ct))`. + +`RunOperation` already provides: the one-operation-at-a-time gate, `_opCts` Esc-to-cancel, +the determinate status-bar progress bar, detail-cache invalidation on success, and the +post-op list refresh. Repair needs no new orchestration. + +### Detail panel & help + +- `DetailPanel.SetDetail` adds `AddAction("R", "Repair install")` after the existing + `V Verify install` entry, in the **Installed** and **Upgrades** action lists. +- `HelpDialog.HelpText` gains a line under Actions: `R Repair install (re-run repair)`. +- Status-bar hint pairs are left unchanged (curated/space-limited; Repair is discoverable + via the detail panel and help). + +## Backend contract (`IBackend`) + +Two additions: + +```csharp +// True when this backend can repair an installed package (COM only). The UI gates the +// Repair action on this and shows a neutral "only available on the COM backend" message +// otherwise — mirroring how Verify degrades. +bool CanRepair { get; } + +// Repair an installed package by re-running its installer in repair mode. Reports progress +// like install/upgrade. Only meaningful when CanRepair is true. +Task RepairAsync (string id, IProgress? progress, CancellationToken ct); +``` + +## Models + +- `OperationKind.Repair` — new enum member. +- `OpPhase.Repairing` — new enum member; `OpProgress.Label` maps it to `"Repairing"`. + +## COM implementation (`ComBackend`) + +- `CanRepair => true`. +- `RepairAsync`: + 1. `FindByIdAsync(id, null, installedContext: true)` — installed context so the package + carries its installed version (what repair targets). Null → not installed → clean + failure `"Installed package '{id}' not found."`. + 2. `RepairOptions options = new () { PackageRepairMode = PackageRepairMode.Silent };` + 3. `var asyncOp = _pm.RepairPackageAsync(pkg, options);` + `asyncOp.Progress = (_, p) => progress?.Report(MapRepair(p));` + `RepairResult result = await asyncOp.AsTask(ct);` + 4. Map `result.Status`: + - `RepairResultStatus.Ok` → success, append `" (reboot required)"` when + `result.RebootRequired`. + - `RepairResultStatus.NoApplicableRepairer` → friendly failure *"{name} doesn't + support repair."* + - otherwise → `$"Repair failed: {result.Status} (repairer {result.RepairerErrorCode}, + hr 0x{HResultOf(result.ExtendedErrorCode):X8})"`, consistent with `DescribeInstall`. +- `MapRepair(RepairProgress p)`: + - `Queued → OpPhase.Queued`, `Repairing → OpPhase.Repairing`, + `PostRepair → OpPhase.Finalizing`, `Finished → OpPhase.Done`, default → `Repairing`. + - fraction: `p.State == Finished ? 1.0 : p.RepairCompletionProgress`. + +## CLI implementation (`CliBackend`) + +- `CanRepair => false`. +- `RepairAsync` returns a safety-net failure `OpResult` *"Repair is only available on the + COM backend."* — never reached through the UI because the `CanRepair` gate stops first, + but implemented to satisfy the interface. + +## Mock implementation (`MockBackend`) + +- `CanRepair => true` (so the flow is exercisable on Linux, as Verify's mock is). +- `RepairAsync` synthesizes a `Repairing` progress ramp (reusing/paralleling + `SimulateProgressAsync`) and returns success `"[mock] Repaired {id}"`. + +## Error handling & edge cases + +| Case | Behavior | +| --- | --- | +| Backend can't repair (CLI) | Neutral status: "Repair is only available on the COM backend." (no red error) | +| Truncated id | `GuardTruncatedId` blocks with an explanatory message | +| Package not installed / not found | Failure OpResult: "Installed package '{id}' not found." | +| Package has no repairer | Friendly failure: "{name} doesn't support repair." | +| Esc during repair | Cooperative cancel via shared `_opCts`; "Cancelled". (Per IDL, cancel during the Repairing phase may not roll back — same caveat as Installing.) | +| Reboot required | "(reboot required)" appended to the success message | +| Second op while one runs | Ignored by `RunOperation`'s one-op gate (unchanged) | + +## Files touched + +- `src/Models.cs` — `OperationKind.Repair`, `OpPhase.Repairing` + `Label`. +- `src/Backend.cs` — `CanRepair`, `RepairAsync` on `IBackend`. +- `src/ComBackend.cs` — `CanRepair`, `RepairAsync`, `MapRepair`. +- `src/CliBackend.cs` — `CanRepair`, `RepairAsync` (unavailable). +- `src/MockBackend.cs` — `CanRepair`, `RepairAsync` (synth). +- `src/App.cs` — `R` key → `AskRepair`; `AskRepair`; Verify→Repair offer in + `ShowVerifyResult`. +- `src/DetailPanel.cs` — `R Repair install` action (Installed + Upgrades). +- `src/Ui.cs` — Help text line. +- `tests/` — mock repair success path; `CliBackend.CanRepair == false`. +- `WINDOWS-TESTING.md` — COM repair verification items. + +## Testing + +- **Unit (Linux):** mock `CanRepair == true` and `RepairAsync` returns a successful + `OpResult` after a progress ramp; assert `CliBackend.CanRepair == false`. If the existing + `AppBehaviorTests` drive operation flows, add a repair-flow case there. +- **Windows (manual, COM):** new `WINDOWS-TESTING.md` items — + - `R` on a healthy installed package repairs it; progress bar advances through a + **Repairing** phase; "Done" (or "(reboot required)"). + - Break an install, `V` → **Issues** → the dialog offers **Repair**; choosing it repairs. + - A package with no repairer reports the friendly "doesn't support repair" message. + - Esc during a repair cancels cooperatively. + - On the CLI fallback backend, `R` shows "Repair is only available on the COM backend." + +## Open questions + +None. The three design choices that needed sign-off — simple confirm (no advanced +options), the `R` keybinding, and Installed+Upgrades-only scope — are all confirmed. diff --git a/spikes/ComBackendSpike/Program.cs b/spikes/ComBackendSpike/Program.cs index f9b0e6c..1eff28f 100644 --- a/spikes/ComBackendSpike/Program.cs +++ b/spikes/ComBackendSpike/Program.cs @@ -82,11 +82,80 @@ int Run (string query) Console.WriteLine ($" · {pkg.Id} name={pkg.Name} installed={installedV} latest={latestV}"); }); + // Step 5: metadata field extraction — the richer-detail-panel fields. The real + // backend's ShowAsync funnels these through StringVector/DocLinks, which SWALLOW + // exceptions and return null. So if a richer field silently never appears in the + // UI, the cause is hidden. Here we probe each field with no swallowing, to see + // whether the AOT trap extends to IReadOnlyList/ and to + // the PublisherSupportUrl getter. + Console.WriteLine (); + Console.WriteLine ("[spike] === metadata probe (richer detail fields) ==="); + + if (result.Matches.Count > 0) + { + CatalogPackage pkg = result.Matches [0].CatalogPackage; + Console.WriteLine ($"[spike] probing metadata for: {pkg.Id}"); + + PackageVersionInfo? vi = pkg.DefaultInstallVersion; + + if (vi is null) + { + Console.WriteLine ("[spike] DefaultInstallVersion is null — cannot fetch metadata"); + } + else + { + CatalogPackageMetadata? meta = null; + + try + { + meta = vi.GetCatalogPackageMetadata (); + } + catch (Exception ex) + { + Console.WriteLine ($"[spike] GetCatalogPackageMetadata threw {ex.GetType ().Name}: {ex.Message}"); + } + + if (meta is not null) + { + ProbeString ("meta.Publisher (simple getter, known-good)", () => meta.Publisher); + ProbeString ("meta.PublisherSupportUrl (simple getter, suspect)", () => meta.PublisherSupportUrl); + Probe ("meta.Tags (IReadOnlyList)", meta.Tags, t => Console.WriteLine ($" · tag={t}")); + Probe ("meta.Documentations (IReadOnlyList)", meta.Documentations, d => Console.WriteLine ($" · {d.DocumentLabel}={d.DocumentUrl}")); + } + + Probe ("vi.ProductCodes (IReadOnlyList)", vi.ProductCodes, c => Console.WriteLine ($" · pc={c}")); + Probe ("vi.PackageFamilyNames (IReadOnlyList)", vi.PackageFamilyNames, f => Console.WriteLine ($" · pfn={f}")); + } + } + + // NOTE: a composite-catalog metadata probe (mirroring the backend's ShowAsync, which uses + // CreateCompositePackageCatalog over winget+msstore) was attempted here but threw + // E_ILLEGAL_STATE_CHANGE because this process already connected the 'winget' catalog + // DIRECTLY in Step 2 — re-referencing an already-connected source for a composite on the + // same process is illegal. To test the composite path cleanly, do it in a FRESH process + // that never does a direct single-catalog connect (or use the app's --comshow diagnostic; + // see HANDOFF.md). This matters: ShowAsync uses the composite path and is the suspect for + // the richer-detail-fields-missing bug, even though this single-catalog Step 5 returns them. + Console.WriteLine ("[spike] done."); return 0; } +// Probe a simple (non-collection) string getter: does it throw, or return empty? +void ProbeString (string label, Func get) +{ + try + { + string? v = get (); + Console.WriteLine ($"[spike] {label}: {(string.IsNullOrEmpty (v) ? "" : v)}"); + } + catch (Exception ex) + { + Console.WriteLine ($"[spike] {label}: ✗ threw {ex.GetType ().Name}: {ex.Message}"); + } +} + // Read the first element of a projected list via indexing only (IVectorView.GetAt), // deliberately avoiding foreach so this never trips the IIterable path. string GetFirstVersion (IReadOnlyList versions) diff --git a/src/App.cs b/src/App.cs index e2fe612..cc138bd 100644 --- a/src/App.cs +++ b/src/App.cs @@ -13,16 +13,26 @@ public sealed class App : Runnable /// One row of breathing room below the wordmark before the list/search start. private const int HeaderHeight = Logo.LogoHeight + 1; + // Debounce before an uncached detail fetch fires. Scrolling the list changes the selection + // rapidly; without this, every row the cursor passes over issues a full backend detail + // request (for COM: ConnectAsync + FindByIdAsync + GetCatalogPackageMetadata). Bursts of + // those throttle/wedge the WinGet out-of-proc COM server, causing the detail panel to stall + // for tens of seconds while it recovers. Holding the fetch until the selection settles for + // this interval collapses a fast scroll to a single request (the row landed on). Each + // selection change cancels the pending delay, so passed-over rows never hit the backend. + private const int DetailLoadDebounceMs = 200; + private readonly AppState _state; private readonly TabBar _tabBar; private readonly Logo _logo; private readonly TextField _filterInput; private readonly TextField _versionInput; private readonly FrameView _listFrame; - private readonly TableView _packageTable; + private readonly SortableTableView _packageTable; private readonly DetailPanel _detailPanel; private readonly StatusBar _statusBar; private readonly Label _searchHint; + private readonly Label _backendLabel; private CancellationTokenSource _viewCts = new (); private CancellationTokenSource _detailCts = new (); @@ -49,6 +59,19 @@ public App (IBackend backend) _logo = new () { X = 1, Y = 0 }; _tabBar = new () { X = Pos.Right (_logo) + 4, Y = (Logo.LogoHeight - 1) / 2, Width = Dim.Fill (1) }; + // Which backend is live + its winget version, dim in the top-right of the header. Empty + // until DescribeAsync resolves at startup (see OnIsRunningChanged). Anchored to the last + // logo row so it never collides with the tab row above it. + _backendLabel = new () + { + X = Pos.AnchorEnd (), + Y = Logo.LogoHeight - 1, + Height = 1, + Width = Dim.Auto (), + Text = string.Empty, + SchemeName = Theme.AccentDimSchemeName + }; + // --- Search / filter input (hidden until needed). Lives immediately below the // header chrome; the list shifts down another row when search is shown. --- _searchHint = new () @@ -109,7 +132,7 @@ public App (IBackend backend) Width = Dim.Fill () }; - Add (_logo, _tabBar, _searchHint, _filterInput, _listFrame, _detailPanel, _statusBar); + Add (_logo, _tabBar, _backendLabel, _searchHint, _filterInput, _listFrame, _detailPanel, _statusBar); // --- Version input dialog field (lives inside MessageBox-like popover; we use a separate field) --- _versionInput = new (); @@ -124,6 +147,7 @@ private void WireEvents () _tabBar.TabClicked += (_, mode) => SwitchToMode (mode); _packageTable.ValueChanged += (_, _) => OnSelectedRowChanged (); + _packageTable.HeaderClicked += OnHeaderClicked; _filterInput.TextChanged += (_, _) => { @@ -193,6 +217,8 @@ protected override void OnIsRunningChanged (bool newIsRunning) _initialLoadDone = true; TriggerRefresh (); StartSpinner (); + LoadBackendDescription (); + LoadSources (); } else if (!newIsRunning) { @@ -200,6 +226,75 @@ protected override void OnIsRunningChanged (bool newIsRunning) } } + /// + /// Resolve which backend is live + its winget version once at startup and show it in the + /// header badge. Best-effort: a failure just leaves the badge empty (the app works regardless). + /// + private void LoadBackendDescription () + { + Task.Run (async () => + { + string description; + + try + { + description = await _state.Backend.DescribeAsync (CancellationToken.None); + } + catch + { + return; + } + + App?.Invoke (() => + { + _state.BackendDescription = description; + _backendLabel.Text = description; + _backendLabel.SetNeedsLayout (); + _backendLabel.SetNeedsDraw (); + }); + }); + } + + /// + /// Discover the configured package sources once at startup so the f source filter cycles + /// through the real source list (including custom/enterprise REST sources) instead of just the + /// two predefined ones. Best-effort: on failure the seeded ["winget","msstore"] defaults stand. + /// A currently-selected source that's absent from the discovered list is reset to "All". + /// + private void LoadSources () + { + Task.Run (async () => + { + IReadOnlyList sources; + + try + { + sources = await _state.Backend.ListSourcesAsync (CancellationToken.None); + } + catch + { + return; + } + + if (sources.Count == 0) + { + return; + } + + App?.Invoke (() => + { + _state.AvailableSources = sources; + + if (_state.SourceFilter is { } current + && !sources.Any (s => string.Equals (s, current, StringComparison.OrdinalIgnoreCase))) + { + _state.SourceFilter = null; + RefreshStatusBar (); + } + }); + }); + } + private void StartSpinner () { if (App is null) @@ -236,7 +331,7 @@ private void TriggerRefresh () CancellationToken ct = _viewCts.Token; int gen = _state.BumpViewGeneration (); AppMode mode = _state.Mode; - SourceFilter src = _state.SourceFilter; + string? src = _state.SourceFilter; string query = _state.SearchQuery; // Remember the currently-selected package id so we can re-position the cursor on the @@ -289,6 +384,13 @@ private void TriggerRefresh () _state.Loading = false; int n = _state.Filtered.Count; _state.StatusMessage = n == 1 ? "1 package" : $"{n} packages"; + + // A search that hit the result cap means there's more the user can't see; + // nudge them to narrow it (only the COM backend actually caps). + if (mode == AppMode.Search && packages.Count >= AppState.SearchResultLimit) + { + _state.StatusMessage = $"{AppState.SearchResultLimit}+ matches — refine your search to narrow"; + } RefreshTable (); RefreshStatusBar (); RestoreCursorOrSelectFirst (previousSelectedId); @@ -332,9 +434,12 @@ private void RefreshTable () CancelPendingDetailLoad (); _detailPanel.SetDetail (null, false); - _packageTable.Table = new EnumerableTableSource ([], new () + // Render a single message row explaining *why* the list is empty, instead of a bare + // headered table. The message is contextual: "All packages are up to date!" vs. a + // filter/pin-specific note. Mirrors upstream winget-tui's empty-state messages (#228). + _packageTable.Table = new EnumerableTableSource ([EmptyStateMessage (_state)], new () { - { _state.Mode == AppMode.Upgrades ? "Name" : "Name", _ => string.Empty } + { " ", message => message } }); RefreshStatusBar (); @@ -452,10 +557,13 @@ private void ApplyColumnStyles (MarkedTableSource marked) _ => Theme.TextSecondary }; + // Color-code only the unselected (Normal) cells. The selected row's + // background is Theme.Accent, so an Accent foreground (msstore) would be + // invisible (Accent-on-Accent); leaving Focus/Active as the row defaults + // keeps the highlighted Source cell readable (dark-on-gold). return new Scheme (args.RowScheme) { - Normal = new (fg, args.RowScheme.Normal.Background), - Focus = new (fg, args.RowScheme.Focus.Background) + Normal = new (fg, args.RowScheme.Normal.Background) }; }; } @@ -472,6 +580,87 @@ private string HeaderWithSort (string label, SortField field) return label + (_state.SortDir == SortDir.Asc ? " ↑" : " ↓"); } + /// + /// Contextual message shown in the list when nothing matches. Distinguishes "up to date" from + /// a filter/pin that's hiding rows, so the user isn't misled. Mirrors upstream winget-tui's + /// draw_package_list empty-state arms (#228), plus a local-filter case the port adds. + /// + internal static string EmptyStateMessage (AppState state) + { + if (!string.IsNullOrEmpty (state.LocalFilter)) + { + return $"No packages match “{state.LocalFilter}”."; + } + + return state.Mode switch + { + AppMode.Search => string.IsNullOrEmpty (state.SearchQuery) + ? "Type to search for packages." + : "No packages found.", + AppMode.Upgrades when state.PinFilter == PinFilter.PinnedOnly => "No pinned packages with upgrades found.", + AppMode.Upgrades when state.PinFilter == PinFilter.UnpinnedOnly => "No unpinned packages with upgrades found.", + AppMode.Upgrades => "All packages are up to date!", + _ => "No packages found." + }; + } + + /// + /// Maps a clicked column header to the field it sorts by, or null for non-sortable columns + /// (the marker, Available, Source). The header text may carry a trailing sort arrow. + /// + internal static SortField? SortFieldForHeader (string columnName) + { + if (columnName.StartsWith ("Name", StringComparison.Ordinal)) + { + return SortField.Name; + } + + if (columnName.StartsWith ("Id", StringComparison.Ordinal)) + { + return SortField.Id; + } + + if (columnName.StartsWith ("Version", StringComparison.Ordinal)) + { + return SortField.Version; + } + + return null; + } + + /// + /// Sort the list when a sortable column header is clicked: first click sorts ascending, a + /// click on the already-active column toggles direction. Mirrors upstream winget-tui's + /// click-to-sort (commit 66d464c4). Clicks on non-sortable headers are a no-op. + /// + private void OnHeaderClicked (int column) + { + ITableSource? source = _packageTable.Table; + + if (source is null || column < 0 || column >= source.Columns) + { + return; + } + + if (SortFieldForHeader (source.ColumnNames [column]) is not { } field) + { + return; + } + + if (_state.SortField == field) + { + _state.SortDir = _state.SortDir == SortDir.Asc ? SortDir.Desc : SortDir.Asc; + } + else + { + _state.SortField = field; + _state.SortDir = SortDir.Asc; + } + + _state.ApplyFilter (); + RefreshTable (); + } + private void SyncTabBar () => _tabBar.Active = _state.Mode; /// @@ -564,6 +753,11 @@ private void OnSelectedRowChanged () { try { + // Debounce: a fast scroll cancels `ct` (via CancelPendingDetailLoad on + // the next selection change) before this delay elapses, so we only fetch + // for the row the cursor settles on — not every row it passes over. + await Task.Delay (DetailLoadDebounceMs, ct); + PackageDetail? detail = await _state.Backend.ShowAsync (p.Id, ct); if (ct.IsCancellationRequested || gen != _state.DetailGeneration) @@ -943,6 +1137,14 @@ private void OnKeyDown (object? sender, Key key) AskVerify (CurrentPackage ()); key.Handled = true; + return; + case 'R': + if (_state.Mode != AppMode.Search) + { + AskRepair (CurrentPackage ()); + key.Handled = true; + } + return; case 'u': AskUpgrade (CurrentPackage ()); @@ -1254,7 +1456,64 @@ private void ShowVerifyResult (Package p, InstallVerification v) sb.AppendLine ($"{(c.Ok ? "✓" : "✗")} {c.Label}{detail}"); } - MessageBox.Query (App, $"Verify: {p.Name}", sb.ToString ().TrimEnd (), "_OK"); + string body = sb.ToString ().TrimEnd (); + + // When the install is damaged, offer to repair it right from the result dialog. Choosing + // Repair runs it directly (no second confirm — clicking Repair here is the confirmation, + // and this path is COM-only since Verify is). Other outcomes are informational only. + if (v.Outcome == VerifyOutcome.Issues) + { + if (MessageBox.Query (App, $"Verify: {p.Name}", body, "_Repair", "_Close") == 0) + { + RunRepair (p); + } + + return; + } + + MessageBox.Query (App, $"Verify: {p.Name}", body, "_OK"); + } + + /// + /// Repair an installed package (re-run the installer in repair mode). Gated on the backend + /// supporting repair — degrades to a neutral message like Verify does on the CLI backend. + /// + private void AskRepair (Package? p) + { + if (p is null || App is null) + { + return; + } + + if (!_state.Backend.CanRepair) + { + _state.StatusMessage = "Repair is only available on the COM backend."; + _state.StatusIsError = false; + RefreshStatusBar (); + + return; + } + + if (!Confirm ("Repair", $"Repair {p.Name}? This re-runs the installer's repair to fix a damaged install.")) + { + return; + } + + RunRepair (p); + } + + /// + /// Execute the repair (guard + run). Shared by the standalone action (which confirms first via + /// ) and the Verify→Repair offer (which treats the button click as the confirm). + /// + private void RunRepair (Package p) + { + if (App is null || GuardTruncatedId (p, "repair")) + { + return; + } + + RunOperation ($"Repairing {p.Name}", (prog, ct) => _state.Backend.RepairAsync (p.Id, prog, ct)); } private InstallSettings? PromptAdvancedOptions (Package p) @@ -1383,19 +1642,35 @@ private void FetchThen (string activity, Func> fet private void AskUpgrade (Package? p) { - if (p is null || App is null || GuardTruncatedId (p, "upgrade")) + if (p is null || App is null) { return; } - if (!Confirm ("Upgrade", $"Upgrade {p.Name}?")) + // Unlike install/uninstall/pin, a truncated id doesn't block an upgrade: the CLI backend's + // UpgradeAsync tries `--id` then falls back to `--name --exact`, so handing it the name + // resolves the row winget truncated. Mirrors upstream winget-tui (commit fd9e9dbe). + // Truncation only arises from the CLI tabular parse; the COM backend always has full ids. + string query = UpgradeQueryFor (p); + string prompt = p.IsTruncated + ? $"Upgrade {p.Name}? (id was truncated by winget — matching by name)" + : $"Upgrade {p.Name}?"; + + if (!Confirm ("Upgrade", prompt)) { return; } - RunOperation ($"Upgrading {p.Name}", (prog, ct) => _state.Backend.UpgradeAsync (p.Id, prog, ct)); + RunOperation ($"Upgrading {p.Name}", (prog, ct) => _state.Backend.UpgradeAsync (query, prog, ct)); } + /// + /// The query to hand for a row: its id normally, but its + /// exact name when winget truncated the id (an `--id` match against the literal `…` can't + /// succeed; the CLI backend then resolves it via `--name --exact`). + /// + internal static string UpgradeQueryFor (Package p) => p.IsTruncated ? p.Name : p.Id; + private void AskUninstall (Package? p) { if (p is null || App is null || GuardTruncatedId (p, "uninstall")) @@ -1699,7 +1974,7 @@ private void ShowHelp () return; } - HelpDialog dlg = new (); + HelpDialog dlg = new (_state.BackendDescription); App.Run (dlg); dlg.Dispose (); } @@ -1819,6 +2094,36 @@ private sealed record EmptyRow (); /// Nested here because it has no consumer outside ; pulling /// it out as a public top-level type would just clutter the public surface. /// + /// + /// A that reports clicks on a column header (raising + /// with the column index) so the app can sort by that column, + /// matching upstream winget-tui's click-to-sort. Clicks on body rows keep the base behaviour. + /// + private sealed class SortableTableView : TableView + { + /// Raised with the clicked header's column index (the marker column is 0). + public event Action? HeaderClicked; + + /// + protected override bool OnMouseEvent (Mouse mouse) + { + if (mouse.IsSingleClicked == true && mouse.Position is { } pos) + { + _ = ScreenToCell (pos.X, pos.Y, out int? headerColumn); + + if (headerColumn is { } column) + { + HeaderClicked?.Invoke (column); + mouse.Handled = true; + + return true; + } + } + + return base.OnMouseEvent (mouse); + } + } + private sealed class MarkedTableSource : IEnumerableTableSource { private readonly EnumerableTableSource _inner; diff --git a/src/AppState.cs b/src/AppState.cs index e5ef5c9..3bd1ee5 100644 --- a/src/AppState.cs +++ b/src/AppState.cs @@ -6,6 +6,13 @@ namespace WingetTuiSharp; /// public sealed class AppState { + /// + /// Upper bound on rows a single search returns, so a pathologically broad query can't flood + /// the table. Lives here (not in the WINGET_COM-gated ComBackend) so the cross-platform App + /// build can reference it too. The COM backend applies it via FindPackagesOptions.ResultLimit. + /// + public const int SearchResultLimit = 1000; + public AppState (IBackend backend) => Backend = backend; public IBackend Backend { get; } @@ -23,7 +30,17 @@ public sealed class AppState public string SearchQuery { get; set; } = string.Empty; public string LocalFilter { get; set; } = string.Empty; - public SourceFilter SourceFilter { get; set; } = SourceFilter.All; + + /// The catalog the source filter is scoped to, or null for "All". Cycled by . + public string? SourceFilter { get; set; } + + /// + /// Configured source names the filter cycles through (besides "All"). Seeded with the two + /// predefined sources and replaced once resolves at + /// startup, so custom/enterprise REST sources become filterable too. + /// + public IReadOnlyList AvailableSources { get; set; } = ["winget", "msstore"]; + public PinFilter PinFilter { get; set; } = PinFilter.All; public SortField SortField { get; set; } = SortField.None; public SortDir SortDir { get; set; } = SortDir.Asc; @@ -32,6 +49,9 @@ public sealed class AppState public string StatusMessage { get; set; } = string.Empty; public bool StatusIsError { get; set; } + /// Which backend is live + its winget version (e.g. "COM · winget 1.11.400"), for the header badge and help. Empty until resolved at startup. + public string BackendDescription { get; set; } = string.Empty; + /// Progress of the in-flight install/upgrade/uninstall, or null when none is running. public OpProgress? OpProgress { get; set; } @@ -117,14 +137,40 @@ public void CycleSort () }; } + /// + /// Cycle All → each configured source in order → All. Resilient to the available-source list + /// changing under it (a now-missing current source just restarts the cycle at All). + /// public void CycleSourceFilter () { - SourceFilter = SourceFilter switch + if (AvailableSources.Count == 0) { - SourceFilter.All => SourceFilter.Winget, - SourceFilter.Winget => SourceFilter.MsStore, - _ => SourceFilter.All - }; + SourceFilter = null; + + return; + } + + if (SourceFilter is null) + { + SourceFilter = AvailableSources [0]; + + return; + } + + int idx = -1; + + for (int i = 0; i < AvailableSources.Count; i++) + { + if (string.Equals (AvailableSources [i], SourceFilter, StringComparison.OrdinalIgnoreCase)) + { + idx = i; + + break; + } + } + + // null = "All" once we step past the last source (or if the current one vanished). + SourceFilter = idx >= 0 && idx + 1 < AvailableSources.Count ? AvailableSources [idx + 1] : null; } public void CyclePinFilter () @@ -154,12 +200,14 @@ public void CycleMode (bool forward) }; } - public static string SourceLabel (SourceFilter f) + /// Status-bar badge text for a source filter: null → "All", else the source name (capitalized for the two predefined ones). + public static string SourceLabel (string? f) => f switch { - SourceFilter.Winget => " Winget ", - SourceFilter.MsStore => " MsStore ", - _ => " All " + null => " All ", + "winget" => " Winget ", + "msstore" => " MsStore ", + _ => $" {f} " }; public static string PinLabel (PinFilter f) diff --git a/src/Backend.cs b/src/Backend.cs index 3d7824a..73e2808 100644 --- a/src/Backend.cs +++ b/src/Backend.cs @@ -3,11 +3,19 @@ namespace WingetTuiSharp; public interface IBackend { - Task> SearchAsync (string query, SourceFilter source, CancellationToken ct); - Task> ListInstalledAsync (SourceFilter source, CancellationToken ct); - Task> ListUpgradesAsync (SourceFilter source, CancellationToken ct); + // `source` is a catalog name (e.g. "winget", "msstore", or a custom REST source) to scope the + // query to, or null for all configured sources. Discover the available names via ListSourcesAsync. + Task> SearchAsync (string query, string? source, CancellationToken ct); + Task> ListInstalledAsync (string? source, CancellationToken ct); + Task> ListUpgradesAsync (string? source, CancellationToken ct); Task ShowAsync (string id, CancellationToken ct); + // The names of the configured package sources (catalogs), e.g. ["winget", "msstore"], used to + // build the source-filter cycle dynamically instead of hard-coding the two predefined sources. + // The COM backend reads them from PackageManager.GetPackageCatalogs(); the CLI parses + // `winget source list`; the mock returns the two defaults. + Task> ListSourcesAsync (CancellationToken ct); + // Available versions for a package, newest first. Drives the version picker. Backends that // can't enumerate versions (CLI) return an empty list, in which case the UI falls back to a // free-text version prompt. @@ -32,7 +40,21 @@ public interface IBackend // Check whether an installed package's files/registration are intact (COM // CheckInstalledStatus). Returns null when the backend has no equivalent (CLI). Task VerifyInstalledAsync (string id, CancellationToken ct); + + // True when this backend can repair an installed package (COM only). The UI gates the Repair + // action on this and shows a neutral "only available on the COM backend" message otherwise, + // mirroring how Verify degrades. RepairAsync re-runs the installer in repair mode to fix a + // damaged install, reporting progress like install/upgrade. Only meaningful when CanRepair. + bool CanRepair { get; } + Task RepairAsync (string id, IProgress? progress, CancellationToken ct); + Task PinAsync (string id, CancellationToken ct); Task UnpinAsync (string id, CancellationToken ct); Task> ListPinsAsync (CancellationToken ct); + + // A short, human-readable description of which backend is live and (where available) the + // winget version behind it — e.g. "COM · winget 1.11.400", "CLI · winget 1.11.400", or + // "Mock backend". Shown in the help dialog and at startup so it's obvious which backend the + // app actually selected (the COM build can silently fall back to CLI — see WINDOWS-TESTING.md). + Task DescribeAsync (CancellationToken ct); } diff --git a/src/CliBackend.cs b/src/CliBackend.cs index ba85c09..468e5ea 100644 --- a/src/CliBackend.cs +++ b/src/CliBackend.cs @@ -12,35 +12,46 @@ namespace WingetTuiSharp; /// public sealed partial class CliBackend : IBackend { - private static string SourceArg (SourceFilter f) - => f switch - { - SourceFilter.Winget => "winget", - SourceFilter.MsStore => "msstore", - _ => string.Empty - }; - - public async Task> SearchAsync (string query, SourceFilter source, CancellationToken ct) + public async Task> SearchAsync (string query, string? source, CancellationToken ct) { string output = await RunAsync (SearchArgs (query, source), ct); return ParseTable (output, hasAvailable: false); } - public async Task> ListInstalledAsync (SourceFilter source, CancellationToken ct) + public async Task> ListInstalledAsync (string? source, CancellationToken ct) { string output = await RunAsync (ListInstalledArgs (source), ct); return ParseTable (output, hasAvailable: false); } - public async Task> ListUpgradesAsync (SourceFilter source, CancellationToken ct) + public async Task> ListUpgradesAsync (string? source, CancellationToken ct) { string output = await RunAsync (ListUpgradesArgs (source), ct); return ParseTable (output, hasAvailable: true); } + /// + /// The configured source names from `winget source list`, e.g. ["winget", "msstore"]. + /// Falls back to the two predefined sources if the command fails or parses empty. + /// + public async Task> ListSourcesAsync (CancellationToken ct) + { + try + { + string output = await RunAsync (["source", "list"], ct); + IReadOnlyList names = ParseSourceNames (output); + + return names.Count > 0 ? names : ["winget", "msstore"]; + } + catch + { + return ["winget", "msstore"]; + } + } + public async Task ShowAsync (string id, CancellationToken ct) { string output = await RunAsync (ShowArgs (id), ct); @@ -62,6 +73,18 @@ public Task> ListVersionsAsync (string id, CancellationTok public Task VerifyInstalledAsync (string id, CancellationToken ct) => Task.FromResult (null); + // Repair is COM-only by design (see the Repair spec). The UI gates on CanRepair, so this + // safety-net failure is never reached through the app. + public bool CanRepair => false; + + public Task RepairAsync (string id, IProgress? progress, CancellationToken ct) + => Task.FromResult (new OpResult + { + Operation = new () { Kind = OperationKind.Repair, PackageId = id }, + Success = false, + Message = "Repair is only available on the COM backend." + }); + // progress is unused: winget.exe only emits an ANSI progress bar to stdout, which we // capture as a whole rather than scrape. The COM backend is the one that reports progress. public async Task InstallAsync (string id, string? version, InstallSettings? settings, IProgress? progress, CancellationToken ct) @@ -140,43 +163,38 @@ public async Task UnpinAsync (string id, CancellationToken ct) // catch any drift. // ────────────────────────────────────────────────────────────────────── - internal static string [] SearchArgs (string query, SourceFilter source) + internal static string [] SearchArgs (string query, string? source) { List args = ["search", query, "--accept-source-agreements"]; - - if (source != SourceFilter.All) - { - args.Add ("--source"); - args.Add (SourceArg (source)); - } + AppendSource (args, source); return [.. args]; } - internal static string [] ListInstalledArgs (SourceFilter source) + internal static string [] ListInstalledArgs (string? source) { List args = ["list", "--accept-source-agreements"]; - - if (source != SourceFilter.All) - { - args.Add ("--source"); - args.Add (SourceArg (source)); - } + AppendSource (args, source); return [.. args]; } - internal static string [] ListUpgradesArgs (SourceFilter source) + internal static string [] ListUpgradesArgs (string? source) { List args = ["upgrade", "--accept-source-agreements", "--include-pinned"]; + AppendSource (args, source); - if (source != SourceFilter.All) + return [.. args]; + } + + /// Append `--source <name>` when a specific source is selected; nothing for "All" (null/empty). + private static void AppendSource (List args, string? source) + { + if (!string.IsNullOrEmpty (source)) { args.Add ("--source"); - args.Add (SourceArg (source)); + args.Add (source); } - - return [.. args]; } internal static string [] ShowArgs (string id) => @@ -297,6 +315,21 @@ public async Task> ListPinsAsync (Cancella return ParsePins (output); } + public async Task DescribeAsync (CancellationToken ct) + { + try + { + (int code, string output) = await RunWithCodeAsync (["--version"], ct); + string version = code == 0 ? output.Trim ().TrimStart ('v', 'V') : string.Empty; + + return string.IsNullOrEmpty (version) ? "CLI · winget" : $"CLI · winget {version}"; + } + catch + { + return "CLI · winget"; + } + } + /// /// Parses `winget pin list` output, distinguishing Blocking / Gating(version) / Pinned /// states. Mirrors upstream src/cli_backend.rs::parse_pins_from_table + parse_pin_state. @@ -431,6 +464,69 @@ private static async Task RunAsync (IReadOnlyList args, Cancella return output; } + /// + /// Parse the Name column out of `winget source list`'s table (Name / Argument columns), reusing + /// the same dash-separator + column-slice machinery as the package table. Falls back to the + /// first whitespace-delimited token per row if the Name column can't be located. + /// + internal static IReadOnlyList ParseSourceNames (string output) + { + output = StripAnsi (output); + string [] lines = SplitLines (output); + int sepIdx = -1; + + for (int i = 0; i < lines.Length; i++) + { + string line = StripControl (lines [i]).TrimEnd (); + + if (line.Length >= 4 && line.All (c => c is '-' or '─')) + { + sepIdx = i; + + break; + } + } + + if (sepIdx <= 0) + { + return []; + } + + List<(string Name, int Start)> columns = ParseHeader (StripControl (lines [sepIdx - 1])); + int nameIdx = ColIndex (columns, "name", "nom", "nombre", "nome"); + + if (nameIdx < 0) + { + nameIdx = 0; + } + + List names = []; + + for (int i = sepIdx + 1; i < lines.Length; i++) + { + if (string.IsNullOrWhiteSpace (lines [i])) + { + continue; + } + + string sanitized = StripControl (lines [i]); + string name = SliceColumn (sanitized, columns, nameIdx).Trim (); + + if (string.IsNullOrEmpty (name)) + { + string [] parts = sanitized.Trim ().Split (' ', StringSplitOptions.RemoveEmptyEntries); + name = parts.Length > 0 ? parts [0] : string.Empty; + } + + if (!string.IsNullOrWhiteSpace (name)) + { + names.Add (name); + } + } + + return names; + } + public static async Task<(int Code, string Output)> RunWithCodeAsync (IReadOnlyList args, CancellationToken ct) { ProcessStartInfo psi = new () diff --git a/src/ComBackend.cs b/src/ComBackend.cs index 5a6cb91..a58a9c3 100644 --- a/src/ComBackend.cs +++ b/src/ComBackend.cs @@ -52,7 +52,7 @@ public sealed class ComBackend : IBackend // Reads // ------------------------------------------------------------------------ - public async Task> SearchAsync (string query, SourceFilter source, CancellationToken ct) + public async Task> SearchAsync (string query, string? source, CancellationToken ct) { if (string.IsNullOrWhiteSpace (query)) { @@ -74,6 +74,11 @@ public async Task> SearchAsync (string query, SourceFilte Value = query }); + // Cap a pathologically broad query (e.g. a one-letter term) so it can't materialize tens + // of thousands of rows. The app already blocks empty queries; this guards the merely-broad + // ones. result.WasLimitExceeded below tells the UI to nudge the user to refine. + opts.ResultLimit = AppState.SearchResultLimit; + FindPackagesResult result = await catalog.FindPackagesAsync (opts).AsTask (ct); List packages = []; @@ -90,7 +95,8 @@ public async Task> SearchAsync (string query, SourceFilte Id = pkg.Id, Name = pkg.Name, Version = version, - Source = SourceOf (pkg) + Source = SourceOf (pkg), + MatchField = NotableMatchField (m) }); } catch @@ -103,10 +109,36 @@ public async Task> SearchAsync (string query, SourceFilte return packages; } - public Task> ListInstalledAsync (SourceFilter source, CancellationToken ct) + /// + /// The field this result matched on, but only when it's a non-obvious one. A match on Name, + /// Id, or the catalog default (free-text) is expected and needs no annotation; a match on a + /// Moniker, Tag, Command, family name, or product code explains why an otherwise-unexpected + /// package surfaced, so we surface those as a "Matched on" hint. Returns null otherwise. + /// + private static string? NotableMatchField (MatchResult m) + { + try + { + return m.MatchCriteria?.Field switch + { + PackageMatchField.Moniker => "moniker", + PackageMatchField.Tag => "tag", + PackageMatchField.Command => "command", + PackageMatchField.PackageFamilyName => "family name", + PackageMatchField.ProductCode => "product code", + _ => null + }; + } + catch + { + return null; + } + } + + public Task> ListInstalledAsync (string? source, CancellationToken ct) => ListLocalAsync (source, upgradesOnly: false, ct); - public Task> ListUpgradesAsync (SourceFilter source, CancellationToken ct) + public Task> ListUpgradesAsync (string? source, CancellationToken ct) => ListLocalAsync (source, upgradesOnly: true, ct); /// @@ -115,7 +147,7 @@ public Task> ListUpgradesAsync (SourceFilter source, Canc /// from the implicit local "installed" catalog, correlated against the supplied remote /// catalog(s) so each row knows its available version / update status. /// - private async Task> ListLocalAsync (SourceFilter source, bool upgradesOnly, CancellationToken ct) + private async Task> ListLocalAsync (string? source, bool upgradesOnly, CancellationToken ct) { PackageCatalog catalog = await ConnectAsync ( CompositeRef (RemoteRefs (source), CompositeSearchBehavior.LocalCatalogs), @@ -161,7 +193,7 @@ private async Task> ListLocalAsync (SourceFilter source, public async Task ShowAsync (string id, CancellationToken ct) { - CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); + CatalogPackage? pkg = await FindByIdAsync (id, null, installedContext: false, ct); if (pkg is null) { @@ -189,6 +221,10 @@ private async Task> ListLocalAsync (SourceFilter source, string? description = Coalesce (meta?.Description, meta?.ShortDescription); + // Installed-only metadata (location/scope) comes from the installed version's metadata + // bag, not the manifest — so resolve it from the installed version specifically. + PackageVersionInfo? installed = SafeInstalledVersion (pkg); + try { return new () @@ -199,11 +235,18 @@ private async Task> ListLocalAsync (SourceFilter source, AvailableVersion = LatestAvailableVersion (pkg), Source = SourceOf (pkg), Publisher = NullIfEmpty (meta?.Publisher), + Author = NullIfEmpty (meta?.Author), + Copyright = NullIfEmpty (meta?.Copyright), Description = description, Homepage = NullIfEmpty (meta?.PackageUrl), License = NullIfEmpty (meta?.License), ReleaseNotesUrl = NullIfEmpty (meta?.ReleaseNotesUrl), SupportUrl = NullIfEmpty (meta?.PublisherSupportUrl), + PrivacyUrl = NullIfEmpty (meta?.PrivacyUrl), + PurchaseUrl = NullIfEmpty (meta?.PurchaseUrl), + InstallationNotes = NullIfEmpty (meta?.InstallationNotes), + InstalledLocation = SafeMetadata (installed, PackageVersionMetadataField.InstalledLocation), + InstalledScope = SafeMetadata (installed, PackageVersionMetadataField.InstalledScope), Tags = meta is null ? null : StringVector (() => meta.Tags), Documentation = DocLinks (meta), ProductCodes = StringVector (() => versionInfo.ProductCodes), @@ -225,7 +268,7 @@ private async Task> ListLocalAsync (SourceFilter source, public async Task> ListVersionsAsync (string id, CancellationToken ct) { - CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); + CatalogPackage? pkg = await FindByIdAsync (id, null, installedContext: false, ct); if (pkg is null) { @@ -258,7 +301,7 @@ public async Task> ListVersionsAsync (string id, Cancellat public async Task GetInstallerPreviewAsync (string id, string? version, CancellationToken ct) { - CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); + CatalogPackage? pkg = await FindByIdAsync (id, null, installedContext: false, ct); if (pkg is null) { @@ -380,7 +423,7 @@ private static bool RequiresElevation (PackageInstallerInfo installer) public async Task InstallAsync (string id, string? version, InstallSettings? settings, IProgress? progress, CancellationToken ct) { Operation op = new () { Kind = OperationKind.Install, PackageId = id, Version = version }; - CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); + CatalogPackage? pkg = await FindByIdAsync (id, null, installedContext: false, ct); if (pkg is null) { @@ -425,7 +468,7 @@ public async Task UpgradeAsync (string id, IProgress? prog // Installed context so the package carries both its installed version and the // correlated remote available versions that the upgrade resolves against. - CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: true, ct); + CatalogPackage? pkg = await FindByIdAsync (id, null, installedContext: true, ct); if (pkg is null) { @@ -450,7 +493,7 @@ public async Task UpgradeAsync (string id, IProgress? prog public async Task UninstallAsync (string id, IProgress? progress, CancellationToken ct) { Operation op = new () { Kind = OperationKind.Uninstall, PackageId = id }; - CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: true, ct); + CatalogPackage? pkg = await FindByIdAsync (id, null, installedContext: true, ct); if (pkg is null) { @@ -470,7 +513,7 @@ public async Task UninstallAsync (string id, IProgress? pr public async Task DownloadAsync (string id, string? version, IProgress? progress, CancellationToken ct) { Operation op = new () { Kind = OperationKind.Download, PackageId = id, Version = version }; - CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: false, ct); + CatalogPackage? pkg = await FindByIdAsync (id, null, installedContext: false, ct); if (pkg is null) { @@ -515,9 +558,37 @@ public async Task DownloadAsync (string id, string? version, IProgress : Fail (op, $"Download failed: {result.Status} (hr 0x{HResultOf (result.ExtendedErrorCode):X8})"); } + public bool CanRepair => true; + + public async Task RepairAsync (string id, IProgress? progress, CancellationToken ct) + { + Operation op = new () { Kind = OperationKind.Repair, PackageId = id }; + + // Installed context so the package carries the installed version that repair targets. + CatalogPackage? pkg = await FindByIdAsync (id, null, installedContext: true, ct); + + if (pkg is null) + { + return Fail (op, $"Installed package '{id}' not found."); + } + + RepairOptions options = new () { PackageRepairMode = PackageRepairMode.Silent }; + + var asyncOp = _pm.RepairPackageAsync (pkg, options); + asyncOp.Progress = (_, p) => progress?.Report (MapRepair (p)); + RepairResult result = await asyncOp.AsTask (ct); + + return result.Status switch + { + RepairResultStatus.Ok => Ok (op, $"Repaired {pkg.Name}{(result.RebootRequired ? " (reboot required)" : string.Empty)}"), + RepairResultStatus.NoApplicableRepairer => Fail (op, $"{pkg.Name} doesn't support repair."), + _ => Fail (op, $"Repair failed: {result.Status} (repairer {result.RepairerErrorCode}, hr 0x{HResultOf (result.ExtendedErrorCode):X8})") + }; + } + public async Task VerifyInstalledAsync (string id, CancellationToken ct) { - CatalogPackage? pkg = await FindByIdAsync (id, SourceFilter.All, installedContext: true, ct); + CatalogPackage? pkg = await FindByIdAsync (id, null, installedContext: true, ct); if (pkg is null) { @@ -740,25 +811,41 @@ private static void ApplyInstallSettings (InstallOptions options, InstallSetting public Task> ListPinsAsync (CancellationToken ct) => _cliForPins.ListPinsAsync (ct); + public Task DescribeAsync (CancellationToken ct) + { + string version; + + try + { + // PackageManager.Version (contract 13) — the running WinGet COM server version. + version = NullIfEmpty (_pm.Version) ?? "unknown version"; + } + catch + { + // Older COM servers don't expose Version; the backend still works. + version = "unknown version"; + } + + return Task.FromResult ($"COM · winget {version}"); + } + // ------------------------------------------------------------------------ // Catalog plumbing // ------------------------------------------------------------------------ - /// Resolve the configured remote catalog reference(s) for a source filter. - private List RemoteRefs (SourceFilter source) + /// + /// Resolve the configured remote catalog reference(s) for a source filter. A specific + /// name resolves just that catalog; null ("All") expands to every + /// configured source (winget, msstore, and any custom REST sources) via GetPackageCatalogs, + /// rather than the hard-coded pair — so enterprise/custom sources are included automatically. + /// + private List RemoteRefs (string? source) { - string [] names = source switch - { - SourceFilter.Winget => ["winget"], - SourceFilter.MsStore => ["msstore"], - _ => ["winget", "msstore"] - }; - List refs = []; - foreach (string name in names) + if (!string.IsNullOrEmpty (source)) { - PackageCatalogReference? r = _pm.GetPackageCatalogByName (name); + PackageCatalogReference? r = _pm.GetPackageCatalogByName (source); if (r is not null) { @@ -767,11 +854,70 @@ private List RemoteRefs (SourceFilter source) r.AcceptSourceAgreements = true; refs.Add (r); } + + return refs; + } + + // All configured sources. GetPackageCatalogs() returns the source list (excludes the + // implicit local "installed" catalog, which the composite adds on its own). + try + { + foreach (PackageCatalogReference r in Materialize (_pm.GetPackageCatalogs ())) + { + r.AcceptSourceAgreements = true; + refs.Add (r); + } + } + catch + { + // GetPackageCatalogs failed (unusual); fall back to the two predefined sources so the + // common case still works. + foreach (string name in (string [])["winget", "msstore"]) + { + PackageCatalogReference? r = _pm.GetPackageCatalogByName (name); + + if (r is not null) + { + r.AcceptSourceAgreements = true; + refs.Add (r); + } + } } return refs; } + public Task> ListSourcesAsync (CancellationToken ct) + { + List names = []; + + try + { + foreach (PackageCatalogReference r in Materialize (_pm.GetPackageCatalogs ())) + { + try + { + string? name = NullIfEmpty (r.Info?.Name); + + if (name is not null) + { + names.Add (name); + } + } + catch + { + // Skip a catalog whose Info/Name read threw rather than dropping the whole list. + } + } + } + catch + { + // GetPackageCatalogs threw — return empty; the app keeps its seeded defaults. + } + + return Task.FromResult> (names); + } + /// /// Wrap one-or-more remote references into a composite catalog. The local "installed" /// catalog is implicit in every composite; selects which side @@ -803,7 +949,7 @@ private static async Task ConnectAsync (PackageCatalogReference } /// Find a single package by exact (case-insensitive) id. - private async Task FindByIdAsync (string id, SourceFilter source, bool installedContext, CancellationToken ct) + private async Task FindByIdAsync (string id, string? source, bool installedContext, CancellationToken ct) { PackageCatalog catalog = await ConnectAsync ( CompositeRef ( @@ -920,6 +1066,24 @@ private static bool SafeIsUpdateAvailable (CatalogPackage pkg) } } + /// Read a single PackageVersionMetadata field, returning null if absent/empty/unreadable. + private static string? SafeMetadata (PackageVersionInfo? info, PackageVersionMetadataField field) + { + if (info is null) + { + return null; + } + + try + { + return NullIfEmpty (info.GetMetadata (field)); + } + catch + { + return null; + } + } + /// Latest available version string (AvailableVersions is newest-first), else the default-install version. private static string? LatestAvailableVersion (CatalogPackage pkg) { @@ -1012,6 +1176,20 @@ private static OpProgress MapDownload (PackageDownloadProgress p) return new (phase, p.State == PackageDownloadProgressState.Finished ? 1.0 : p.DownloadProgress); } + private static OpProgress MapRepair (RepairProgress p) + { + OpPhase phase = p.State switch + { + PackageRepairProgressState.Queued => OpPhase.Queued, + PackageRepairProgressState.Repairing => OpPhase.Repairing, + PackageRepairProgressState.PostRepair => OpPhase.Finalizing, + PackageRepairProgressState.Finished => OpPhase.Done, + _ => OpPhase.Repairing + }; + + return new (phase, p.State == PackageRepairProgressState.Finished ? 1.0 : p.RepairCompletionProgress); + } + private static string DescribeInstall (string verb, InstallResult result) => $"{verb} failed: {result.Status} (installer {result.InstallerErrorCode}, hr 0x{HResultOf (result.ExtendedErrorCode):X8})"; diff --git a/src/DetailPanel.cs b/src/DetailPanel.cs index 80b22b5..9754fd8 100644 --- a/src/DetailPanel.cs +++ b/src/DetailPanel.cs @@ -73,6 +73,11 @@ public void SetDetail (PackageDetail? detail, bool loading) AddKv ("Publisher", detail.Publisher); } + if (!string.IsNullOrEmpty (detail.Author)) + { + AddKv ("Author", detail.Author); + } + // Skip the Source line when both the show output and the list-row context lacked it // (rare — typically only ARP/MSIX-only packages with no manifest source). if (!string.IsNullOrEmpty (detail.Source)) @@ -85,11 +90,33 @@ public void SetDetail (PackageDetail? detail, bool loading) }); } + // Search-only: explain a non-obvious match (e.g. the package matched on a tag, not its + // name) so the user understands why it surfaced. Dim so it reads as a footnote. + if (Mode == AppMode.Search && !string.IsNullOrEmpty (detail.MatchField)) + { + AddSingle ($"↳ matched on {detail.MatchField}", Theme.TextSecondary, TextStyle.Italic); + } + + if (!string.IsNullOrEmpty (detail.InstalledScope)) + { + AddKv ("Scope", detail.InstalledScope); + } + + if (!string.IsNullOrEmpty (detail.InstalledLocation)) + { + AddKv ("Installed to", detail.InstalledLocation); + } + if (!string.IsNullOrEmpty (detail.License)) { AddKv ("License", detail.License); } + if (!string.IsNullOrEmpty (detail.Copyright)) + { + AddKv ("Copyright", detail.Copyright); + } + if (detail.Tags is { Count: > 0 } tags) { AddKv ("Tags", string.Join (", ", tags)); @@ -126,6 +153,16 @@ public void SetDetail (PackageDetail? detail, bool loading) AddMarkdownLinkRow ("Support", detail.SupportUrl); } + if (!string.IsNullOrEmpty (detail.PrivacyUrl)) + { + AddMarkdownLinkRow ("Privacy", detail.PrivacyUrl); + } + + if (!string.IsNullOrEmpty (detail.PurchaseUrl)) + { + AddMarkdownLinkRow ("Purchase", detail.PurchaseUrl); + } + if (detail.Documentation is { Count: > 0 } docs) { foreach (DocLink doc in docs) @@ -151,6 +188,13 @@ public void SetDetail (PackageDetail? detail, bool loading) } } + if (!string.IsNullOrEmpty (detail.InstallationNotes)) + { + AddBlank (); + AddSingle ("Installation notes:", Theme.Accent, TextStyle.Bold); + AddParagraph (detail.InstallationNotes); + } + AddBlank (); AddSingle ("Actions:", Theme.Accent, TextStyle.Bold); @@ -170,12 +214,14 @@ public void SetDetail (PackageDetail? detail, bool loading) AddAction ("x", "Uninstall"); AddAction ("p", "Pin/Unpin"); AddAction ("V", "Verify install"); + AddAction ("R", "Repair install"); break; case AppMode.Upgrades: AddAction ("u", "Upgrade"); AddAction ("x", "Uninstall"); AddAction ("V", "Verify install"); + AddAction ("R", "Repair install"); AddAction ("Spc", "Select"); AddAction ("a", "Toggle All"); AddAction ("U", "Upgrade selected"); diff --git a/src/MockBackend.cs b/src/MockBackend.cs index de916c0..85b388b 100644 --- a/src/MockBackend.cs +++ b/src/MockBackend.cs @@ -31,7 +31,7 @@ public sealed class MockBackend : IBackend private readonly Dictionary _pins = new (StringComparer.OrdinalIgnoreCase); - public Task> SearchAsync (string query, SourceFilter source, CancellationToken ct) + public Task> SearchAsync (string query, string? source, CancellationToken ct) { IEnumerable q = _searchResults .Concat (_installed) @@ -39,24 +39,19 @@ public Task> SearchAsync (string query, SourceFilter sour || p.Name.Contains (query, StringComparison.OrdinalIgnoreCase) || p.Id.Contains (query, StringComparison.OrdinalIgnoreCase)); - q = source switch + if (!string.IsNullOrEmpty (source)) { - SourceFilter.Winget => q.Where (p => p.Source == "winget"), - SourceFilter.MsStore => q.Where (p => p.Source == "msstore"), - _ => q - }; + q = q.Where (p => p.Source == source); + } return Task.FromResult> (q.ToArray ()); } - public Task> ListInstalledAsync (SourceFilter source, CancellationToken ct) + public Task> ListInstalledAsync (string? source, CancellationToken ct) { - Package [] q = source switch - { - SourceFilter.Winget => _installed.Where (p => p.Source == "winget").ToArray (), - SourceFilter.MsStore => _installed.Where (p => p.Source == "msstore").ToArray (), - _ => _installed - }; + Package [] q = string.IsNullOrEmpty (source) + ? _installed + : _installed.Where (p => p.Source == source).ToArray (); foreach (Package p in q) { @@ -69,16 +64,14 @@ public Task> ListInstalledAsync (SourceFilter source, Can return Task.FromResult> (q); } - public Task> ListUpgradesAsync (SourceFilter source, CancellationToken ct) + public Task> ListUpgradesAsync (string? source, CancellationToken ct) { Package [] q = _installed.Where (p => p.AvailableVersion is not null).ToArray (); - q = source switch + if (!string.IsNullOrEmpty (source)) { - SourceFilter.Winget => q.Where (p => p.Source == "winget").ToArray (), - SourceFilter.MsStore => q.Where (p => p.Source == "msstore").ToArray (), - _ => q - }; + q = q.Where (p => p.Source == source).ToArray (); + } foreach (Package p in q) { @@ -91,6 +84,9 @@ public Task> ListUpgradesAsync (SourceFilter source, Canc return Task.FromResult> (q); } + public Task> ListSourcesAsync (CancellationToken ct) + => Task.FromResult> (["winget", "msstore"]); + public Task ShowAsync (string id, CancellationToken ct) { Package? p = _installed.Concat (_searchResults).FirstOrDefault (x => x.Id == id); @@ -174,6 +170,31 @@ public Task> ListVersionsAsync (string id, CancellationTok return Task.FromResult (v); } + public bool CanRepair => true; + + public async Task RepairAsync (string id, IProgress? progress, CancellationToken ct) + { + // Synthesize a repair ramp so the flow is exercisable on Linux (mirrors how the mock's + // Verify fakes a result). No download phase — repair re-runs the local installer. + if (progress is not null) + { + for (int i = 0; i <= 10; i++) + { + progress.Report (new (OpPhase.Repairing, i / 10.0)); + await Task.Delay (45, ct); + } + + progress.Report (new (OpPhase.Done, 1.0)); + } + + return new () + { + Operation = new () { Kind = OperationKind.Repair, PackageId = id }, + Success = true, + Message = $"[mock] Repaired {id}" + }; + } + public async Task InstallAsync (string id, string? version, InstallSettings? settings, IProgress? progress, CancellationToken ct) { await SimulateProgressAsync (progress, downloads: true, ct); @@ -292,4 +313,6 @@ public Task UnpinAsync (string id, CancellationToken ct) public Task> ListPinsAsync (CancellationToken ct) => Task.FromResult> (_pins); + + public Task DescribeAsync (CancellationToken ct) => Task.FromResult ("Mock backend"); } diff --git a/src/Models.cs b/src/Models.cs index f160f1c..938e29b 100644 --- a/src/Models.cs +++ b/src/Models.cs @@ -35,13 +35,6 @@ public enum SortDir Desc } -public enum SourceFilter -{ - All, - Winget, - MsStore -} - public enum PinFilter { All, @@ -82,6 +75,14 @@ public sealed class Package public string? AvailableVersion { get; init; } public PinState PinState { get; set; } = PinState.Unpinned; + /// + /// For search results only: the manifest field the package matched on when it's a + /// non-obvious one (Tag / Moniker / Command / family name / product code) — i.e. not Name/Id. + /// Null for normal name/id matches and for non-search rows. Surfaced as a "Matched on" hint + /// in the detail panel so the user understands why an unexpected package showed up. + /// + public string? MatchField { get; init; } + public bool IsTruncated => Id.EndsWith ('…') || Id.EndsWith ("...", StringComparison.Ordinal); } @@ -106,6 +107,22 @@ public sealed class PackageDetail public IReadOnlyList? ProductCodes { get; init; } public IReadOnlyList? PackageFamilyNames { get; init; } + // Additional manifest/installed metadata surfaced by the COM backend (null elsewhere). + // Every one is optional and rendered only when present, so sparse packages stay clean. + public string? Author { get; init; } + public string? Copyright { get; init; } + public string? PrivacyUrl { get; init; } + public string? PurchaseUrl { get; init; } + public string? InstallationNotes { get; init; } + public string? InstalledLocation { get; init; } + public string? InstalledScope { get; init; } + + /// + /// For search-result detail: the non-obvious field the package matched on (see + /// ). Carried over from the originating list row. + /// + public string? MatchField { get; set; } + /// /// True when holds a synthesized "couldn't fetch / nothing available" /// note rather than real manifest copy. Used by the detail panel to dim/annotate the line so @@ -141,6 +158,12 @@ public void MergeContext (Package context) { PinState = context.PinState; } + + // The "matched on" hint is only known from the search row, not from `show`/FindById. + if (string.IsNullOrEmpty (MatchField) && !string.IsNullOrEmpty (context.MatchField)) + { + MatchField = context.MatchField; + } } /// @@ -177,7 +200,8 @@ public enum OperationKind Pin, Unpin, BatchUpgrade, - Download + Download, + Repair } public enum InstallScopePref @@ -282,6 +306,7 @@ public enum OpPhase Downloading, Installing, Uninstalling, + Repairing, Finalizing, Done } @@ -301,6 +326,7 @@ public string Label OpPhase.Downloading => "Downloading", OpPhase.Installing => "Installing", OpPhase.Uninstalling => "Uninstalling", + OpPhase.Repairing => "Repairing", OpPhase.Finalizing => "Finalizing", OpPhase.Done => "Done", _ => string.Empty diff --git a/src/Ui.cs b/src/Ui.cs index 5513ed3..4784777 100644 --- a/src/Ui.cs +++ b/src/Ui.cs @@ -121,7 +121,7 @@ public StatusBar () public AppMode Mode { get; set; } = AppMode.Installed; public InputMode InputMode { get; set; } = InputMode.Normal; - public SourceFilter SourceFilter { get; set; } = SourceFilter.All; + public string? SourceFilter { get; set; } public PinFilter PinFilter { get; set; } = PinFilter.All; public string Message { get; set; } = string.Empty; public bool IsError { get; set; } @@ -160,8 +160,10 @@ protected override bool OnDrawingContent (DrawContext? context) string srcLabel = AppState.SourceLabel (SourceFilter); Attribute srcAttr = SourceFilter switch { - SourceFilter.Winget => new (Theme.TextOnAccent, Theme.Info, TextStyle.Bold), - SourceFilter.MsStore => new (Theme.TextOnAccent, Theme.Accent, TextStyle.Bold), + "winget" => new (Theme.TextOnAccent, Theme.Info, TextStyle.Bold), + "msstore" => new (Theme.TextOnAccent, Theme.Accent, TextStyle.Bold), + + // null = "All", and any custom source, share the neutral badge. _ => new (Theme.TextOnAccent, Theme.TextSecondary, TextStyle.Bold) }; SetAttribute (srcAttr); @@ -492,7 +494,7 @@ public AdvancedInstallDialog (string packageName) /// public sealed class HelpDialog : Runnable { - public HelpDialog () + public HelpDialog (string backendDescription = "") { Title = " Help "; BorderStyle = LineStyle.Rounded; @@ -503,13 +505,19 @@ public HelpDialog () SchemeName = Theme.SurfaceSchemeName; Arrangement = ViewArrangement.Movable; + // Lead with which backend is live so it's discoverable that the COM build can fall back + // to the CLI (see WINDOWS-TESTING.md). Empty until resolved → omit the line entirely. + string header = string.IsNullOrEmpty (backendDescription) + ? string.Empty + : $"Backend: {backendDescription}\n\n"; + Code content = new () { X = 1, Y = 0, Width = Dim.Fill (1), Height = Dim.Fill (1), - Text = HelpText + Text = header + HelpText }; Add (content); @@ -560,6 +568,7 @@ d Download installer only (no install) u Upgrade x Uninstall V Verify install (check files / registration) + R Repair install (re-run the installer's repair) p Pin / Unpin Space Toggle batch select (Upgrades only) a Select / deselect all (Upgrades only) diff --git a/tests/AppBehaviorTests.cs b/tests/AppBehaviorTests.cs index a7925cd..e1f496e 100644 --- a/tests/AppBehaviorTests.cs +++ b/tests/AppBehaviorTests.cs @@ -130,6 +130,74 @@ public void AppState_ApplyFilter_SortsVersionsNumericallyDescending () Assert.Equal (["10.0.0", "2.0.0", "1.9.0"], state.Filtered.Select (p => p.Version)); } + [Fact] + public void UpgradeQueryFor_TruncatedId_FallsBackToName () + { + // winget truncates long ids in tabular output with `…`; an --id match can't succeed, so + // the upgrade query must be the exact name instead. Mirrors upstream winget-tui fd9e9dbe. + Package p = new () { Id = "Microsoft.Azure.Function…", Name = "Azure Functions Core Tools", Source = "winget" }; + + Assert.Equal ("Azure Functions Core Tools", App.UpgradeQueryFor (p)); + } + + [Fact] + public void UpgradeQueryFor_FullId_UsesId () + { + Package p = new () { Id = "7zip.7zip", Name = "7-Zip", Source = "winget" }; + + Assert.Equal ("7zip.7zip", App.UpgradeQueryFor (p)); + } + + [Theory] + [InlineData (AppMode.Upgrades, PinFilter.All, "", "All packages are up to date!")] + [InlineData (AppMode.Upgrades, PinFilter.PinnedOnly, "", "No pinned packages with upgrades found.")] + [InlineData (AppMode.Upgrades, PinFilter.UnpinnedOnly, "", "No unpinned packages with upgrades found.")] + [InlineData (AppMode.Installed, PinFilter.All, "", "No packages found.")] + public void EmptyStateMessage_ReflectsModeAndPinFilter (AppMode mode, PinFilter pin, string filter, string expected) + { + AppState state = new (new MockBackend ()) + { + Mode = mode, + PinFilter = pin, + LocalFilter = filter + }; + + Assert.Equal (expected, App.EmptyStateMessage (state)); + } + + [Fact] + public void EmptyStateMessage_WithActiveLocalFilter_ExplainsTheFilter () + { + // An active filter that hides everything must not read "All packages are up to date!". + AppState state = new (new MockBackend ()) + { + Mode = AppMode.Upgrades, + PinFilter = PinFilter.All, + LocalFilter = "nonesuch" + }; + + Assert.Contains ("nonesuch", App.EmptyStateMessage (state), StringComparison.Ordinal); + } + + [Theory] + [InlineData ("Name", SortField.Name)] + [InlineData ("Name ↑", SortField.Name)] + [InlineData ("Id", SortField.Id)] + [InlineData ("Version ↓", SortField.Version)] + public void SortFieldForHeader_MapsSortableColumns (string header, SortField expected) + { + Assert.Equal (expected, App.SortFieldForHeader (header)); + } + + [Theory] + [InlineData (" ")] + [InlineData ("Available")] + [InlineData ("Source")] + public void SortFieldForHeader_ReturnsNullForNonSortableColumns (string header) + { + Assert.Null (App.SortFieldForHeader (header)); + } + [Theory] [InlineData ("=2+5", "'=2+5")] [InlineData (" -hidden formula", "' -hidden formula")] @@ -162,6 +230,34 @@ public void TryNormalizeOpenableUrl_AllowsOnlyHttpSchemes (string input, bool ex } } + [Fact] + public async Task MockBackend_Repair_ReportsRepairingProgressAndSucceeds () + { + CollectingProgress progress = new (); + + OpResult result = await new MockBackend ().RepairAsync ("Git.Git", progress, CancellationToken.None); + + Assert.True (result.Success); + Assert.Equal (OperationKind.Repair, result.Operation.Kind); + Assert.Contains (progress.Samples, s => s.Phase == OpPhase.Repairing); + Assert.Contains (progress.Samples, s => s.Phase == OpPhase.Done); + } + + [Fact] + public void CanRepair_IsTrueForMock_AndFalseForCli () + { + // Repair is COM-only: the mock fakes it for dev iteration; the CLI reports it unavailable. + Assert.True (new MockBackend ().CanRepair); + Assert.False (new CliBackend ().CanRepair); + } + + private sealed class CollectingProgress : IProgress + { + public List Samples { get; } = []; + + public void Report (OpProgress value) => Samples.Add (value); + } + private static DetailPanel CreateDetailPanel () { DetailPanel panel = new () diff --git a/tests/ParserTests.cs b/tests/ParserTests.cs index 3b843e0..7775968 100644 --- a/tests/ParserTests.cs +++ b/tests/ParserTests.cs @@ -793,7 +793,7 @@ public void UpgradeByIdArgs_DoNotIncludeExact_OnlyTheNameFallbackDoes () [Fact] public void ListUpgradesArgs_IncludePinnedFlagIsPresent () { - string [] args = CliBackend.ListUpgradesArgs (SourceFilter.All); + string [] args = CliBackend.ListUpgradesArgs (null); Assert.Contains ("--include-pinned", args); } @@ -802,7 +802,7 @@ public void ListUpgradesArgs_IncludePinnedFlagIsPresent () public void ListInstalledArgs_DoNotInclude_IncludePinned () { // `--include-pinned` is upgrade-only and would error on `list`. - string [] args = CliBackend.ListInstalledArgs (SourceFilter.All); + string [] args = CliBackend.ListInstalledArgs (null); Assert.DoesNotContain ("--include-pinned", args); } @@ -832,14 +832,40 @@ public void PinRemoveArgs_DoNotIncludeInstalledFlag () [Fact] public void SourceFilterArgs_AppendedWhenNotAll () { - string [] withAll = CliBackend.ListInstalledArgs (SourceFilter.All); - string [] withWinget = CliBackend.ListInstalledArgs (SourceFilter.Winget); - string [] withMsStore = CliBackend.ListInstalledArgs (SourceFilter.MsStore); + string [] withAll = CliBackend.ListInstalledArgs (null); + string [] withWinget = CliBackend.ListInstalledArgs ("winget"); + string [] withMsStore = CliBackend.ListInstalledArgs ("msstore"); + string [] withCustom = CliBackend.ListInstalledArgs ("contoso"); Assert.DoesNotContain ("--source", withAll); Assert.Contains ("--source", withWinget); Assert.Equal ("winget", withWinget [Array.IndexOf (withWinget, "--source") + 1]); Assert.Equal ("msstore", withMsStore [Array.IndexOf (withMsStore, "--source") + 1]); + + // A custom/enterprise source name passes straight through (no enum mapping). + Assert.Equal ("contoso", withCustom [Array.IndexOf (withCustom, "--source") + 1]); + } + + [Fact] + public void ParseSourceNames_ExtractsNameColumnIncludingCustomSources () + { + // Representative `winget source list` output with the two predefined sources plus a custom one. + string output = + "Name Argument\n" + + "------------------------------------------------\n" + + "winget https://cdn.winget.microsoft.com/cache\n" + + "msstore https://storeedgefd.dsx.mp.microsoft.com/v9.0\n" + + "contoso https://pkgmgr-int.azureedge.net/cache\n"; + + IReadOnlyList names = CliBackend.ParseSourceNames (output); + + Assert.Equal (["winget", "msstore", "contoso"], names); + } + + [Fact] + public void ParseSourceNames_ReturnsEmptyWhenNoTable () + { + Assert.Empty (CliBackend.ParseSourceNames ("no sources configured\n")); } [Fact]