src/UI/20-Submenu.ps1
|
# 20-Submenu.ps1 — per-module actions: details, load/unload/install, version # pinning, duplicate cleanup, auth status/disconnect (#32), add-unmanaged # (#27), edit/delete/move, command browsing. # Build the details panel for one entry. $Auth may be $null (not yet queried). function script:Build-PSMMModuleMenuView { param( [Parameter(Mandatory)] $Entry, $Auth, [string]$StatusMarkup ) $isUnmanaged = [bool]$Entry.PSObject.Properties['Unmanaged'] $issues = if ($Entry.Issues.Count) { ConvertTo-PSMMSafe ($Entry.Issues -join '; ') } else { 'none' } $srcLabel = if ($isUnmanaged) { 'not in any config file (unmanaged)' } else { "$(ConvertTo-PSMMSafe $Entry.Source) ($(if ($Entry.Writable) { 'writable' } else { 'read-only' }))" } $installedTxt = if ($Entry.Installed) { $vers = @($Entry.InstalledVersions) $extra = if ($vers.Count -gt 1) { " [orange1]($($vers.Count) versions on disk - K cleans up)[/]" } else { '' } $upd = if ($Entry.UpdateAvailable) { " [orange1](update -> v$($Entry.LatestVersion))[/]" } else { '' } "yes v$($Entry.InstalledVersion) [grey66]$($Entry.InstallScope)[/]$upd$extra" } else { 'no' } $pinTxt = if ($Entry.Version) { "$($Entry.Version)$(if ($Entry.PinnedExact) { ' (exact)' } else { ' (range)' })" } else { '-' } $rows = @( , @('Name ', (ConvertTo-PSMMSafe $Entry.Name)) , @('Description ', $(if ([string]::IsNullOrWhiteSpace($Entry.Description)) { '-' } else { ConvertTo-PSMMSafe $Entry.Description })) , @('Source ', $srcLabel) , @('Install/Mode', "$($Entry.Install) / $($Entry.Mode)") , @('Version pin ', $pinTxt) , @('Installed ', $installedTxt) , @('Loaded ', $(if ($Entry.Loaded) { "yes v$($Entry.LoadedVersion)$(if ($null -ne $Entry.ImportMs) { " [grey66]import took $($Entry.ImportMs) ms[/]" })" } else { 'no' })) , @('Issues ', $issues) ) if ($Auth) { $authTxt = if (-not $Auth.Supported) { '-' } elseif ($Auth.Connected) { "[green3]connected[/] $(ConvertTo-PSMMSafe $Auth.Account)$(if ($Auth.Detail) { " [grey66]$(ConvertTo-PSMMSafe $Auth.Detail)[/]" })" } else { '[grey66]not connected[/]' } $rows += , @('Connection ', $authTxt) } $items = [System.Collections.Generic.List[Spectre.Console.Rendering.IRenderable]]::new() $rule = [Spectre.Console.Rule]::new("[$script:PSMM_ColAccent]$(ConvertTo-PSMMSafe $Entry.FriendlyName)[/]") $rule.Style = [Spectre.Console.Style]::Parse($script:PSMM_ColAccent) $items.Add($rule) $panel = [Spectre.Console.Panel]::new((New-PSMMDetailGrid -Rows $rows)) $panel.Header = [Spectre.Console.PanelHeader]::new('details') $panel.Border = [Spectre.Console.BoxBorder]::Rounded $panel.BorderStyle = [Spectre.Console.Style]::Parse($script:PSMM_ColMute) $items.Add($panel) $pairs = @('L=load', 'U=unload', 'P=install/update', 'B=browse commands') if ($Entry.Installed -and @($Entry.InstalledVersions).Count -gt 1) { $pairs += 'K=clean old versions' } if ($isUnmanaged) { $pairs += 'A=add to config' } elseif ($Entry.Writable -and $Entry.Source -ne '<profile inline>') { $pairs += @('E=edit', 'V=pin version', 'D=delete', 'M=move to file') } if ($Auth -and $Auth.Supported) { $pairs += 'I=re-check connection' if ($Auth.Connected) { $pairs += 'O=disconnect' } } elseif (Get-PSMMAuthProvider -ModuleName $Entry.Name) { $pairs += 'I=check connection' } $pairs += @('?=help', 'esc=back', 'Ctrl+Q=quit') $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs $pairs))) if ($StatusMarkup) { $items.Add([Spectre.Console.Markup]::new($StatusMarkup)) } [Spectre.Console.Rows]::new($items) } function script:Show-PSMMModuleMenu { param([Parameter(Mandatory)] $Entry) $ui = $script:PSMM_UI $isUnmanaged = [bool]$Entry.PSObject.Properties['Unmanaged'] $auth = $null # cheap providers get an automatic status check when the module is loaded; # slow ones (network round-trip) only on the explicit I key $provider = Get-PSMMAuthProvider -ModuleName $Entry.Name if ($provider -and -not $provider.Slow) { $auth = Get-PSMMConnectionStatus -ModuleName $Entry.Name } $status = '' while ($true) { if ($ui.HardQuit) { return } Clear-PSMMScreen Write-PSMMRenderable (Build-PSMMModuleMenuView -Entry $Entry -Auth $auth -StatusMarkup $status) $status = '' $k = [Console]::ReadKey($true) if (Test-PSMMHardQuitKey $k) { $ui.HardQuit = $true; return } switch ($k.Key) { ([ConsoleKey]::L) { Write-PSMMLine "[$script:PSMM_ColAccent]loading $(ConvertTo-PSMMSafe $Entry.Name)...[/]" try { Import-PSMMModuleTimed -Entry $Entry $status = "[green3]loaded ($($Entry.ImportMs) ms)[/]" } catch { $status = "[indianred1]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" } Update-PSMMLoaded -Entries $ui.Entries } ([ConsoleKey]::U) { Write-PSMMLine "[$script:PSMM_ColAccent]unloading $(ConvertTo-PSMMSafe $Entry.Name)...[/]" try { Remove-Module -Name $Entry.Name -Force -ErrorAction Stop; $status = '[green3]unloaded[/]' } catch { $status = "[indianred1]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" } Update-PSMMLoaded -Entries $ui.Entries } ([ConsoleKey]::P) { Clear-PSMMScreen Write-PSMMLine "[$script:PSMM_ColAccent]installing/updating $(ConvertTo-PSMMSafe $Entry.Name)... (this can take a while)[/]" try { Install-PSMMModule -Name $Entry.Name -Update:([bool]$Entry.Installed) -Version $Entry.Version $status = '[green3]install/update done[/]' } catch { $status = "[indianred1]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" } Update-PSMMAvailable -Entries $ui.Entries -Name $Entry.Name } ([ConsoleKey]::B) { Show-PSMMCommands -Entry $Entry } ([ConsoleKey]::K) { if ($Entry.Installed -and @($Entry.InstalledVersions).Count -gt 1) { $status = Invoke-PSMMVersionCleanup -Entry $Entry Update-PSMMAvailable -Entries $ui.Entries -Name $Entry.Name } } ([ConsoleKey]::I) { if ($provider) { Write-PSMMLine "[$script:PSMM_ColAccent]checking connection status$(if ($provider.Slow) { ' (network - may take a moment)' })...[/]" $auth = Get-PSMMConnectionStatus -ModuleName $Entry.Name } } ([ConsoleKey]::O) { if ($auth -and $auth.Connected) { Write-PSMMLine "[$script:PSMM_ColAccent]disconnecting $(ConvertTo-PSMMSafe $Entry.Name)...[/]" try { Disconnect-PSMMModule -ModuleName $Entry.Name $status = '[green3]disconnected[/]' $auth = Get-PSMMConnectionStatus -ModuleName $Entry.Name } catch { $status = "[indianred1]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" } } } ([ConsoleKey]::A) { if ($isUnmanaged) { if (Add-PSMMUnmanagedEntry -Entry $Entry) { return } } } ([ConsoleKey]::E) { if (-not $isUnmanaged -and $Entry.Writable) { Edit-PSMMEntry -Entry $Entry; return } } ([ConsoleKey]::V) { if (-not $isUnmanaged -and $Entry.Writable) { if (Set-PSMMEntryPin -Entry $Entry) { return } } } ([ConsoleKey]::D) { if (-not $isUnmanaged -and $Entry.Writable) { if (Remove-PSMMEntryUI -Entry $Entry) { return } } } ([ConsoleKey]::M) { if (-not $isUnmanaged -and $Entry.Writable -and $Entry.Source -ne '<profile inline>') { if (Move-PSMMEntryUI -Entry $Entry) { return } } } ([ConsoleKey]::Escape) { return } ([ConsoleKey]::LeftArrow) { return } # "move out" (#24) ([ConsoleKey]::RightArrow) { Show-PSMMCommands -Entry $Entry } # "move into" commands (#24) default { if ($k.KeyChar -eq '?') { Show-PSMMHelpScreen -Topic 'module' } } } } } # Prune all but the newest installed version of a module (#30-lite, research # "Clean Up"). Respects scope: AllUsers copies need elevation. function script:Invoke-PSMMVersionCleanup { param([Parameter(Mandatory)] $Entry) $ui = $script:PSMM_UI $obsolete = @($Entry.InstalledVersions | Sort-Object { [version]"$($_.Version)" } -Descending | Select-Object -Skip 1) if (-not $obsolete.Count) { return '[grey66]nothing to clean[/]' } $blocked = @($obsolete | Where-Object { $_.Scope -eq 'AllUsers' -and -not $ui.Elevated }) $doable = @($obsolete | Where-Object { $_.Scope -ne 'AllUsers' -or $ui.Elevated }) Clear-PSMMScreen Write-PSMMLine "[$script:PSMM_ColAccent]Clean up old versions of $(ConvertTo-PSMMSafe $Entry.Name)[/]" Write-PSMMLine "keeping [green3]v$($Entry.InstalledVersion)[/], removing: $(($doable | ForEach-Object { "v$($_.Version)" }) -join ', ')" if ($blocked.Count) { Write-PSMMLine "[orange1]skipping $(@($blocked).Count) AllUsers version(s) - session is not elevated[/]" } if (-not $doable.Count) { $null = Wait-PSMMKey; return '[orange1]nothing removable without elevation[/]' } if (-not (Read-SpectreConfirm -Message "Remove $($doable.Count) old version(s)?" -DefaultAnswer 'n')) { return '[grey66]cleanup cancelled[/]' } $ok = 0; $failed = 0 foreach ($v in $doable) { Write-PSMMLine "[$script:PSMM_ColAccent]removing v$($v.Version)...[/]" try { Uninstall-PSMMModuleVersion -Name $Entry.Name -Version "$($v.Version)"; $ok++ } catch { $failed++; Write-PSMMLine "[indianred1] v$($v.Version): $(ConvertTo-PSMMSafe $_.Exception.Message)[/]" } } if ($failed) { "[orange1]removed $ok, $failed failed[/]" } else { "[green3]removed $ok old version(s)[/]" } } # Pin (or unpin) an entry's version and save (#research: version pinning). # Returns $true when the config changed (caller reloads). function script:Set-PSMMEntryPin { param([Parameter(Mandatory)] $Entry) Clear-PSMMScreen Write-PSMMLine "[$script:PSMM_ColAccent]Pin $(ConvertTo-PSMMSafe $Entry.Name) to a version[/]" Write-PSMMLine "[grey66]exact '1.2.3' or NuGet range '[[1.0,2.0)'; empty removes the pin[/]" $v = Read-SpectreText -Message 'Version' -DefaultAnswer ($Entry.Version ?? '') -AllowEmpty $v = "$v".Trim() if ($v -eq "$($Entry.Version)") { return $false } $probe = Resolve-PSMMEntry -Raw ([pscustomobject]@{ Name = $Entry.Name; Version = $v }) -Source $Entry.Source -Writable $true if ($v -and -not $probe.Version) { Write-PSMMLine "[indianred1]'$(ConvertTo-PSMMSafe $v)' is not a valid version or range - nothing saved[/]" $null = Wait-PSMMKey return $false } $Entry.Version = $probe.Version $Entry.PinnedExact = $probe.PinnedExact Save-PSMMFile -Path $Entry.Source -Entries (Get-PSMMAllEntries) $script:PSMM_UI.Dirty = $true $true } # Writable targets for adding an entry; when none exist, offer to create the # main config on the spot (2026-07-05 live-run feedback) instead of a dead # end. Save-PSMMFile creates the file itself - only the directory must exist. function script:Get-PSMMAddTargets { $meta = Get-PSMMFileMeta $targets = @($meta.Values | Where-Object { $_.Writable -and $_.Kind -ne 'inline' } | Select-Object -ExpandProperty Path) if ($targets.Count) { return $targets } $main = Get-PSMMMainConfigPath Write-PSMMLine '[orange1]No writable config file yet.[/]' if (-not (Read-SpectreConfirm -Message "Create $(ConvertTo-PSMMSafe $main) now?" -DefaultAnswer 'y')) { return @() } $dir = Split-Path -Parent $main if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } @($main) } # Add an unmanaged module to a writable config file (#27). function script:Add-PSMMUnmanagedEntry { param([Parameter(Mandatory)] $Entry) Clear-PSMMScreen Write-PSMMLine "[$script:PSMM_ColAccent]Add $(ConvertTo-PSMMSafe $Entry.Name) to a config file[/]" $targets = @(Get-PSMMAddTargets) if (-not $targets.Count) { return $false } $target = if ($targets.Count -eq 1) { $targets[0] } else { Read-SpectreSelection -Message 'Add to which file?' -Choices $targets -Color $script:PSMM_ColAccent } if (-not $target) { return $false } $install = Read-SpectreSelection -Message 'Install policy' -Choices 'IfMissing', 'CheckOnly', 'Latest' -Color $script:PSMM_ColAccent $mode = Read-SpectreSelection -Message 'Mode' -Choices 'Load', 'InstallOnly', 'Ignore' -Color $script:PSMM_ColAccent $new = Resolve-PSMMEntry -Raw ([pscustomobject]@{ Name = $Entry.Name; Description = $Entry.Description; Install = $install; Mode = $mode }) -Source $target -Writable $true $new | Add-Member -NotePropertyName FromMain -NotePropertyValue ($target -eq (Get-PSMMMainConfigPath)) -Force $new | Add-Member -NotePropertyName FileEnabled -NotePropertyValue $true -Force Add-PSMMAllEntry -Entry $new Save-PSMMFile -Path $target -Entries (Get-PSMMAllEntries) Write-PSMMLine "[green3]added to $(ConvertTo-PSMMSafe (Split-Path $target -Leaf)) - it is now managed[/]" $script:PSMM_UI.Dirty = $true $null = Wait-PSMMKey $true } # Edit an entry's fields and save. function script:Edit-PSMMEntry { param([Parameter(Mandatory)] $Entry) if (-not $Entry.Writable) { return } Clear-PSMMScreen Write-PSMMLine "[$script:PSMM_ColAccent]Edit $(ConvertTo-PSMMSafe $Entry.Name)[/]" $Entry.Name = Read-SpectreText -Message 'Name' -DefaultAnswer $Entry.Name $Entry.FriendlyName = Read-SpectreText -Message 'Friendly name' -DefaultAnswer ($Entry.FriendlyName ?? '') -AllowEmpty $Entry.Description = Read-SpectreText -Message 'Description' -DefaultAnswer ($Entry.Description ?? '') -AllowEmpty $Entry.Install = Read-SpectreSelection -Message 'Install policy' -Choices (@($Entry.Install) + (@('IfMissing', 'CheckOnly', 'Latest') | Where-Object { $_ -ne $Entry.Install })) -Color $script:PSMM_ColAccent $Entry.Mode = Read-SpectreSelection -Message 'Mode' -Choices (@($Entry.Mode) + (@('Load', 'InstallOnly', 'Ignore') | Where-Object { $_ -ne $Entry.Mode })) -Color $script:PSMM_ColAccent Save-PSMMFile -Path $Entry.Source -Entries (Get-PSMMAllEntries) $script:PSMM_UI.Dirty = $true } # Create a brand-new entry (grid key 'a'). function script:New-PSMMEntry { Clear-PSMMScreen Write-PSMMLine "[$script:PSMM_ColAccent]New entry[/]" $targets = @(Get-PSMMAddTargets) if (-not $targets.Count) { $script:PSMM_UI.Status = '[grey66]add cancelled - no config file[/]'; return } $target = if ($targets.Count -eq 1) { $targets[0] } else { Read-SpectreSelection -Message 'Add to which file?' -Choices $targets -Color $script:PSMM_ColAccent } $name = Read-SpectreText -Message 'Module name' if ([string]::IsNullOrWhiteSpace($name)) { return } $friendly = Read-SpectreText -Message 'Friendly name' -AllowEmpty $desc = Read-SpectreText -Message 'Description' -AllowEmpty $install = Read-SpectreSelection -Message 'Install policy' -Choices 'IfMissing', 'CheckOnly', 'Latest' -Color $script:PSMM_ColAccent $mode = Read-SpectreSelection -Message 'Mode' -Choices 'Load', 'InstallOnly', 'Ignore' -Color $script:PSMM_ColAccent $new = Resolve-PSMMEntry -Raw ([pscustomobject]@{ Name = $name; FriendlyName = $friendly; Description = $desc; Install = $install; Mode = $mode }) -Source $target -Writable $true $new | Add-Member -NotePropertyName FromMain -NotePropertyValue ($target -eq (Get-PSMMMainConfigPath)) -Force $new | Add-Member -NotePropertyName FileEnabled -NotePropertyValue $true -Force Add-PSMMAllEntry -Entry $new Save-PSMMFile -Path $target -Entries (Get-PSMMAllEntries) $script:PSMM_UI.Dirty = $true } # Delete an entry (with confirm). Returns $true when deleted. function script:Remove-PSMMEntryUI { param([Parameter(Mandatory)] $Entry) if (-not $Entry.Writable) { return $false } if (-not (Read-SpectreConfirm -Message "Delete '$($Entry.Name)' from $(Split-Path $Entry.Source -Leaf)?" -DefaultAnswer 'n')) { return $false } Set-PSMMAllEntries -Entries @(Get-PSMMAllEntries | Where-Object { -not ($_.Source -eq $Entry.Source -and $_.Name -eq $Entry.Name) }) Save-PSMMFile -Path $Entry.Source -Entries (Get-PSMMAllEntries) $script:PSMM_UI.Dirty = $true return $true } # Move one entry to another writable config file. function script:Move-PSMMEntryUI { param([Parameter(Mandatory)] $Entry) if (-not $Entry.Writable -or $Entry.Source -eq '<profile inline>') { return $false } $meta = Get-PSMMFileMeta $targets = @($meta.Values | Where-Object { $_.Writable -and $_.Kind -ne 'inline' -and $_.Path -ne $Entry.Source } | Select-Object -ExpandProperty Path) if (-not $targets.Count) { Write-PSMMLine '[orange1]No other writable config file to move to (create one via f -> n).[/]' $null = Wait-PSMMKey return $false } Clear-PSMMScreen Write-PSMMLine "[$script:PSMM_ColAccent]Move $(ConvertTo-PSMMSafe $Entry.Name)[/]" $target = if ($targets.Count -eq 1) { $targets[0] } else { Read-SpectreSelection -Message 'Move to which file?' -Choices $targets -Color $script:PSMM_ColAccent } if (-not $target) { return $false } $oldSource = $Entry.Source $Entry.Source = $target Save-PSMMFile -Path $oldSource -Entries (Get-PSMMAllEntries) # removes it there Save-PSMMFile -Path $target -Entries (Get-PSMMAllEntries) # adds it here Write-PSMMLine "[green3]Moved to $(ConvertTo-PSMMSafe (Split-Path $target -Leaf)).[/]" $script:PSMM_UI.Dirty = $true $null = Wait-PSMMKey return $true } |