added expand/collapse feature

This commit is contained in:
christophe-duc 2026-01-07 19:22:32 -04:00
parent 56dcdec0ae
commit 2f69b2e1db
16 changed files with 241 additions and 58 deletions

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. 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. 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: Original elevator pitch below:

View file

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

View file

@ -84,6 +84,14 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.ContainerLi
} }
container := item.Container 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 // 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, // 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. // so we will need to keep an eye on this.
@ -98,7 +106,11 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.ContainerLi
return true return true
}, },
GetTableCells: func(item *commands.ContainerListItem) []string { 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. // sortContainerListItems sorts items to group pods with their containers.
// Order: pods first (sorted by state/name), then their containers indented, // Order: pods first (sorted alphabetically), then their containers indented (sorted alphabetically),
// then standalone containers (sorted by state/name). // then standalone containers (sorted alphabetically).
func sortContainerListItems(a *commands.ContainerListItem, b *commands.ContainerListItem, legacySort bool) bool { func sortContainerListItems(a *commands.ContainerListItem, b *commands.ContainerListItem, _ bool) bool {
// If both are in the same pod, sort by indent (pod first) then by name // Pods and their containers sort before standalone containers
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
aInPod := a.IsPod || a.PodID() != "" aInPod := a.IsPod || a.PodID() != ""
bInPod := b.IsPod || b.PodID() != "" bInPod := b.IsPod || b.PodID() != ""
@ -167,18 +150,39 @@ func sortContainerListItems(a *commands.ContainerListItem, b *commands.Container
return false return false
} }
// Both in same category (pod-related or standalone) // Both are in the same category (pod-related or standalone)
if legacySort {
return aKey < bKey // 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: sort alphabetically
stateA := containerStates[a.State()] return a.Name() < b.Name()
stateB := containerStates[b.State()]
if stateA == stateB {
return aKey < bKey
}
return stateA < stateB
} }
// Wrapper functions that delegate to container or pod rendering // Wrapper functions that delegate to container or pod rendering
@ -725,3 +729,19 @@ func (gui *Gui) openContainerInBrowser(ctr *commands.Container) error {
link := fmt.Sprintf("http://%s:%d/", ip, port.PublicPort) link := fmt.Sprintf("http://%s:%d/", ip, port.PublicPort)
return gui.OSCommand.OpenLink(link) 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 // if true, we show containers with an 'exited' status in the containers panel
ShowExitedContainers bool 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 ScreenMode WindowMaximisation
// Maintains the state of manual filtering i.e. typing in a substring // 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{}, ViewStack: []string{},
ShowExitedContainers: true, ShowExitedContainers: true,
ExpandedPods: make(map[string]bool),
ScreenMode: getScreenMode(config), ScreenMode: getScreenMode(config),
} }

View file

@ -262,6 +262,13 @@ func (gui *Gui) GetInitialKeybindings() []*Binding {
Handler: gui.handleContainersOpenInBrowserCommand, Handler: gui.handleContainersOpenInBrowserCommand,
Description: gui.Tr.OpenInBrowser, Description: gui.Tr.OpenInBrowser,
}, },
{
ViewName: "containers",
Key: gocui.KeySpace,
Modifier: gocui.ModNone,
Handler: gui.handleTogglePodExpansion,
Description: gui.Tr.TogglePodExpansion,
},
{ {
ViewName: "services", ViewName: "services",
Key: 'u', 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) // 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 { if item.IsPod && item.Pod != nil {
return GetPodDisplayStrings(guiConfig, item.Pod) return GetPodDisplayStrings(guiConfig, item.Pod, expanded)
} }
if item.Container == nil { if item.Container == nil {
@ -44,11 +44,21 @@ func GetContainerListItemDisplayStrings(guiConfig *config.GuiConfig, item *comma
} }
// GetPodDisplayStrings returns display strings for a pod // 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{ return []string{
getPodDisplayStatus(guiConfig, pod), getPodDisplayStatus(guiConfig, pod),
"", // No substatus for pods "", // No substatus for pods
utils.ColoredString(pod.Name, color.FgCyan), utils.ColoredString(indicator+pod.Name, color.FgCyan),
"", // No CPU% for pods "", // No CPU% for pods
"", // No ports for pods "", // No ports for pods
utils.ColoredString(fmt.Sprintf("(%d containers)", len(pod.Containers)), color.FgMagenta), 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]) 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:
// 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. apple (standalone, alphabetically first)
// 8. zebra (standalone)
expectedOrder := []string{"alpha", "ant", "bear", "beta", "xray", "yak", "apple", "zebra"}
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: "查看批量命令", ViewBulkCommands: "查看批量命令",
FilterList: "过滤列表", FilterList: "过滤列表",
OpenInBrowser: "在浏览器中打开(第一个端口为http)", OpenInBrowser: "在浏览器中打开(第一个端口为http)",
TogglePodExpansion: "展开/折叠 pod",
SortContainersByState: "按状态排序容器", SortContainersByState: "按状态排序容器",
GlobalTitle: "全局", GlobalTitle: "全局",

View file

@ -51,8 +51,9 @@ func dutchSet() TranslationSet {
PruneVolumes: "vernietig ongebruikte volumes", PruneVolumes: "vernietig ongebruikte volumes",
PruneNetworks: "vernietig ongebruikte networks", PruneNetworks: "vernietig ongebruikte networks",
PruneImages: "vernietig ongebruikte images", PruneImages: "vernietig ongebruikte images",
ViewRestartOptions: "bekijk herstart opties", ViewRestartOptions: "bekijk herstart opties",
RunCustomCommand: "draai een vooraf bedacht aangepaste opdracht", RunCustomCommand: "draai een vooraf bedacht aangepaste opdracht",
TogglePodExpansion: "pod uitvouwen/invouwen",
GlobalTitle: "Globaal", GlobalTitle: "Globaal",
MainTitle: "Hoofd", MainTitle: "Hoofd",

View file

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

View file

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

View file

@ -50,8 +50,9 @@ func germanSet() TranslationSet {
PruneVolumes: "entferne unbenutzte Volumes", PruneVolumes: "entferne unbenutzte Volumes",
PruneNetworks: "entferne unbenutzte Netzwerk", PruneNetworks: "entferne unbenutzte Netzwerk",
PruneImages: "entferne unbenutzte Images", PruneImages: "entferne unbenutzte Images",
ViewRestartOptions: "zeige Neustartoptionen", ViewRestartOptions: "zeige Neustartoptionen",
RunCustomCommand: "führe vordefinierten benutzerdefinierten Befehl aus", RunCustomCommand: "führe vordefinierten benutzerdefinierten Befehl aus",
TogglePodExpansion: "Pod erweitern/reduzieren",
GlobalTitle: "Global", GlobalTitle: "Global",
MainTitle: "Haupt", MainTitle: "Haupt",

View file

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

View file

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

View file

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

View file

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