Skip to content

Commit eeb2b5c

Browse files
harderclaude
andcommitted
fix: COM Verify/activation correctness + TUI polish from Windows COM run
First real interactive COM run on Windows surfaced two COM-backend bugs (latent because the AOT build always fell back to CLI) plus several UX gaps: - ComBackend.ConnectAsync: stop re-setting AcceptSourceAgreements on the *composite* reference (throws E_ILLEGAL_STATE_CHANGE). The source refs already accept it in RemoteRefs; this had blocked every COM search/list/detail the moment COM activated. - ComBackend.VerifyInstalledAsync: CheckInstalledStatus returns a status block per manifest installer, and the installers that aren't the one installed legitimately report 'ARP entry not found' (0x8A150201). Old code flattened all installers and reported Issues on any failure, so healthy multi-installer/portable packages looked corrupt. Now grouped per installer: Ok when any one installer's checks all pass; the dialog shows that clean installer's checks. - Search now reads the composite's correlated InstalledVersion: an installed search row shows a green '✓ Installed' badge and Uninstall/Upgrade actions instead of a bare Install. - Responsive column widths: Name/Id/Version shrink toward minimums on a narrow terminal so the Available column stays visible; reflows on resize via ViewportChanged. - The operation result line ('Done', etc.) now persists through the post-op list reload instead of being masked by 'Loading Installed…'. - Upgrades status bar shows 'Spc Select' / 'U Upgrade sel' to make batch select discoverable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c588012 commit eeb2b5c

5 files changed

Lines changed: 200 additions & 65 deletions

File tree

src/App.cs

Lines changed: 104 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ public App (IBackend backend)
115115
};
116116
_packageTable.Style.ShowHorizontalHeaderUnderline = true;
117117
_packageTable.Style.ExpandLastColumn = true;
118+
119+
// Reflow column widths when the table is resized (e.g. terminal resize) so the Available
120+
// column stays visible on a narrow window instead of being pushed off the right edge.
121+
_packageTable.ViewportChanged += (_, _) => ApplyColumnWidths ();
118122
_listFrame.Add (_packageTable);
119123

120124
_detailPanel = new ()
@@ -324,7 +328,11 @@ private void StopSpinner ()
324328
}
325329
}
326330

327-
private void TriggerRefresh ()
331+
// keepMessage: when a list reload is triggered right after an operation, pass the op's result
332+
// line (e.g. "Done", "Uninstalled X") so the reload keeps showing it — with the spinner as a
333+
// "refreshing" cue — instead of overwriting it with "Loading Installed…" (the slow reload would
334+
// otherwise mask the brief result). Null on a normal refresh, which shows the usual messages.
335+
private void TriggerRefresh (string? keepMessage = null)
328336
{
329337
_viewCts.Cancel ();
330338
_viewCts = new ();
@@ -356,8 +364,11 @@ private void TriggerRefresh ()
356364
}
357365

358366
_state.Loading = true;
359-
_state.StatusMessage = $"Loading {_state.Mode}…";
360-
_state.StatusIsError = false;
367+
_state.StatusMessage = keepMessage ?? $"Loading {_state.Mode}…";
368+
369+
// Keep the op's error styling (set by the caller) when preserving its message; a plain
370+
// refresh is never an error.
371+
_state.StatusIsError = keepMessage is not null && _state.StatusIsError;
361372
RefreshStatusBar ();
362373
SyncTabBar ();
363374

@@ -382,14 +393,20 @@ private void TriggerRefresh ()
382393
_state.Packages = packages.ToList ();
383394
_state.ApplyFilter ();
384395
_state.Loading = false;
385-
int n = _state.Filtered.Count;
386-
_state.StatusMessage = n == 1 ? "1 package" : $"{n} packages";
387396

388-
// A search that hit the result cap means there's more the user can't see;
389-
// nudge them to narrow it (only the COM backend actually caps).
390-
if (mode == AppMode.Search && packages.Count >= AppState.SearchResultLimit)
397+
// Keep the op's result line visible after the reload rather than replacing it
398+
// with a package count, so the user sees what just happened.
399+
if (keepMessage is null)
391400
{
392-
_state.StatusMessage = $"{AppState.SearchResultLimit}+ matches — refine your search to narrow";
401+
int n = _state.Filtered.Count;
402+
_state.StatusMessage = n == 1 ? "1 package" : $"{n} packages";
403+
404+
// A search that hit the result cap means there's more the user can't see;
405+
// nudge them to narrow it (only the COM backend actually caps).
406+
if (mode == AppMode.Search && packages.Count >= AppState.SearchResultLimit)
407+
{
408+
_state.StatusMessage = $"{AppState.SearchResultLimit}+ matches — refine your search to narrow";
409+
}
393410
}
394411
RefreshTable ();
395412
RefreshStatusBar ();
@@ -516,37 +533,8 @@ private void ApplyColumnStyles (MarkedTableSource marked)
516533
Focus = new (Theme.Accent, Theme.Surface, TextStyle.Bold)
517534
};
518535

