mirror of
https://github.com/jesseduffield/lazydocker.git
synced 2026-07-25 08:31:03 +00:00
added expand/collapse feature
This commit is contained in:
parent
56dcdec0ae
commit
2f69b2e1db
16 changed files with 241 additions and 58 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ func chineseSet() TranslationSet {
|
|||
ViewBulkCommands: "查看批量命令",
|
||||
FilterList: "过滤列表",
|
||||
OpenInBrowser: "在浏览器中打开(第一个端口为http)",
|
||||
TogglePodExpansion: "展开/折叠 pod",
|
||||
SortContainersByState: "按状态排序容器",
|
||||
|
||||
GlobalTitle: "全局",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue