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.
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

@ -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).
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
// Order: pods first (sorted alphabetically), then their containers indented (sorted alphabetically),
// then standalone containers (sorted alphabetically).
func sortContainerListItems(a *commands.ContainerListItem, b *commands.ContainerListItem, _ bool) bool {
// Pods and their containers sort before standalone containers
aInPod := a.IsPod || a.PodID() != ""
bInPod := b.IsPod || b.PodID() != ""
@ -167,18 +150,39 @@ 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
stateA := containerStates[a.State()]
stateB := containerStates[b.State()]
if stateA == stateB {
return aKey < bKey
}
return stateA < stateB
// Both are standalone containers: sort alphabetically
return a.Name() < b.Name()
}
// 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)
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:
// 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: "查看批量命令",
FilterList: "过滤列表",
OpenInBrowser: "在浏览器中打开(第一个端口为http)",
TogglePodExpansion: "展开/折叠 pod",
SortContainersByState: "按状态排序容器",
GlobalTitle: "全局",

View file

@ -51,8 +51,9 @@ func dutchSet() TranslationSet {
PruneVolumes: "vernietig ongebruikte volumes",
PruneNetworks: "vernietig ongebruikte networks",
PruneImages: "vernietig ongebruikte images",
ViewRestartOptions: "bekijk herstart opties",
RunCustomCommand: "draai een vooraf bedacht aangepaste opdracht",
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

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

View file

@ -50,8 +50,9 @@ func polishSet() TranslationSet {
PruneVolumes: "wyczyść nieużywane wolumeny",
PruneNetworks: "wyczyść nieużywane sieci",
PruneImages: "wyczyść nieużywane obrazy",
ViewRestartOptions: "pokaż opcje restartu",
RunCustomCommand: "wykonaj predefiniowaną własną komende",
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

@ -50,8 +50,9 @@ func turkishSet() TranslationSet {
PruneVolumes: "kullanılmayan alanları temizle",
PruneNetworks: "kullanılmayan ağları temizle",
PruneImages: "kullanılmayan imajları temizle",
ViewRestartOptions: "yeniden başlatma seçeneklerini görüntüle",
RunCustomCommand: "önceden tanımlanmış özel komutu çalıştır",
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",