519-
// Pin column widths by setting MinWidth = MaxWidth. Otherwise TableView's
520-
// CalculateMaxCellWidth scans the visible viewport and recomputes widths every
521-
// frame from the max content width — so when the user presses Down arrow and
522-
// a new row enters the viewport with different content widths, all columns
523-
// shift. Fixed widths avoid that visual jump.
524-
string name = marked.ColumnNames [i];
525-
526-
if (name.StartsWith ("Name", StringComparison.Ordinal))
527-
{
528-
s.MinWidth = 24;
529-
s.MaxWidth = 24;
530-
}
531-
else if (name.StartsWith ("Id", StringComparison.Ordinal))
532-
{
533-
s.MinWidth = 28;
534-
s.MaxWidth = 28;
535-
}
536-
else if (name.StartsWith ("Version", StringComparison.Ordinal))
537-
{
538-
s.MinWidth = 14;
539-
s.MaxWidth = 14;
540-
}
541-
else if (name == "Available")
542-
{
543-
s.MinWidth = 14;
544-
s.MaxWidth = 14;
545-
}
546-
else if (name == "Source")
536+
if (marked.ColumnNames [i] == "Source")
547537
{
548-
s.MinWidth = 8;
549-
s.MaxWidth = 8;
550538
s.ColorGetter = args =>
551539
{
552540
string val = args.CellValue?.ToString () ?? string.Empty;
@@ -568,6 +556,81 @@ private void ApplyColumnStyles (MarkedTableSource marked)
568556
};
569557
}
570558
}
559+
560+
// Pin per-column widths (MinWidth = MaxWidth). Otherwise TableView's CalculateMaxCellWidth
561+
// recomputes widths every frame from the visible rows' content, so scrolling jumps columns
562+
// around. Width depends on the table's current size, so re-run on resize (ViewportChanged).
563+
ApplyColumnWidths (force: true);
564+
}
565+
566+
private int _lastColumnLayoutWidth = -1;
567+
568+
/// <summary>
569+
/// Sizes the data columns to the table's current width. Name/Id/Version shrink toward minimums
570+
/// when the terminal is narrow so the <b>Available</b> column stays visible — it sits just
571+
/// before the expanding Source column and is otherwise the first to be pushed off-screen, so a
572+
/// user on a small window can't tell it exists. Wired to ViewportChanged to reflow on resize.
573+
/// </summary>
574+
private void ApplyColumnWidths (bool force = false)
575+
{
576+
ITableSource? table = _packageTable.Table;
577+
578+
if (table is null)
579+
{
580+
return;
581+
}
582+
583+
int avail = _packageTable.Viewport.Width;
584+
585+
if (avail <= 0 || (!force && avail == _lastColumnLayoutWidth))
586+
{
587+
return;
588+
}
589+
590+
_lastColumnLayoutWidth = avail;
591+
592+
string [] names = table.ColumnNames;
593+
594+
// Preferred widths, and how far each may shrink. Available/Source are not shrunk so they
595+
// survive; Source is the ExpandLastColumn target and fills whatever remains.
596+
int nameW = 24, idW = 28, verW = 14;
597+
const int availW = 14, sourceW = 8, srcReserve = 6;
598+
const int nameMin = 14, idMin = 16, verMin = 9;
599+
600+
bool hasAvailable = names.Contains ("Available");
601+
int dataCols = Math.Max (0, names.Length - 1); // exclude the 1-wide marker column
602+
603+
// Reserve the marker, rough inter-column padding, and a minimum for the expanding Source
604+
// column, then shrink Id → Name → Version (in that order) to fit Name+Id+Version+Available.
605+
int budget = avail - 1 - (dataCols + 1) - srcReserve;
606+
int deficit = nameW + idW + verW + (hasAvailable ? availW : 0) - budget;
607+
608+
if (deficit > 0) { int c = Math.Min (deficit, idW - idMin); idW -= c; deficit -= c; }
609+
if (deficit > 0) { int c = Math.Min (deficit, nameW - nameMin); nameW -= c; deficit -= c; }
610+
if (deficit > 0) { int c = Math.Min (deficit, verW - verMin); verW -= c; deficit -= c; }
611+
612+
for (int i = 1; i < names.Length; i++)
613+
{
614+
string name = names [i];
615+
616+
int? w = name.StartsWith ("Name", StringComparison.Ordinal) ? nameW
617+
: name.StartsWith ("Id", StringComparison.Ordinal) ? idW
618+
: name.StartsWith ("Version", StringComparison.Ordinal) ? verW
619+
: name == "Available" ? availW
620+
: name == "Source" ? sourceW
621+
: null;
622+
623+
if (w is null)
624+
{
625+
continue;
626+
}
627+
628+
ColumnStyle s = _packageTable.Style.GetOrCreateColumnStyle (i);
629+
s.MinWidth = w.Value;
630+
s.MaxWidth = w.Value;
631+
}
632+
633+
_packageTable.SetNeedsDraw ();
571634
}
572635

573636
private string HeaderWithSort (string label, SortField field)
@@ -1833,7 +1896,7 @@ private void AskBatchUpgrade ()
18331896
_state.StatusIsError = false;
18341897
}
18351898

1836-
TriggerRefresh ();
1899+
TriggerRefresh (_state.StatusMessage);
18371900
});
18381901
});
18391902
}
@@ -1904,7 +1967,7 @@ private void RunOperation (string activity, Func<IProgress<OpProgress>, Cancella
19041967
}
19051968
}
19061969

1907-
TriggerRefresh ();
1970+
TriggerRefresh (_state.StatusMessage);
19081971
});
19091972
});
19101973
}

src/ComBackend.cs

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,21 @@ public async Task<IReadOnlyList<Package>> SearchAsync (string query, string? sou
9090
CatalogPackage pkg = m.CatalogPackage;
9191
string version = SafeVersion (SafeDefaultInstallVersion (pkg)) ?? LatestAvailableVersion (pkg) ?? string.Empty;
9292

93+
// The search composite (RemotePackagesFromRemoteCatalogs) correlates installed
94+
// status, so a search row knows whether it's installed and whether an upgrade is
95+
// available — surfaced so the UI can offer Uninstall/Upgrade rather than Install.
96+
string? installedVersion = SafeVersion (SafeInstalledVersion (pkg));
97+
bool updateAvailable = installedVersion is not null && SafeIsUpdateAvailable (pkg);
98+
9399
packages.Add (new ()
94100
{
95101
Id = pkg.Id,
96102
Name = pkg.Name,
97103
Version = version,
98104
Source = SourceOf (pkg),
99-
MatchField = NotableMatchField (m)
105+
MatchField = NotableMatchField (m),
106+
InstalledVersion = installedVersion,
107+
AvailableVersion = updateAvailable ? LatestAvailableVersion (pkg) : null
100108
});
101109
}
102110
catch
@@ -233,6 +241,7 @@ private async Task<IReadOnlyList<Package>> ListLocalAsync (string? source, bool
233241
Name = Coalesce (meta?.PackageName, pkg.Name) ?? pkg.Id,
234242
Version = SafeVersion (SafeInstalledVersion (pkg)) ?? SafeVersion (versionInfo) ?? string.Empty,
235243
AvailableVersion = LatestAvailableVersion (pkg),
244+
InstalledVersion = SafeVersion (installed),
236245
Source = SourceOf (pkg),
237246
Publisher = NullIfEmpty (meta?.Publisher),
238247
Author = NullIfEmpty (meta?.Author),
@@ -615,8 +624,15 @@ public async Task<OpResult> RepairAsync (string id, IProgress<OpProgress>? progr
615624
return new () { Outcome = VerifyOutcome.Error };
616625
}
617626

618-
List<VerifyCheck> checks = [];
619-
bool anyFailed = false;
627+
// CheckInstalledStatus returns one status block PER installer in the package's manifest
628+
// (x64/arm64/x86 × user/machine, the portable variant, etc.). Only the installer that's
629+
// actually present passes its checks; the others legitimately report "Apps & Features
630+
// entry not found" (0x8A150201) and the like. So evaluate each installer independently
631+
// and treat the package as installed correctly when ANY single installer's checks all
632+
// pass — rather than flagging the package because some *other* manifest installer (which
633+
// was never installed) didn't match. (The old code flattened all installers and reported
634+
// Issues if any one check failed, so multi-installer packages always looked corrupt.)
635+
List<List<VerifyCheck>> perInstaller = [];
620636
bool hadReadError = false;
621637

622638
// Two nested projected vectors — indexed via Materialize (AOT rule).
@@ -635,6 +651,8 @@ public async Task<OpResult> RepairAsync (string id, IProgress<OpProgress>? progr
635651
continue;
636652
}
637653

654+
List<VerifyCheck> checks = [];
655+
638656
foreach (InstalledStatus entry in entries)
639657
{
640658
try
@@ -643,30 +661,35 @@ public async Task<OpResult> RepairAsync (string id, IProgress<OpProgress>? progr
643661
bool ok = entry.Status is null;
644662
string? path = NullIfEmpty (entry.Path);
645663
checks.Add (new (StatusTypeName (entry.Type), ok, ok ? path : Coalesce (path, $"hr 0x{HResultOf (entry.Status):X8}")));
646-
647-
if (!ok)
648-
{
649-
anyFailed = true;
650-
}
651664
}
652665
catch
653666
{
654667
hadReadError = true;
655668
}
656669
}
670+
671+
if (checks.Count > 0)
672+
{
673+
perInstaller.Add (checks);
674+
}
657675
}
658676

