Merge pull request #4 from christophe-duc/feature/sort_pods_containers

added expand/collapse feature
This commit is contained in:
Christophe 2026-01-07 19:43:29 -04:00 committed by GitHub
commit 3deb0f1d3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 255 additions and 47 deletions

View file

@ -149,15 +149,22 @@ GetEvents() - Streaming (socket) or polling (libpod)
- Custom commands configurable via YAML
- Command templates use Go template syntax
### GUI Features
- **Pod expand/collapse**: Pods can be expanded/collapsed in the containers panel. State tracked in `gui.State.ExpandedPods` map
- **Container sorting**: Controlled by `LegacySortContainers` config option:
- `false` (default): Sort by state (running → exited → created), then alphabetically
- `true`: Sort alphabetically by name only
- **Container list items**: `ContainerListItem` wraps both pods and containers for unified list display
## Testing
```bash
# Run all tests with coverage
./test.sh
# Run specific package tests
go test -mod=vendor ./pkg/commands/...
go test -mod=vendor ./pkg/gui/...
# Run specific package tests (requires build tags to avoid CGO dependencies)
go test -tags=containers_image_openpgp,exclude_graphdriver_btrfs -mod=vendor ./pkg/gui/...
go test -tags=containers_image_openpgp,exclude_graphdriver_btrfs -mod=vendor ./pkg/commands/...
```
## Module Info

View file

@ -19,6 +19,8 @@ A simple terminal UI for Podman and podman-compose, written in Go with the [gocu
Again, this is a fork! and probably with reduced functionality as the original from Jesse Duffield. It was created to resolve a simple problem, work fully with podman and don't depend on how docker works and the socket.
Lazypodman DOES support pods
This is published as is. Compilation works and lazypodman runs on Linux without needing a socket present to monitor your containers.
Original elevator pitch below:

View file

@ -27,6 +27,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>c</kbd>: führe vordefinierten benutzerdefinierten Befehl aus
<kbd>b</kbd>: view bulk commands
<kbd>w</kbd>: open in browser (first port is http)
<kbd>space</kbd>: Pod erweitern/reduzieren
<kbd>enter</kbd>: fokussieren aufs Hauptpanel
<kbd>[</kbd>: vorheriges Tab
<kbd>]</kbd>: nächstes Tab

View file

@ -27,6 +27,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>c</kbd>: run predefined custom command
<kbd>b</kbd>: view bulk commands
<kbd>w</kbd>: open in browser (first port is http)
<kbd>space</kbd>: expand/collapse pod
<kbd>enter</kbd>: focus main panel
<kbd>[</kbd>: previous tab
<kbd>]</kbd>: next tab

View file

@ -27,6 +27,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>c</kbd>: ejecutar comando personalizado
<kbd>b</kbd>: ver comandos masivos
<kbd>w</kbd>: abrir en navegador (first port is http)
<kbd>space</kbd>: expandir/colapsar pod
<kbd>enter</kbd>: enfocar panel principal
<kbd>[</kbd>: anterior pestaña
<kbd>]</kbd>: siguiente pestaña

View file

@ -27,6 +27,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>c</kbd>: exécuter une commande prédéfinie
<kbd>b</kbd>: voir les commandes groupées
<kbd>w</kbd>: ouvrir dans le navigateur (le premier port est http)
<kbd>space</kbd>: ouvrir/réduire les conteneurs du pod
<kbd>enter</kbd>: focus panneau principal
<kbd>[</kbd>: onglet précédent
<kbd>]</kbd>: onglet suivant

View file

@ -27,6 +27,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>c</kbd>: draai een vooraf bedacht aangepaste opdracht
<kbd>b</kbd>: view bulk commands
<kbd>w</kbd>: open in browser (first port is http)
<kbd>space</kbd>: pod uitvouwen/invouwen
<kbd>enter</kbd>: focus hoofdpaneel
<kbd>[</kbd>: vorige tab
<kbd>]</kbd>: volgende tab

View file

@ -27,6 +27,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>c</kbd>: wykonaj predefiniowaną własną komende
<kbd>b</kbd>: view bulk commands
<kbd>w</kbd>: open in browser (first port is http)
<kbd>space</kbd>: rozwiń/zwiń pod
<kbd>enter</kbd>: skup na głównym panelu
<kbd>[</kbd>: poprzednia zakładka
<kbd>]</kbd>: następna zakładka

View file

@ -27,6 +27,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>c</kbd>: executar comando personalizado predefinido
<kbd>b</kbd>: ver comandos em massa
<kbd>w</kbd>: abrir no navegador (primeira porta é http)
<kbd>space</kbd>: expandir/recolher pod
<kbd>enter</kbd>: focar no painel principal
<kbd>[</kbd>: aba anterior
<kbd>]</kbd>: próxima aba

View file

@ -27,6 +27,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>c</kbd>: önceden tanımlanmış özel komutu çalıştır
<kbd>b</kbd>: view bulk commands
<kbd>w</kbd>: open in browser (first port is http)
<kbd>space</kbd>: pod'u genişlet/daralt
<kbd>enter</kbd>: ana panele odaklan
<kbd>[</kbd>: önceki sekme
<kbd>]</kbd>: sonraki sekme

View file

@ -27,6 +27,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
<kbd>c</kbd>: 运行预定义的自定义命令
<kbd>b</kbd>: 查看批量命令
<kbd>w</kbd>: 在浏览器中打开(第一个端口为http)
<kbd>space</kbd>: 展开/折叠 pod
<kbd>enter</kbd>: 聚焦主面板
<kbd>[</kbd>: 上一个选项卡
<kbd>]</kbd>: 下一个选项卡

View file

@ -440,12 +440,19 @@ func (c *PodmanCommand) buildContainerListItems(containers []*Container, podSumm
// Add pods and their containers
for _, podID := range podIDs {
ps := podMap[podID]
// Sort containers within this pod alphabetically
podCtrs := podContainers[podID]
sort.Slice(podCtrs, func(i, j int) bool {
return podCtrs[i].Name < podCtrs[j].Name
})
// Create pod object
pod := &Pod{
ID: ps.ID,
Name: ps.Name,
Summary: ps,
Containers: podContainers[podID],
Containers: podCtrs,
OSCommand: c.OSCommand,
Log: c.Log,
}
@ -458,7 +465,7 @@ func (c *PodmanCommand) buildContainerListItems(containers []*Container, podSumm
})
// Add containers in this pod with indent
for _, ctr := range podContainers[podID] {
for _, ctr := range podCtrs {
// Set pod name on container if not already set
if ctr.Summary.PodName == "" {
ctr.Summary.PodName = ps.Name

View file

@ -84,6 +84,14 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.ContainerLi
}
container := item.Container
// Hide containers in collapsed pods
if container.Summary.Pod != "" {
if !gui.State.ExpandedPods[container.Summary.Pod] {
return false // Pod is collapsed, hide this container
}
}
// Note that this is O(N*M) time complexity where N is the number of services
// and M is the number of containers. We expect N to be small but M may be large,
// so we will need to keep an eye on this.
@ -98,7 +106,11 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.ContainerLi
return true
},
GetTableCells: func(item *commands.ContainerListItem) []string {
return presentation.GetContainerListItemDisplayStrings(&gui.Config.UserConfig.Gui, item)
expanded := false
if item.IsPod && item.Pod != nil {
expanded = gui.State.ExpandedPods[item.Pod.ID]
}
return presentation.GetContainerListItemDisplayStrings(&gui.Config.UserConfig.Gui, item, expanded)
},
}
}
@ -124,39 +136,10 @@ func sortContainers(a *commands.Container, b *commands.Container, legacySort boo
}
// sortContainerListItems sorts items to group pods with their containers.
// Order: pods first (sorted by state/name), then their containers indented,
// then standalone containers (sorted by state/name).
// Order: pods first (sorted alphabetically), then their containers indented (sorted alphabetically),
// then standalone containers (sorted alphabetically).
func sortContainerListItems(a *commands.ContainerListItem, b *commands.ContainerListItem, legacySort bool) bool {
// If both are in the same pod, sort by indent (pod first) then by name
if a.PodID() != "" && a.PodID() == b.PodID() {
// Pod comes before its containers
if a.IsPod && !b.IsPod {
return true
}
if !a.IsPod && b.IsPod {
return false
}
// Both are containers in the same pod, sort by name
return a.Name() < b.Name()
}
// Get the effective sort key (pod name for items in pods, own name for standalone)
aKey := a.Name()
bKey := b.Name()
if a.PodID() != "" && !a.IsPod {
aKey = a.PodName() + "\x00" + a.Name() // Sort after the pod
}
if b.PodID() != "" && !b.IsPod {
bKey = b.PodName() + "\x00" + b.Name()
}
if a.IsPod {
aKey = a.Name() + "\x00" // Pod sorts before its containers
}
if b.IsPod {
bKey = b.Name() + "\x00"
}
// Pods and their containers sort together, standalone containers at the end
// Pods and their containers sort before standalone containers
aInPod := a.IsPod || a.PodID() != ""
bInPod := b.IsPod || b.PodID() != ""
@ -167,16 +150,46 @@ func sortContainerListItems(a *commands.ContainerListItem, b *commands.Container
return false
}
// Both in same category (pod-related or standalone)
if legacySort {
return aKey < bKey
// Both are in the same category (pod-related or standalone)
// For pod-related items, sort by pod name first, then by type (pod before containers), then by container name
if aInPod && bInPod {
// Get effective pod name for comparison
aPodName := a.PodName()
if a.IsPod {
aPodName = a.Name()
}
bPodName := b.PodName()
if b.IsPod {
bPodName = b.Name()
}
// Different pods: sort by pod name
if aPodName != bPodName {
return aPodName < bPodName
}
// Same pod: pod comes first, then containers alphabetically
if a.IsPod && !b.IsPod {
return true
}
if !a.IsPod && b.IsPod {
return false
}
// Both are containers in the same pod: sort by name
return a.Name() < b.Name()
}
// Sort by state, then by key
// Both are standalone containers
if legacySort {
return a.Name() < b.Name()
}
// Sort by state, then by name
stateA := containerStates[a.State()]
stateB := containerStates[b.State()]
if stateA == stateB {
return aKey < bKey
return a.Name() < b.Name()
}
return stateA < stateB
}
@ -725,3 +738,19 @@ func (gui *Gui) openContainerInBrowser(ctr *commands.Container) error {
link := fmt.Sprintf("http://%s:%d/", ip, port.PublicPort)
return gui.OSCommand.OpenLink(link)
}
func (gui *Gui) handleTogglePodExpansion(g *gocui.Gui, v *gocui.View) error {
item, err := gui.Panels.Containers.GetSelectedItem()
if err != nil {
return nil
}
if !item.IsPod {
return nil // Only works on pods
}
podID := item.Pod.ID
gui.State.ExpandedPods[podID] = !gui.State.ExpandedPods[podID]
return gui.Panels.Containers.RerenderList()
}

View file

@ -80,6 +80,10 @@ type guiState struct {
// if true, we show containers with an 'exited' status in the containers panel
ShowExitedContainers bool
// ExpandedPods tracks which pods are expanded (showing their containers)
// Key is pod ID, value is true if expanded. Pods start collapsed by default.
ExpandedPods map[string]bool
ScreenMode WindowMaximisation
// Maintains the state of manual filtering i.e. typing in a substring
@ -134,6 +138,7 @@ func NewGui(log *logrus.Entry, podmanCommand *commands.PodmanCommand, oSCommand
ViewStack: []string{},
ShowExitedContainers: true,
ExpandedPods: make(map[string]bool),
ScreenMode: getScreenMode(config),
}

View file

@ -262,6 +262,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleContainersOpenInBrowserCommand,
Description: gui.Tr.OpenInBrowser,
},
{
ViewName: "containers",
Key: gocui.KeySpace,
Modifier: gocui.ModNone,
Handler: gui.handleTogglePodExpansion,
Description: gui.Tr.TogglePodExpansion,
},
{
ViewName: "services",
Key: 'u',

View file

@ -25,9 +25,9 @@ func GetContainerDisplayStrings(guiConfig *config.GuiConfig, container *commands
}
// GetContainerListItemDisplayStrings returns display strings for a ContainerListItem (pod or container)
func GetContainerListItemDisplayStrings(guiConfig *config.GuiConfig, item *commands.ContainerListItem) []string {
func GetContainerListItemDisplayStrings(guiConfig *config.GuiConfig, item *commands.ContainerListItem, expanded bool) []string {
if item.IsPod && item.Pod != nil {
return GetPodDisplayStrings(guiConfig, item.Pod)
return GetPodDisplayStrings(guiConfig, item.Pod, expanded)
}
if item.Container == nil {
@ -44,11 +44,21 @@ func GetContainerListItemDisplayStrings(guiConfig *config.GuiConfig, item *comma
}
// GetPodDisplayStrings returns display strings for a pod
func GetPodDisplayStrings(guiConfig *config.GuiConfig, pod *commands.Pod) []string {
func GetPodDisplayStrings(guiConfig *config.GuiConfig, pod *commands.Pod, expanded bool) []string {
// Add expand/collapse indicator to pod name
var indicator string
if len(pod.Containers) > 0 {
if expanded {
indicator = "- "
} else {
indicator = "+ "
}
}
return []string{
getPodDisplayStatus(guiConfig, pod),
"", // No substatus for pods
utils.ColoredString(pod.Name, color.FgCyan),
utils.ColoredString(indicator+pod.Name, color.FgCyan),
"", // No CPU% for pods
"", // No ports for pods
utils.ColoredString(fmt.Sprintf("(%d containers)", len(pod.Containers)), color.FgMagenta),

View file

@ -145,3 +145,125 @@ func TestLegacySortedContainers(t *testing.T) {
assertEqualContainers(t, expected[i], actual[i])
}
}
func TestSortContainerListItems(t *testing.T) {
// Create test data: 2 pods with containers and 2 standalone containers
items := []*commands.ContainerListItem{
// Standalone container "zebra"
{
IsPod: false,
Container: &commands.Container{
ID: "standalone-z",
Name: "zebra",
Summary: commands.ContainerSummary{
State: "running",
},
},
Indent: 0,
},
// Pod "beta" with containers
{
IsPod: true,
Pod: &commands.Pod{
ID: "pod-beta",
Name: "beta",
},
Indent: 0,
},
{
IsPod: false,
Container: &commands.Container{
ID: "ctr-beta-y",
Name: "yak",
Summary: commands.ContainerSummary{
State: "running",
Pod: "pod-beta",
PodName: "beta",
},
},
Indent: 2,
},
// Standalone container "apple"
{
IsPod: false,
Container: &commands.Container{
ID: "standalone-a",
Name: "apple",
Summary: commands.ContainerSummary{
State: "exited",
},
},
Indent: 0,
},
// Pod "alpha" with containers
{
IsPod: true,
Pod: &commands.Pod{
ID: "pod-alpha",
Name: "alpha",
},
Indent: 0,
},
{
IsPod: false,
Container: &commands.Container{
ID: "ctr-alpha-b",
Name: "bear",
Summary: commands.ContainerSummary{
State: "running",
Pod: "pod-alpha",
PodName: "alpha",
},
},
Indent: 2,
},
{
IsPod: false,
Container: &commands.Container{
ID: "ctr-alpha-a",
Name: "ant",
Summary: commands.ContainerSummary{
State: "exited",
Pod: "pod-alpha",
PodName: "alpha",
},
},
Indent: 2,
},
{
IsPod: false,
Container: &commands.Container{
ID: "ctr-beta-x",
Name: "xray",
Summary: commands.ContainerSummary{
State: "exited",
Pod: "pod-beta",
PodName: "beta",
},
},
Indent: 2,
},
}
// Sort the items
sort.Slice(items, func(i, j int) bool {
return sortContainerListItems(items[i], items[j], false)
})
// Expected order (with legacySort=false, sorts by state then name):
// 1. pod alpha (alphabetically first pod)
// 2. ant (container in alpha, alphabetically first)
// 3. bear (container in alpha)
// 4. pod beta (alphabetically second pod)
// 5. xray (container in beta, alphabetically first)
// 6. yak (container in beta)
// 7. zebra (standalone, running - state 1)
// 8. apple (standalone, exited - state 2)
expectedOrder := []string{"alpha", "ant", "bear", "beta", "xray", "yak", "zebra", "apple"}
assert.Equal(t, len(expectedOrder), len(items))
for i, item := range items {
assert.Equal(t, expectedOrder[i], item.Name(), "Item at index %d should be %s but was %s", i, expectedOrder[i], item.Name())
}
}

View file

@ -78,6 +78,7 @@ func chineseSet() TranslationSet {
ViewBulkCommands: "查看批量命令",
FilterList: "过滤列表",
OpenInBrowser: "在浏览器中打开(第一个端口为http)",
TogglePodExpansion: "展开/折叠 pod",
SortContainersByState: "按状态排序容器",
GlobalTitle: "全局",

View file

@ -53,6 +53,7 @@ func dutchSet() TranslationSet {
PruneImages: "vernietig ongebruikte images",
ViewRestartOptions: "bekijk herstart opties",
RunCustomCommand: "draai een vooraf bedacht aangepaste opdracht",
TogglePodExpansion: "pod uitvouwen/invouwen",
GlobalTitle: "Globaal",
MainTitle: "Hoofd",

View file

@ -108,6 +108,7 @@ type TranslationSet struct {
ViewBulkCommands string
FilterList string
OpenInBrowser string
TogglePodExpansion string
SortContainersByState string
LogsTitle string
ConfigTitle string
@ -214,6 +215,7 @@ func englishSet() TranslationSet {
ViewBulkCommands: "view bulk commands",
FilterList: "filter list",
OpenInBrowser: "open in browser (first port is http)",
TogglePodExpansion: "expand/collapse pod",
SortContainersByState: "sort containers by state",
GlobalTitle: "Global",

View file

@ -68,6 +68,7 @@ func frenchSet() TranslationSet {
RunCustomCommand: "exécuter une commande prédéfinie",
ViewBulkCommands: "voir les commandes groupées",
OpenInBrowser: "ouvrir dans le navigateur (le premier port est http)",
TogglePodExpansion: "ouvrir/réduire les conteneurs du pod",
SortContainersByState: "ordonner les conteneurs par état",
GlobalTitle: "Global",

View file

@ -52,6 +52,7 @@ func germanSet() TranslationSet {
PruneImages: "entferne unbenutzte Images",
ViewRestartOptions: "zeige Neustartoptionen",
RunCustomCommand: "führe vordefinierten benutzerdefinierten Befehl aus",
TogglePodExpansion: "Pod erweitern/reduzieren",
GlobalTitle: "Global",
MainTitle: "Haupt",

View file

@ -52,6 +52,7 @@ func polishSet() TranslationSet {
PruneImages: "wyczyść nieużywane obrazy",
ViewRestartOptions: "pokaż opcje restartu",
RunCustomCommand: "wykonaj predefiniowaną własną komende",
TogglePodExpansion: "rozwiń/zwiń pod",
GlobalTitle: "Globalne",
MainTitle: "Główne",

View file

@ -78,6 +78,7 @@ func portugueseSet() TranslationSet {
ViewBulkCommands: "ver comandos em massa",
FilterList: "filtrar lista",
OpenInBrowser: "abrir no navegador (primeira porta é http)",
TogglePodExpansion: "expandir/recolher pod",
SortContainersByState: "ordenar contêineres por estado",
GlobalTitle: "Global",

View file

@ -73,6 +73,7 @@ func spanishSet() TranslationSet {
ViewBulkCommands: "ver comandos masivos",
FilterList: "filtar list",
OpenInBrowser: "abrir en navegador (first port is http)",
TogglePodExpansion: "expandir/colapsar pod",
SortContainersByState: "ordenar contenedores por estado",
GlobalTitle: "Global",

View file

@ -52,6 +52,7 @@ func turkishSet() TranslationSet {
PruneImages: "kullanılmayan imajları temizle",
ViewRestartOptions: "yeniden başlatma seçeneklerini görüntüle",
RunCustomCommand: "önceden tanımlanmış özel komutu çalıştır",
TogglePodExpansion: "pod'u genişlet/daralt",
GlobalTitle: "Global",
MainTitle: "Ana",