659-
// A confirmed failed check → Issues. Otherwise, if any read errored we can't honestly
660-
// claim the install is clean, so report Error rather than Ok/NotApplicable.
661-
VerifyOutcome outcome = anyFailed
662-
? VerifyOutcome.Issues
663-
: hadReadError
664-
? VerifyOutcome.Error
665-
: checks.Count == 0
666-
? VerifyOutcome.NotApplicable
667-
: VerifyOutcome.Ok;
677+
if (perInstaller.Count == 0)
678+
{
679+
// No installer yielded a readable check: can't honestly verify if a read errored.
680+
return new () { Outcome = hadReadError ? VerifyOutcome.Error : VerifyOutcome.NotApplicable };
681+
}
682+
683+
// Best-matching installer = the one with the fewest failing checks. If it has none, the
684+
// package is installed correctly and we show that installer's clean checks; otherwise we
685+
// surface the closest installer so the user sees the most relevant failures.
686+
List<VerifyCheck> best = perInstaller.OrderBy (cs => cs.Count (c => !c.Ok)).First ();
668687

669-
return new () { Outcome = outcome, Checks = checks };
688+
return new ()
689+
{
690+
Outcome = best.TrueForAll (c => c.Ok) ? VerifyOutcome.Ok : VerifyOutcome.Issues,
691+
Checks = best
692+
};
670693
}
671694
catch
672695
{
@@ -937,7 +960,11 @@ private PackageCatalogReference CompositeRef (List<PackageCatalogReference> refs
937960

938961
private static async Task<PackageCatalog> ConnectAsync (PackageCatalogReference reference, CancellationToken ct)
939962
{
940-
reference.AcceptSourceAgreements = true;
963+
// NOTE: do NOT set AcceptSourceAgreements here. Every reference passed in is a *composite*
964+
// (from CompositeRef), and setting AcceptSourceAgreements on a composite reference throws
965+
// E_ILLEGAL_STATE_CHANGE. The API-correct place is each *source* reference before it's
966+
// composited — RemoteRefs already does that. (This only surfaced once COM actually
967+
// activated; under AOT the backend silently fell back to CLI, so the path was never run.)
941968
ConnectResult result = await reference.ConnectAsync ().AsTask (ct);
942969

943970
if (result.Status != ConnectResultStatus.Ok || result.PackageCatalog is null)

src/DetailPanel.cs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ public void SetDetail (PackageDetail? detail, bool loading)
6868
AddKv ("Available", detail.AvailableVersion, ValueScheme.Success);
6969
}
7070

71+
// On the Search tab a row can be one the user already has installed — make that obvious
72+
// (the Installed/Upgrades tabs are installed by definition, so the badge is search-only).
73+
if (Mode == AppMode.Search && !string.IsNullOrEmpty (detail.InstalledVersion))
74+
{
75+
AddSingle ($"✓ Installed ({detail.InstalledVersion})", Theme.Success, TextStyle.Bold);
76+
}
77+
7178
if (!string.IsNullOrEmpty (detail.Publisher))
7279
{
7380
AddKv ("Publisher", detail.Publisher);
@@ -201,8 +208,24 @@ public void SetDetail (PackageDetail? detail, bool loading)
201208
switch (Mode)
202209
{
203210
case AppMode.Search:
204-
AddAction ("i", "Install");
205-
AddAction ("I", "Install specific version");
211+
if (!string.IsNullOrEmpty (detail.InstalledVersion))
212+
{
213+
// Already installed: offer manage actions instead of a bare Install. Show
214+
// Upgrade only when the catalog's latest differs from what's installed.
215+
if (!string.IsNullOrEmpty (detail.AvailableVersion)
216+
&& !string.Equals (detail.AvailableVersion, detail.InstalledVersion, StringComparison.OrdinalIgnoreCase))
217+
{
218+
AddAction ("u", "Upgrade");
219+
}
220+
221+
AddAction ("x", "Uninstall");
222+
AddAction ("I", "Install specific version");
223+
}
224+
else
225+
{
226+
AddAction ("i", "Install");
227+
AddAction ("I", "Install specific version");
228+
}
206229

207230
break;
208231
case AppMode.Installed:

0 commit comments

Comments
 (0)