From c023c24be2f518edc9b18967029c4810026d4d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Fri, 1 May 2026 01:15:37 -0300 Subject: [PATCH 01/19] upgrade dependencies --- go.mod | 33 ++-- go.sum | 36 ++++ internal/tui/commands.go | 206 +++++++++++++++++++++ internal/tui/container.go | 94 ---------- internal/tui/image.go | 38 ---- internal/tui/model.go | 197 ++++++++++++++++++++ internal/tui/network.go | 37 ---- internal/tui/{theme.go => styles.go} | 3 +- internal/tui/system.go | 66 ------- internal/tui/tui.go | 230 ------------------------ internal/tui/types.go | 33 ++++ internal/tui/update.go | 257 +++++++-------------------- internal/tui/update_keyboard.go | 195 ++++++++++++++++++++ internal/tui/volume.go | 30 ---- 14 files changed, 751 insertions(+), 704 deletions(-) create mode 100644 internal/tui/commands.go delete mode 100644 internal/tui/container.go delete mode 100644 internal/tui/image.go create mode 100644 internal/tui/model.go delete mode 100644 internal/tui/network.go rename internal/tui/{theme.go => styles.go} (94%) delete mode 100644 internal/tui/system.go delete mode 100644 internal/tui/tui.go create mode 100644 internal/tui/types.go create mode 100644 internal/tui/update_keyboard.go delete mode 100644 internal/tui/volume.go diff --git a/go.mod b/go.mod index 664de74..1c04990 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,26 @@ module github.com/rluders/berth -go 1.24.2 +go 1.25.0 require ( - github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/docker/docker v28.3.3+incompatible + github.com/docker/docker v28.5.2+incompatible github.com/opencontainers/image-spec v1.1.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 ) require ( github.com/Microsoft/go-winio v0.4.14 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -30,10 +33,10 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect @@ -53,9 +56,9 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.12.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect diff --git a/go.sum b/go.sum index faec9a3..0494513 100644 --- a/go.sum +++ b/go.sum @@ -6,24 +6,43 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -36,6 +55,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -66,12 +87,18 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -111,6 +138,8 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -140,6 +169,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -153,6 +183,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -162,10 +194,14 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..a55e8b8 --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,206 @@ +package tui + +import ( + "fmt" + "log/slog" + + tea "github.com/charmbracelet/bubbletea" + "github.com/rluders/berth/internal/controller" +) + +func fetchContainersCmd() tea.Cmd { + return func() tea.Msg { + slog.Debug("fetchContainersCmd called") + containers, err := controller.ListContainers() + if err != nil { + slog.Error("fetchContainersCmd error", "error", err) + return errMsg{err} + } + return containerListMsg(containers) + } +} + +func fetchImagesCmd() tea.Cmd { + return func() tea.Msg { + slog.Debug("fetchImagesCmd called") + images, err := controller.ListImages() + if err != nil { + slog.Error("fetchImagesCmd error", "error", err) + return errMsg{err} + } + return imageListMsg(images) + } +} + +func fetchVolumesCmd() tea.Cmd { + return func() tea.Msg { + slog.Debug("fetchVolumesCmd called") + volumes, err := controller.ListVolumes() + if err != nil { + slog.Error("fetchVolumesCmd error", "error", err) + return errMsg{err} + } + return volumeListMsg(volumes) + } +} + +func fetchNetworksCmd() tea.Cmd { + return func() tea.Msg { + slog.Debug("fetchNetworksCmd called") + networks, err := controller.ListNetworks() + if err != nil { + slog.Error("fetchNetworksCmd error", "error", err) + return errMsg{err} + } + return networkListMsg(networks) + } +} + +func fetchSystemInfoCmd() tea.Cmd { + return func() tea.Msg { + slog.Debug("fetchSystemInfoCmd called") + info, err := controller.GetSystemInfo() + if err != nil { + slog.Error("fetchSystemInfoCmd error", "error", err) + return errMsg{err} + } + return systemInfoMsg(info) + } +} + +func fetchAllCmd() tea.Cmd { + return tea.Batch( + fetchContainersCmd(), + fetchImagesCmd(), + fetchVolumesCmd(), + fetchNetworksCmd(), + fetchSystemInfoCmd(), + ) +} + +func startContainerCmd(idOrName string) tea.Cmd { + return func() tea.Msg { + slog.Debug("startContainerCmd called", "id", idOrName) + if err := controller.StartContainer(idOrName); err != nil { + slog.Error("startContainerCmd error", "id", idOrName, "error", err) + return errMsg{err} + } + return statusMsg(fmt.Sprintf("Container %s started.", idOrName)) + } +} + +func stopContainerCmd(idOrName string) tea.Cmd { + return func() tea.Msg { + slog.Debug("stopContainerCmd called", "id", idOrName) + if err := controller.StopContainer(idOrName); err != nil { + slog.Error("stopContainerCmd error", "id", idOrName, "error", err) + return errMsg{err} + } + return statusMsg(fmt.Sprintf("Container %s stopped.", idOrName)) + } +} + +func removeContainerCmd(idOrName string) tea.Cmd { + return func() tea.Msg { + slog.Debug("removeContainerCmd called", "id", idOrName) + if err := controller.RemoveContainer(idOrName); err != nil { + slog.Error("removeContainerCmd error", "id", idOrName, "error", err) + return errMsg{err} + } + return statusMsg(fmt.Sprintf("Container %s removed.", idOrName)) + } +} + +func getLogsCmd(idOrName string) tea.Cmd { + return func() tea.Msg { + slog.Debug("getLogsCmd called", "id", idOrName) + logs, err := controller.GetContainerLogs(idOrName) + if err != nil { + slog.Error("getLogsCmd error", "id", idOrName, "error", err) + return errMsg{err} + } + return logsMsg(logs) + } +} + +func inspectContainerCmd(idOrName string) tea.Cmd { + return func() tea.Msg { + slog.Debug("inspectContainerCmd called", "id", idOrName) + output, err := controller.InspectContainer(idOrName) + if err != nil { + slog.Error("inspectContainerCmd error", "id", idOrName, "error", err) + return errMsg{err} + } + return inspectMsg(output) + } +} + +func removeImageCmd(idOrName string) tea.Cmd { + return func() tea.Msg { + slog.Debug("removeImageCmd called", "id", idOrName) + if err := controller.RemoveImage(idOrName); err != nil { + slog.Error("removeImageCmd error", "id", idOrName, "error", err) + return errMsg{err} + } + return statusMsg(fmt.Sprintf("Image %s removed.", idOrName)) + } +} + +func removeVolumeCmd(name string) tea.Cmd { + return func() tea.Msg { + slog.Debug("removeVolumeCmd called", "name", name) + if err := controller.RemoveVolume(name); err != nil { + slog.Error("removeVolumeCmd error", "name", name, "error", err) + return errMsg{err} + } + return statusMsg(fmt.Sprintf("Volume %s removed.", name)) + } +} + +func inspectNetworkCmd(idOrName string) tea.Cmd { + return func() tea.Msg { + slog.Debug("inspectNetworkCmd called", "id", idOrName) + output, err := controller.InspectNetwork(idOrName) + if err != nil { + slog.Error("inspectNetworkCmd error", "id", idOrName, "error", err) + return errMsg{err} + } + return inspectMsg(output) + } +} + +func basicCleanupCmd() tea.Cmd { + return func() tea.Msg { + slog.Debug("basicCleanupCmd called") + output, err := controller.BasicCleanup() + if err != nil { + slog.Error("basicCleanupCmd error", "error", err) + return errMsg{err} + } + return statusMsg(fmt.Sprintf("Basic cleanup: %s", output)) + } +} + +func advancedCleanupCmd() tea.Cmd { + return func() tea.Msg { + slog.Debug("advancedCleanupCmd called") + output, err := controller.AdvancedCleanup() + if err != nil { + slog.Error("advancedCleanupCmd error", "error", err) + return errMsg{err} + } + return statusMsg(fmt.Sprintf("Advanced cleanup: %s", output)) + } +} + +func totalCleanupCmd() tea.Cmd { + return func() tea.Msg { + slog.Debug("totalCleanupCmd called") + output, err := controller.TotalCleanup() + if err != nil { + slog.Error("totalCleanupCmd error", "error", err) + return errMsg{err} + } + return statusMsg(fmt.Sprintf("Total cleanup: %s", output)) + } +} diff --git a/internal/tui/container.go b/internal/tui/container.go deleted file mode 100644 index ecdeebc..0000000 --- a/internal/tui/container.go +++ /dev/null @@ -1,94 +0,0 @@ -// Package tui provides the Terminal User Interface for Berth. -package tui - -import ( - "fmt" - "log/slog" - - tea "github.com/charmbracelet/bubbletea" - "github.com/rluders/berth/internal/controller" -) - -// fetchContainersCmd is a Bubble Tea command that fetches a list of containers. -func fetchContainersCmd() tea.Cmd { - return func() tea.Msg { - slog.Debug("fetchContainersCmd: Calling controller.ListContainers...") - containers, err := controller.ListContainers() - if err != nil { - slog.Error("fetchContainersCmd: Error listing containers", "error", err) - return err - } - slog.Debug("fetchContainersCmd: Successfully listed containers.") - return containers - } -} - -// startContainerCmd is a Bubble Tea command that starts a container. -func startContainerCmd(idOrName string) tea.Cmd { - return func() tea.Msg { - slog.Debug("startContainerCmd: Calling controller.StartContainer", "idOrName", idOrName) - err := controller.StartContainer(idOrName) - if err != nil { - slog.Error("startContainerCmd: Error starting container", "idOrName", idOrName, "error", err) - return err - } - slog.Debug("startContainerCmd: Successfully started container.", "idOrName", idOrName) - return statusMsg(fmt.Sprintf("Container %s started.", idOrName)) - } -} - -// stopContainerCmd is a Bubble Tea command that stops a container. -func stopContainerCmd(idOrName string) tea.Cmd { - return func() tea.Msg { - slog.Debug("stopContainerCmd: Calling controller.StopContainer", "idOrName", idOrName) - err := controller.StopContainer(idOrName) - if err != nil { - slog.Error("stopContainerCmd: Error stopping container", "idOrName", idOrName, "error", err) - return err - } - slog.Debug("stopContainerCmd: Successfully stopped container.", "idOrName", idOrName) - return statusMsg(fmt.Sprintf("Container %s stopped.", idOrName)) - } -} - -// removeContainerCmd is a Bubble Tea command that removes a container. -func removeContainerCmd(idOrName string) tea.Cmd { - return func() tea.Msg { - slog.Debug("removeContainerCmd: Calling controller.RemoveContainer", "idOrName", idOrName) - err := controller.RemoveContainer(idOrName) - if err != nil { - slog.Error("removeContainerCmd: Error removing container", "idOrName", idOrName, "error", err) - return err - } - slog.Debug("removeContainerCmd: Successfully removed container.", "idOrName", idOrName) - return statusMsg(fmt.Sprintf("Container %s removed.", idOrName)) - } -} - -// getLogsCmd is a Bubble Tea command that fetches logs for a container. -func getLogsCmd(idOrName string) tea.Cmd { - return func() tea.Msg { - slog.Debug("getLogsCmd: Calling controller.GetContainerLogs", "idOrName", idOrName) - logs, err := controller.GetContainerLogs(idOrName) - if err != nil { - slog.Error("getLogsCmd: Error getting container logs", "idOrName", idOrName, "error", err) - return err - } - slog.Debug("getLogsCmd: Successfully retrieved container logs.", "idOrName", idOrName) - return logs - } -} - -// inspectContainerCmd is a Bubble Tea command that inspects a container. -func inspectContainerCmd(idOrName string) tea.Cmd { - return func() tea.Msg { - slog.Debug("inspectContainerCmd: Calling controller.InspectContainer", "idOrName", idOrName) - output, err := controller.InspectContainer(idOrName) - if err != nil { - slog.Error("inspectContainerCmd: Error inspecting container", "idOrName", idOrName, "error", err) - return err - } - slog.Debug("inspectContainerCmd: Successfully inspected container.", "idOrName", idOrName) - return output - } -} diff --git a/internal/tui/image.go b/internal/tui/image.go deleted file mode 100644 index 984b734..0000000 --- a/internal/tui/image.go +++ /dev/null @@ -1,38 +0,0 @@ -// Package tui provides the Terminal User Interface for Berth. -package tui - -import ( - "fmt" - "log/slog" - - tea "github.com/charmbracelet/bubbletea" - "github.com/rluders/berth/internal/controller" -) - -// fetchImagesCmd is a Bubble Tea command that fetches a list of images. -func fetchImagesCmd() tea.Cmd { - return func() tea.Msg { - slog.Debug("fetchImagesCmd: Calling controller.ListImages...") - images, err := controller.ListImages() - if err != nil { - slog.Error("fetchImagesCmd: Error listing images", "error", err) - return err - } - slog.Debug("fetchImagesCmd: Successfully listed images.") - return images - } -} - -// removeImageCmd is a Bubble Tea command that removes an image. -func removeImageCmd(idOrName string) tea.Cmd { - return func() tea.Msg { - slog.Debug("removeImageCmd: Calling controller.RemoveImage", "idOrName", idOrName) - err := controller.RemoveImage(idOrName) - if err != nil { - slog.Error("removeImageCmd: Error removing image", "idOrName", idOrName, "error", err) - return err - } - slog.Debug("removeImageCmd: Successfully removed image.", "idOrName", idOrName) - return statusMsg(fmt.Sprintf("Image %s removed.", idOrName)) - } -} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..279c3ab --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,197 @@ +package tui + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/rluders/berth/internal/controller" + "github.com/rluders/berth/internal/engine" +) + + +var currentTheme = DefaultTheme() + +// Model represents the main application model. +type Model struct { + engineType engine.EngineType + currentView ViewType + containerTable table.Model + imageTable table.Model + volumeTable table.Model + networkTable table.Model + systemInfo controller.SystemInfo + inspectViewPort viewport.Model + inspectReady bool + inspectRawContent string + logViewPort viewport.Model + logReady bool + err error + statusMessage string + showSpinner bool + spinner spinner.Model + width int + height int + currentLogContainerID string + currentInspectID string + viewStack []ViewType +} + +// InitialModel returns an initialized Model with default values. +func InitialModel() Model { + slog.Debug("InitialModel called") + + containerTable := table.New( + table.WithColumns([]table.Column{ + {Title: "ID", Width: 12}, + {Title: "Image", Width: 20}, + {Title: "Command", Width: 30}, + {Title: "Created", Width: 15}, + {Title: "Status", Width: 20}, + {Title: "Ports", Width: 20}, + {Title: "Names", Width: 20}, + }), + table.WithFocused(true), + table.WithHeight(0), + ) + + imageTable := table.New( + table.WithColumns([]table.Column{ + {Title: "ID", Width: 15}, + {Title: "Repository", Width: 30}, + {Title: "Tag", Width: 15}, + {Title: "Size", Width: 10}, + {Title: "Created", Width: 20}, + }), + table.WithFocused(false), + table.WithHeight(0), + ) + + volumeTable := table.New( + table.WithColumns([]table.Column{ + {Title: "Name", Width: 30}, + {Title: "Driver", Width: 15}, + {Title: "Scope", Width: 10}, + {Title: "Mountpoint", Width: 50}, + }), + table.WithFocused(false), + table.WithHeight(0), + ) + + networkTable := table.New( + table.WithColumns([]table.Column{ + {Title: "ID", Width: 15}, + {Title: "Name", Width: 30}, + {Title: "Driver", Width: 15}, + {Title: "Scope", Width: 10}, + }), + table.WithFocused(false), + table.WithHeight(0), + ) + + s := table.DefaultStyles() + s.Header = currentTheme.TableHeaderStyle + s.Selected = currentTheme.TableSelectedStyle + containerTable.SetStyles(s) + imageTable.SetStyles(s) + volumeTable.SetStyles(s) + networkTable.SetStyles(s) + + return Model{ + engineType: engine.DetectEngine(), + currentView: ContainersView, + containerTable: containerTable, + imageTable: imageTable, + volumeTable: volumeTable, + networkTable: networkTable, + systemInfo: controller.SystemInfo{}, + inspectViewPort: viewport.New(0, 0), + logViewPort: viewport.New(0, 0), + spinner: spinner.New(), + } +} + +// Init initializes the Bubble Tea program. +func (m Model) Init() tea.Cmd { + slog.Debug("Init called") + return tea.Batch(fetchAllCmd(), m.spinner.Tick) +} + +func (m Model) getViewName() string { + switch m.currentView { + case ContainersView: + return "Containers" + case ImagesView: + return "Images" + case VolumesView: + return "Volumes" + case NetworksView: + return "Networks" + case SystemView: + return "System" + case InspectView: + return fmt.Sprintf("Inspect %s", m.currentInspectID) + case LogsView: + return fmt.Sprintf("Logs for %s", m.currentLogContainerID) + } + return "Unknown" +} + +func (m Model) getFooterHelp() string { + nav := "1:Containers • 2:Images • 3:Volumes • 4:Networks • 5:System" + switch m.currentView { + case ContainersView: + return nav + " • s:Start • x:Stop • d:Remove • l:Logs • i:Inspect • q:Quit" + case ImagesView: + return nav + " • d:Remove • q:Quit" + case VolumesView: + return nav + " • d:Remove • q:Quit" + case NetworksView: + return nav + " • i:Inspect • q:Quit" + case SystemView: + return nav + " • b:Basic Cleanup • a:Advanced Cleanup • t:Total Cleanup • q:Quit" + case InspectView, LogsView: + return "q/esc:Return • ↑/↓:Scroll" + } + return "q:Quit" +} + +func (m Model) headerText() string { + return fmt.Sprintf("Berth - %s - %s Engine", m.getViewName(), strings.ToUpper(string(m.engineType))) +} + +func (m *Model) pushView(view ViewType) { + m.viewStack = append(m.viewStack, m.currentView) + m.currentView = view +} + +func (m *Model) popView() { + if len(m.viewStack) > 0 { + m.currentView = m.viewStack[len(m.viewStack)-1] + m.viewStack = m.viewStack[:len(m.viewStack)-1] + } else { + m.currentView = ContainersView + } +} + +// contentHeight calculates available height for the main content area +// following Golden Rule #1: subtract header + footer + app padding. +func (m Model) contentHeight() int { + h := m.height + h -= lipgloss.Height(currentTheme.HeaderStyle.Render(m.headerText())) + h -= currentTheme.FooterStyle.GetVerticalFrameSize() + h -= currentTheme.AppStyle.GetVerticalFrameSize() + // status message adds an extra line above the footer help + if m.statusMessage != "" { + h -= 1 + } + if h < 0 { + return 0 + } + return h +} diff --git a/internal/tui/network.go b/internal/tui/network.go deleted file mode 100644 index 5003156..0000000 --- a/internal/tui/network.go +++ /dev/null @@ -1,37 +0,0 @@ -// Package tui provides the Terminal User Interface for Berth. -package tui - -import ( - "log/slog" - - tea "github.com/charmbracelet/bubbletea" - "github.com/rluders/berth/internal/controller" -) - -// fetchNetworksCmd is a Bubble Tea command that fetches a list of networks. -func fetchNetworksCmd() tea.Cmd { - return func() tea.Msg { - slog.Debug("fetchNetworksCmd: Calling controller.ListNetworks...") - networks, err := controller.ListNetworks() - if err != nil { - slog.Error("fetchNetworksCmd: Error listing networks", "error", err) - return err - } - slog.Debug("fetchNetworksCmd: Successfully listed networks.") - return networks - } -} - -// inspectNetworkCmd is a Bubble Tea command that inspects a network. -func inspectNetworkCmd(idOrName string) tea.Cmd { - return func() tea.Msg { - slog.Debug("inspectNetworkCmd: Calling controller.InspectNetwork", "idOrName", idOrName) - output, err := controller.InspectNetwork(idOrName) - if err != nil { - slog.Error("inspectNetworkCmd: Error inspecting network", "idOrName", idOrName, "error", err) - return err - } - slog.Debug("inspectNetworkCmd: Successfully inspected network.", "idOrName", idOrName) - return output - } -} diff --git a/internal/tui/theme.go b/internal/tui/styles.go similarity index 94% rename from internal/tui/theme.go rename to internal/tui/styles.go index 5bd3fd3..758ce92 100644 --- a/internal/tui/theme.go +++ b/internal/tui/styles.go @@ -1,4 +1,3 @@ -// Package tui provides the Terminal User Interface for Berth. package tui import "github.com/charmbracelet/lipgloss" @@ -23,4 +22,4 @@ func DefaultTheme() Theme { TableSelectedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Bold(false), TableHeaderStyle: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).BorderBottom(true).Bold(false), } -} \ No newline at end of file +} diff --git a/internal/tui/system.go b/internal/tui/system.go deleted file mode 100644 index 60756ae..0000000 --- a/internal/tui/system.go +++ /dev/null @@ -1,66 +0,0 @@ -// Package tui provides the Terminal User Interface for Berth. -package tui - -import ( - "fmt" - "log/slog" - - tea "github.com/charmbracelet/bubbletea" - "github.com/rluders/berth/internal/controller" -) - -// fetchSystemInfoCmd is a Bubble Tea command that fetches system information. -func fetchSystemInfoCmd() tea.Cmd { - return func() tea.Msg { - slog.Debug("fetchSystemInfoCmd: Calling controller.GetSystemInfo...") - info, err := controller.GetSystemInfo() - if err != nil { - slog.Error("fetchSystemInfoCmd: Error getting system info", "error", err) - return err - } - slog.Debug("fetchSystemInfoCmd: Successfully retrieved system info.") - return info - } -} - -// basicCleanupCmd is a Bubble Tea command that performs basic cleanup. -func basicCleanupCmd() tea.Cmd { - return func() tea.Msg { - slog.Debug("basicCleanupCmd: Calling controller.BasicCleanup...") - output, err := controller.BasicCleanup() - if err != nil { - slog.Error("basicCleanupCmd: Error during basic cleanup", "error", err) - return err - } - slog.Debug("basicCleanupCmd: Basic cleanup completed.", "output", output) - return statusMsg(fmt.Sprintf("Basic cleanup: %s", output)) - } -} - -// advancedCleanupCmd is a Bubble Tea command that performs advanced cleanup. -func advancedCleanupCmd() tea.Cmd { - return func() tea.Msg { - slog.Debug("advancedCleanupCmd: Calling controller.AdvancedCleanup...") - output, err := controller.AdvancedCleanup() - if err != nil { - slog.Error("advancedCleanupCmd: Error during advanced cleanup", "error", err) - return err - } - slog.Debug("advancedCleanupCmd: Advanced cleanup completed.", "output", output) - return statusMsg(fmt.Sprintf("Advanced cleanup: %s", output)) - } -} - -// totalCleanupCmd is a Bubble Tea command that performs total cleanup. -func totalCleanupCmd() tea.Cmd { - return func() tea.Msg { - slog.Debug("totalCleanupCmd: Calling controller.TotalCleanup...") - output, err := controller.TotalCleanup() - if err != nil { - slog.Error("totalCleanupCmd: Error during total cleanup", "error", err) - return err - } - slog.Debug("totalCleanupCmd: Total cleanup completed.", "output", output) - return statusMsg(fmt.Sprintf("Total cleanup: %s", output)) - } -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go deleted file mode 100644 index 93d0456..0000000 --- a/internal/tui/tui.go +++ /dev/null @@ -1,230 +0,0 @@ -// Package tui provides the Terminal User Interface for Berth. -package tui - -import ( - "fmt" - "log/slog" - - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/rluders/berth/internal/controller" - "github.com/rluders/berth/internal/engine" -) - -var ( - currentTheme = DefaultTheme() -) - -// statusMsg is a custom type for sending status messages. -type statusMsg string - -// ViewType represents the different views in the TUI. -type ViewType int - -const ( - ContainersView ViewType = iota - ImagesView - VolumesView - NetworksView - SystemView - InspectView - LogsView -) - -// Model represents the main application model. -type Model struct { - engineType engine.EngineType - currentView ViewType - containerTable table.Model - imageTable table.Model - volumeTable table.Model - networkTable table.Model - systemInfo controller.SystemInfo - inspectViewPort viewport.Model - inspectReady bool - inspectRawContent string - logViewPort viewport.Model - logReady bool - err error - statusMessage string - showSpinner bool - spinner spinner.Model - width int - height int - currentLogContainerID string - currentInspectID string - viewStack []ViewType -} - -// InitialModel returns an initialized Model with default values. -func InitialModel() Model { - slog.Debug("InitialModel: Initializing containerColumns...") - containerColumns := []table.Column{ - {Title: "ID", Width: 12}, - {Title: "Image", Width: 20}, - {Title: "Command", Width: 30}, - {Title: "Created", Width: 15}, - {Title: "Status", Width: 20}, - {Title: "Ports", Width: 20}, - {Title: "Names", Width: 20}, - } - - slog.Debug("InitialModel: Initializing containerTable...") - containerTable := table.New( - table.WithColumns(containerColumns), - table.WithFocused(true), - table.WithHeight(10), - ) - - slog.Debug("InitialModel: Initializing imageColumns...") - imageColumns := []table.Column{ - {Title: "ID", Width: 15}, - {Title: "Repository", Width: 30}, - {Title: "Tag", Width: 15}, - {Title: "Size", Width: 10}, - {Title: "Created", Width: 20}, - } - - slog.Debug("InitialModel: Initializing imageTable...") - imageTable := table.New( - table.WithColumns(imageColumns), - table.WithFocused(false), - table.WithHeight(10), - ) - - slog.Debug("InitialModel: Initializing volumeColumns...") - volumeColumns := []table.Column{ - {Title: "Name", Width: 30}, - {Title: "Driver", Width: 15}, - {Title: "Scope", Width: 10}, - {Title: "Mountpoint", Width: 50}, - } - - slog.Debug("InitialModel: Initializing volumeTable...") - volumeTable := table.New( - table.WithColumns(volumeColumns), - table.WithFocused(false), - table.WithHeight(10), - ) - - slog.Debug("InitialModel: Initializing networkColumns...") - networkColumns := []table.Column{ - {Title: "ID", Width: 15}, - {Title: "Name", Width: 30}, - {Title: "Driver", Width: 15}, - {Title: "Scope", Width: 10}, - } - - slog.Debug("InitialModel: Initializing networkTable...") - networkTable := table.New( - table.WithColumns(networkColumns), - table.WithFocused(false), - table.WithHeight(10), - ) - - slog.Debug("InitialModel: Setting table styles...") - s := table.DefaultStyles() - s.Header = currentTheme.TableHeaderStyle - s.Selected = currentTheme.TableSelectedStyle - containerTable.SetStyles(s) - imageTable.SetStyles(s) - volumeTable.SetStyles(s) - networkTable.SetStyles(s) - - slog.Debug("InitialModel: Returning Model...") - return Model{ - engineType: engine.DetectEngine(), - currentView: ContainersView, - containerTable: containerTable, - imageTable: imageTable, - volumeTable: volumeTable, - networkTable: networkTable, - systemInfo: controller.SystemInfo{}, // Initialize with empty SystemInfo - inspectViewPort: viewport.New(0, 0), // Initialize viewport for inspect - inspectReady: false, - inspectRawContent: "", - logViewPort: viewport.New(0, 0), // Initialize viewport - spinner: spinner.New(), - } -} - -// getViewName returns the string representation of the current view. -func (m Model) getViewName() string { - slog.Debug("getViewName called") - switch m.currentView { - case ContainersView: - return "Containers" - case ImagesView: - return "Images" - case VolumesView: - return "Volumes" - case NetworksView: - return "Networks" - case SystemView: - return "System" - case InspectView: - return fmt.Sprintf("Inspect %s", m.currentInspectID) - case LogsView: - return fmt.Sprintf("Logs for %s", m.currentLogContainerID) - } - return "Unknown" -} - -// getFooterHelp returns the help text for the current view. -func (m Model) getFooterHelp() string { - slog.Debug("getFooterHelp called") - switch m.currentView { - case ContainersView: - return "1:Containers • 2:Images • 3:Volumes • 4:Networks • 5:System • s:Start • x:Stop • d:Remove • l:Logs • i:Inspect • q:Quit" - case ImagesView: - return "1:Containers • 2:Images • 3:Volumes • 4:Networks • 5:System • d:Remove • q:Quit" - case VolumesView: - return "1:Containers • 2:Images • 3:Volumes • 4:Networks • 5:System • d:Remove • q:Quit" - case NetworksView: - return "1:Containers • 2:Images • 3:Volumes • 4:Networks • 5:System • i:Inspect • q:Quit" - case SystemView: - return "1:Containers • 2:Images • 3:Volumes • 4:Networks • 5:System • b:Basic Cleanup • a:Advanced Cleanup • t:Total Cleanup • q:Quit" - case InspectView: - return "q/esc:Return • ↑/↓:Scroll" - case LogsView: - return "q/esc:Return • ↑/↓:Scroll" - } - return "q:Quit" -} - -// pushView adds the current view to the stack and sets the new view. -func (m *Model) pushView(view ViewType) { - slog.Debug("pushView called", "view", view) - m.viewStack = append(m.viewStack, m.currentView) - m.currentView = view -} - -// popView removes the current view from the stack and returns to the previous view. -func (m *Model) popView() { - slog.Debug("popView called") - if len(m.viewStack) > 0 { - m.currentView = m.viewStack[len(m.viewStack)-1] - m.viewStack = m.viewStack[:len(m.viewStack)-1] - } else { - m.currentView = ContainersView // Fallback to ContainersView if stack is empty - } -} - -// Init initializes the Bubble Tea program. -func (m Model) Init() tea.Cmd { - slog.Debug("Init: Calling fetchContainersCmd...") - cmd1 := fetchContainersCmd() - slog.Debug("Init: Calling fetchImagesCmd...") - cmd2 := fetchImagesCmd() - slog.Debug("Init: Calling fetchVolumesCmd...") - cmd3 := fetchVolumesCmd() - slog.Debug("Init: Calling fetchNetworksCmd...") - cmd4 := fetchNetworksCmd() - slog.Debug("Init: Calling fetchSystemInfoCmd...") - cmd5 := fetchSystemInfoCmd() - slog.Debug("Init: Calling spinner.Tick...") - cmd6 := m.spinner.Tick - return tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6) -} diff --git a/internal/tui/types.go b/internal/tui/types.go new file mode 100644 index 0000000..3e38fd8 --- /dev/null +++ b/internal/tui/types.go @@ -0,0 +1,33 @@ +package tui + +import ( + "github.com/rluders/berth/internal/controller" +) + +// ViewType represents the different views in the TUI. +type ViewType int + +const ( + ContainersView ViewType = iota + ImagesView + VolumesView + NetworksView + SystemView + InspectView + LogsView +) + +// Typed message types for the Update dispatcher. +type ( + containerListMsg []controller.Container + imageListMsg []controller.Image + volumeListMsg []controller.Volume + networkListMsg []controller.Network + systemInfoMsg controller.SystemInfo + logsMsg string + inspectMsg string + statusMsg string + errMsg struct{ err error } +) + +func (e errMsg) Error() string { return e.err.Error() } diff --git a/internal/tui/update.go b/internal/tui/update.go index b4f1972..c28ae82 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,10 +1,7 @@ -// Package tui provides the Terminal User Interface for Berth. package tui import ( - "bytes" - "encoding/json" - "fmt" + "log/slog" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" @@ -12,183 +9,47 @@ import ( "github.com/rluders/berth/internal/controller" ) -// Update handles incoming messages and updates the model accordingly. +// Update handles incoming messages and updates the model. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { case spinner.TickMsg: - var spinCmd tea.Cmd - m.spinner, spinCmd = m.spinner.Update(msg) - cmds = append(cmds, spinCmd) + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - if m.currentView == InspectView || m.currentView == LogsView { - m.popView() - m.logReady = false // Reset logReady when exiting logs view - return m, nil - } - return m, tea.Quit - case "1": - m.currentView = ContainersView - return m, nil - case "2": - m.currentView = ImagesView - return m, nil - case "3": - m.currentView = VolumesView - return m, nil - case "4": - m.currentView = NetworksView - return m, nil - case "5": - m.currentView = SystemView - return m, nil - } - - if m.currentView == ContainersView { - m.containerTable, cmd = m.containerTable.Update(msg) - cmds = append(cmds, cmd) - switch msg.String() { - case "s": // Start container - if len(m.containerTable.SelectedRow()) > 0 { - containerID := m.containerTable.SelectedRow()[0] - m.statusMessage = fmt.Sprintf("Starting container %s...", containerID) - m.showSpinner = true - cmds = append(cmds, startContainerCmd(containerID), m.spinner.Tick) - } - case "x": // Stop container - if len(m.containerTable.SelectedRow()) > 0 { - containerID := m.containerTable.SelectedRow()[0] - m.statusMessage = fmt.Sprintf("Stopping container %s...", containerID) - m.showSpinner = true - cmds = append(cmds, stopContainerCmd(containerID), m.spinner.Tick) - } - case "d": // Remove container - if len(m.containerTable.SelectedRow()) > 0 { - containerID := m.containerTable.SelectedRow()[0] - m.statusMessage = fmt.Sprintf("Removing container %s...", containerID) - m.showSpinner = true - cmds = append(cmds, removeContainerCmd(containerID), m.spinner.Tick) - } - case "l": // View logs - if len(m.containerTable.SelectedRow()) > 0 { - containerID := m.containerTable.SelectedRow()[0] - m.pushView(LogsView) - m.logReady = false // Reset logReady for new logs - m.statusMessage = fmt.Sprintf("Fetching logs for %s...", containerID) - m.showSpinner = true - m.currentLogContainerID = containerID // Store the container ID - cmds = append(cmds, getLogsCmd(containerID), m.spinner.Tick) - } - case "i": // Inspect container - if len(m.containerTable.SelectedRow()) > 0 { - containerID := m.containerTable.SelectedRow()[0] - m.pushView(InspectView) - m.currentInspectID = containerID - m.statusMessage = fmt.Sprintf("Inspecting container %s...", containerID) - m.showSpinner = true - cmds = append(cmds, inspectContainerCmd(containerID), m.spinner.Tick) - } - } - } else if m.currentView == ImagesView { - m.imageTable, cmd = m.imageTable.Update(msg) - cmds = append(cmds, cmd) - switch msg.String() { - case "d": // Remove image - if len(m.imageTable.SelectedRow()) > 0 { - imageID := m.imageTable.SelectedRow()[0] - m.statusMessage = fmt.Sprintf("Removing image %s...", imageID) - m.showSpinner = true - cmds = append(cmds, removeImageCmd(imageID), m.spinner.Tick) - } - } - } else if m.currentView == VolumesView { - m.volumeTable, cmd = m.volumeTable.Update(msg) - cmds = append(cmds, cmd) - switch msg.String() { - case "d": // Remove volume - if len(m.volumeTable.SelectedRow()) > 0 { - volumeName := m.volumeTable.SelectedRow()[0] - m.statusMessage = fmt.Sprintf("Removing volume %s...", volumeName) - m.showSpinner = true - cmds = append(cmds, removeVolumeCmd(volumeName), m.spinner.Tick) - } - } - } else if m.currentView == NetworksView { - m.networkTable, cmd = m.networkTable.Update(msg) - cmds = append(cmds, cmd) - switch msg.String() { - case "i": // Inspect network - if len(m.networkTable.SelectedRow()) > 0 { - networkID := m.networkTable.SelectedRow()[0] - m.currentView = InspectView // Re-use inspect view for network inspect - m.statusMessage = fmt.Sprintf("Inspecting network %s...", networkID) - m.showSpinner = true - cmds = append(cmds, inspectNetworkCmd(networkID), m.spinner.Tick) - } - } - } else if m.currentView == SystemView { - switch msg.String() { - case "b": // Basic Cleanup - m.statusMessage = "Performing basic cleanup..." - m.showSpinner = true - cmds = append(cmds, basicCleanupCmd(), m.spinner.Tick) - case "a": // Advanced Cleanup - m.statusMessage = "Performing advanced cleanup..." - m.showSpinner = true - cmds = append(cmds, advancedCleanupCmd(), m.spinner.Tick) - case "t": // Total Cleanup - m.statusMessage = "Performing total cleanup..." - m.showSpinner = true - cmds = append(cmds, totalCleanupCmd(), m.spinner.Tick) - } - } else if m.currentView == InspectView { - // Delegate update to the inspect viewport - newModel, cmd := m.inspectViewPort.Update(msg) - m.inspectViewPort = newModel - cmds = append(cmds, cmd) - } else if m.currentView == LogsView { - // Delegate update to the log viewport - newModel, cmd := m.logViewPort.Update(msg) - m.logViewPort = newModel - cmds = append(cmds, cmd) - } + newM, cmd := m.handleKeyMsg(msg) + return newM, cmd case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - // Adjust table heights based on window size - tableHeight := msg.Height - 10 // Header, footer, status, and some padding - if tableHeight < 0 { - tableHeight = 0 + contentH := m.contentHeight() + m.containerTable.SetHeight(contentH) + m.imageTable.SetHeight(contentH) + m.volumeTable.SetHeight(contentH) + m.networkTable.SetHeight(contentH) + + viewW := msg.Width - currentTheme.AppStyle.GetHorizontalFrameSize() - 4 + if viewW < 0 { + viewW = 0 } - m.containerTable.SetHeight(tableHeight) - m.imageTable.SetHeight(tableHeight) - m.volumeTable.SetHeight(tableHeight) - m.networkTable.SetHeight(tableHeight) + m.inspectViewPort.Width = viewW + m.inspectViewPort.Height = contentH + m.logViewPort.Width = viewW + m.logViewPort.Height = contentH - // Adjust viewport sizes - if m.currentView == InspectView && !m.inspectReady { - // Pretty print JSON - var prettyJSON bytes.Buffer - if err := json.Indent(&prettyJSON, []byte(m.inspectRawContent), "", " "); err != nil { - m.inspectViewPort.SetContent(m.inspectRawContent + "\n\n(Error formatting JSON: " + err.Error() + ")") - } else { - m.inspectViewPort.SetContent(prettyJSON.String()) - } + // If inspect content arrived before window size, render it now. + if m.currentView == InspectView && !m.inspectReady && m.inspectRawContent != "" { + m.inspectViewPort.SetContent(prettyJSON(m.inspectRawContent)) m.inspectReady = true } - m.inspectViewPort.Width = msg.Width - 4 - m.inspectViewPort.Height = msg.Height - 6 - m.logViewPort.Width = msg.Width - 4 - m.logViewPort.Height = msg.Height - 6 - case []controller.Container: + case containerListMsg: + slog.Debug("containerListMsg received", "count", len(msg)) rows := make([]table.Row, len(msg)) for i, c := range msg { rows[i] = table.Row{c.ID, c.Image, c.Command, c.Created, c.Status, c.Ports, c.Names} @@ -196,7 +57,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.containerTable.SetRows(rows) m.showSpinner = false m.statusMessage = "" - case []controller.Image: + + case imageListMsg: + slog.Debug("imageListMsg received", "count", len(msg)) rows := make([]table.Row, len(msg)) for i, img := range msg { rows[i] = table.Row{img.ID, img.Repository, img.Tag, img.Size, img.Created} @@ -204,7 +67,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.imageTable.SetRows(rows) m.showSpinner = false m.statusMessage = "" - case []controller.Volume: + + case volumeListMsg: + slog.Debug("volumeListMsg received", "count", len(msg)) rows := make([]table.Row, len(msg)) for i, vol := range msg { rows[i] = table.Row{vol.Name, vol.Driver, vol.Scope, vol.Mountpoint} @@ -212,7 +77,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.volumeTable.SetRows(rows) m.showSpinner = false m.statusMessage = "" - case []controller.Network: + + case networkListMsg: + slog.Debug("networkListMsg received", "count", len(msg)) rows := make([]table.Row, len(msg)) for i, net := range msg { rows[i] = table.Row{net.ID, net.Name, net.Driver, net.Scope} @@ -220,40 +87,46 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.networkTable.SetRows(rows) m.showSpinner = false m.statusMessage = "" - case controller.SystemInfo: - m.systemInfo = msg + + case systemInfoMsg: + slog.Debug("systemInfoMsg received") + m.systemInfo = controller.SystemInfo(msg) m.showSpinner = false m.statusMessage = "" - case string: // For logs or inspect output - m.statusMessage = "" - m.showSpinner = false - if m.currentView == InspectView { - // Pretty print JSON - var prettyJSON bytes.Buffer - if err := json.Indent(&prettyJSON, []byte(msg), "", " "); err != nil { - m.inspectViewPort.SetContent(msg + "\n\n(Error formatting JSON: " + err.Error() + ")") - } else { - m.inspectViewPort.SetContent(prettyJSON.String()) - } + case inspectMsg: + slog.Debug("inspectMsg received") + m.showSpinner = false + m.statusMessage = "" + m.inspectRawContent = string(msg) + if m.width > 0 { + m.inspectViewPort.SetContent(prettyJSON(string(msg))) m.inspectReady = true - // Manually send a WindowSizeMsg to the inspectViewPort to trigger content rendering - cmds = append(cmds, func() tea.Msg { - return tea.WindowSizeMsg{Width: m.width, Height: m.height} - }) - } else if m.currentView == LogsView { - m.logViewPort.SetContent(msg) - m.logViewPort.GotoBottom() - m.logReady = true } - case error: - m.err = msg + // Trigger viewport to re-measure in case window size already arrived. + cmds = append(cmds, func() tea.Msg { + return tea.WindowSizeMsg{Width: m.width, Height: m.height} + }) + + case logsMsg: + slog.Debug("logsMsg received") m.showSpinner = false m.statusMessage = "" - case statusMsg: // For status messages after actions + m.logViewPort.SetContent(string(msg)) + m.logViewPort.GotoBottom() + m.logReady = true + + case statusMsg: + slog.Debug("statusMsg received", "msg", string(msg)) m.statusMessage = string(msg) m.showSpinner = false - cmds = append(cmds, tea.Batch(fetchContainersCmd(), fetchImagesCmd(), fetchVolumesCmd(), fetchNetworksCmd(), fetchSystemInfoCmd())) + cmds = append(cmds, fetchAllCmd()) + + case errMsg: + slog.Error("errMsg received", "error", msg.err) + m.err = msg.err + m.showSpinner = false + m.statusMessage = "" } return m, tea.Batch(cmds...) diff --git a/internal/tui/update_keyboard.go b/internal/tui/update_keyboard.go new file mode 100644 index 0000000..1537526 --- /dev/null +++ b/internal/tui/update_keyboard.go @@ -0,0 +1,195 @@ +package tui + +import ( + "bytes" + "encoding/json" + "log/slog" + + tea "github.com/charmbracelet/bubbletea" +) + +// handleKeyMsg dispatches keyboard events to the appropriate handler. +func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { + slog.Debug("handleKeyMsg", "key", msg.String()) + + // Global keys + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "q", "esc": + if m.currentView == InspectView || m.currentView == LogsView { + m.popView() + m.logReady = false + return m, nil + } + return m, tea.Quit + case "1": + m.currentView = ContainersView + return m, nil + case "2": + m.currentView = ImagesView + return m, nil + case "3": + m.currentView = VolumesView + return m, nil + case "4": + m.currentView = NetworksView + return m, nil + case "5": + m.currentView = SystemView + return m, nil + } + + // Per-view keys + switch m.currentView { + case ContainersView: + return m.handleContainersKey(msg) + case ImagesView: + return m.handleImagesKey(msg) + case VolumesView: + return m.handleVolumesKey(msg) + case NetworksView: + return m.handleNetworksKey(msg) + case SystemView: + return m.handleSystemKey(msg) + case InspectView: + return m.handleInspectKey(msg) + case LogsView: + return m.handleLogsKey(msg) + } + + return m, nil +} + +func (m Model) handleContainersKey(msg tea.KeyMsg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + m.containerTable, cmd = m.containerTable.Update(msg) + cmds = append(cmds, cmd) + + if len(m.containerTable.SelectedRow()) == 0 { + return m, tea.Batch(cmds...) + } + id := m.containerTable.SelectedRow()[0] + + switch msg.String() { + case "s": + m.statusMessage = "Starting container " + id + "..." + m.showSpinner = true + cmds = append(cmds, startContainerCmd(id), m.spinner.Tick) + case "x": + m.statusMessage = "Stopping container " + id + "..." + m.showSpinner = true + cmds = append(cmds, stopContainerCmd(id), m.spinner.Tick) + case "d": + m.statusMessage = "Removing container " + id + "..." + m.showSpinner = true + cmds = append(cmds, removeContainerCmd(id), m.spinner.Tick) + case "l": + m.pushView(LogsView) + m.logReady = false + m.currentLogContainerID = id + m.statusMessage = "Fetching logs for " + id + "..." + m.showSpinner = true + cmds = append(cmds, getLogsCmd(id), m.spinner.Tick) + case "i": + m.pushView(InspectView) + m.currentInspectID = id + m.inspectReady = false + m.statusMessage = "Inspecting container " + id + "..." + m.showSpinner = true + cmds = append(cmds, inspectContainerCmd(id), m.spinner.Tick) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) handleImagesKey(msg tea.KeyMsg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + m.imageTable, cmd = m.imageTable.Update(msg) + cmds = append(cmds, cmd) + + if msg.String() == "d" && len(m.imageTable.SelectedRow()) > 0 { + id := m.imageTable.SelectedRow()[0] + m.statusMessage = "Removing image " + id + "..." + m.showSpinner = true + cmds = append(cmds, removeImageCmd(id), m.spinner.Tick) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) handleVolumesKey(msg tea.KeyMsg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + m.volumeTable, cmd = m.volumeTable.Update(msg) + cmds = append(cmds, cmd) + + if msg.String() == "d" && len(m.volumeTable.SelectedRow()) > 0 { + name := m.volumeTable.SelectedRow()[0] + m.statusMessage = "Removing volume " + name + "..." + m.showSpinner = true + cmds = append(cmds, removeVolumeCmd(name), m.spinner.Tick) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) handleNetworksKey(msg tea.KeyMsg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + m.networkTable, cmd = m.networkTable.Update(msg) + cmds = append(cmds, cmd) + + if msg.String() == "i" && len(m.networkTable.SelectedRow()) > 0 { + id := m.networkTable.SelectedRow()[0] + m.pushView(InspectView) + m.currentInspectID = id + m.inspectReady = false + m.statusMessage = "Inspecting network " + id + "..." + m.showSpinner = true + cmds = append(cmds, inspectNetworkCmd(id), m.spinner.Tick) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) handleSystemKey(msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "b": + m.statusMessage = "Performing basic cleanup..." + m.showSpinner = true + return m, tea.Batch(basicCleanupCmd(), m.spinner.Tick) + case "a": + m.statusMessage = "Performing advanced cleanup..." + m.showSpinner = true + return m, tea.Batch(advancedCleanupCmd(), m.spinner.Tick) + case "t": + m.statusMessage = "Performing total cleanup..." + m.showSpinner = true + return m, tea.Batch(totalCleanupCmd(), m.spinner.Tick) + } + return m, nil +} + +func (m Model) handleInspectKey(msg tea.KeyMsg) (Model, tea.Cmd) { + var cmd tea.Cmd + m.inspectViewPort, cmd = m.inspectViewPort.Update(msg) + return m, cmd +} + +func (m Model) handleLogsKey(msg tea.KeyMsg) (Model, tea.Cmd) { + var cmd tea.Cmd + m.logViewPort, cmd = m.logViewPort.Update(msg) + return m, cmd +} + +// prettyJSON formats raw JSON content, falling back to raw on error. +func prettyJSON(raw string) string { + var buf bytes.Buffer + if err := json.Indent(&buf, []byte(raw), "", " "); err != nil { + return raw + "\n\n(Error formatting JSON: " + err.Error() + ")" + } + return buf.String() +} diff --git a/internal/tui/volume.go b/internal/tui/volume.go deleted file mode 100644 index 6b57cc7..0000000 --- a/internal/tui/volume.go +++ /dev/null @@ -1,30 +0,0 @@ -// Package tui provides the Terminal User Interface for Berth. -package tui - -import ( - "fmt" - tea "github.com/charmbracelet/bubbletea" - "github.com/rluders/berth/internal/controller" -) - -// fetchVolumesCmd is a Bubble Tea command that fetches a list of volumes. -func fetchVolumesCmd() tea.Cmd { - return func() tea.Msg { - volumes, err := controller.ListVolumes() - if err != nil { - return err - } - return volumes - } -} - -// removeVolumeCmd is a Bubble Tea command that removes a volume. -func removeVolumeCmd(name string) tea.Cmd { - return func() tea.Msg { - err := controller.RemoveVolume(name) - if err != nil { - return err - } - return statusMsg(fmt.Sprintf("Volume %s removed.", name)) - } -} From 83caf13b6d78611e36757dd0c75d5547e21da1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20L=C3=BCders?= Date: Fri, 1 May 2026 08:05:38 -0300 Subject: [PATCH 02/19] better ui --- cmd/berth/main.go | 2 +- go.mod | 2 + go.sum | 4 + internal/controller/container.go | 307 +++++++++++++++++++++++-- internal/controller/image.go | 10 + internal/engine/client.go | 44 +++- internal/service/container.go | 12 + internal/tui/commands.go | 149 +++++++++--- internal/tui/keybindings.go | 236 +++++++++++++++++++ internal/tui/keymaps.go | 138 +++++++++++ internal/tui/modal.go | 253 ++++++++++++++++++++ internal/tui/model.go | 304 ++++++++++++++++++------ internal/tui/styles.go | 352 ++++++++++++++++++++++++++-- internal/tui/types.go | 35 ++- internal/tui/update.go | 246 +++++++++++++++++--- internal/tui/update_keyboard.go | 280 +++++++++++++++++----- internal/tui/update_mouse.go | 181 +++++++++++++++ internal/tui/view.go | 382 ++++++++++++++++++++++++++++--- internal/utils/time.go | 35 +++ 19 files changed, 2719 insertions(+), 253 deletions(-) create mode 100644 internal/tui/keybindings.go create mode 100644 internal/tui/keymaps.go create mode 100644 internal/tui/modal.go create mode 100644 internal/tui/update_mouse.go create mode 100644 internal/utils/time.go diff --git a/cmd/berth/main.go b/cmd/berth/main.go index ed1fce7..d8f7732 100644 --- a/cmd/berth/main.go +++ b/cmd/berth/main.go @@ -42,7 +42,7 @@ func main() { } }() slog.Debug("Initializing Bubble Tea program...") - program = tea.NewProgram(tui.InitialModel(), tea.WithAltScreen()) + program = tea.NewProgram(tui.InitialModel(), tea.WithAltScreen(), tea.WithMouseCellMotion()) }() slog.Debug("Running Bubble Tea program...") diff --git a/go.mod b/go.mod index 1c04990..144b527 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,10 @@ require ( require ( github.com/Microsoft/go-winio v0.4.14 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/go.sum b/go.sum index 0494513..61fb458 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -21,6 +23,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= diff --git a/internal/controller/container.go b/internal/controller/container.go index 9abe8dc..9225a55 100644 --- a/internal/controller/container.go +++ b/internal/controller/container.go @@ -2,6 +2,7 @@ package controller import ( + "bufio" "context" "encoding/json" "fmt" @@ -9,6 +10,9 @@ import ( "strings" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/pkg/stdcopy" + "time" + "github.com/rluders/berth/internal/engine" "github.com/rluders/berth/internal/service" ) @@ -18,7 +22,6 @@ var containerService service.ContainerService func init() { cli, err := engine.NewClient() if err != nil { - // Handle error, perhaps log it or panic if it's unrecoverable panic(fmt.Errorf("failed to create Docker client: %w", err)) } containerService = service.NewContainerService(cli) @@ -26,13 +29,82 @@ func init() { // Container represents a container's simplified information. type Container struct { - ID string - Image string - Command string - Created string - Status string - Ports string - Names string + ID string + Image string + Command string + CreatedAt int64 + Status string + Ports string + Names string + Labels map[string]string +} + +// ContainerDetails holds structured inspection data for the details view. +type ContainerDetails struct { + ID string + Name string + Image string + Command string + Env []string + Ports []PortBinding + Mounts []Mount + Networks []NetworkEndpoint + State string + Created string +} + +// PortBinding represents a single port mapping. +type PortBinding struct { + ContainerPort string + Protocol string + HostIP string + HostPort string +} + +// Mount represents a volume/bind mount. +type Mount struct { + Type string + Source string + Destination string + Mode string + RW bool +} + +// NetworkEndpoint represents a container's connection to a network. +type NetworkEndpoint struct { + Name string + IPAddress string + Gateway string +} + +// ContainerStat holds live resource usage for a container. +type ContainerStat struct { + CPUPercent float64 + MemUsage uint64 + MemLimit uint64 +} + +// statsJSON is a minimal struct to decode Docker stats API response. +type statsJSON struct { + CPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + PercpuUsage []uint64 `json:"percpu_usage"` + } `json:"cpu_usage"` + SystemCPUUsage uint64 `json:"system_cpu_usage"` + OnlineCPUs uint32 `json:"online_cpus"` + } `json:"cpu_stats"` + PreCPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemCPUUsage uint64 `json:"system_cpu_usage"` + } `json:"precpu_stats"` + MemoryStats struct { + Usage uint64 `json:"usage"` + Limit uint64 `json:"limit"` + Stats map[string]uint64 `json:"stats"` + } `json:"memory_stats"` } // ListContainers lists all running and stopped containers. @@ -44,14 +116,16 @@ func ListContainers() ([]Container, error) { var result []Container for _, c := range containers { + ports := formatPorts(c.Ports) result = append(result, Container{ - ID: c.ID[:12], - Image: c.Image, - Command: c.Command, - Created: fmt.Sprintf("%d", c.Created), - Status: c.Status, - Ports: fmt.Sprintf("%v", c.Ports), - Names: strings.Join(c.Names, ","), + ID: c.ID[:12], + Image: c.Image, + Command: c.Command, + CreatedAt: c.Created, + Status: c.Status, + Ports: ports, + Names: strings.TrimPrefix(strings.Join(c.Names, ","), "/"), + Labels: c.Labels, }) } @@ -68,29 +142,77 @@ func StopContainer(idOrName string) error { return containerService.StopContainer(context.Background(), idOrName, container.StopOptions{}) } +// RestartContainer restarts a container by its ID or name. +func RestartContainer(idOrName string) error { + return containerService.RestartContainer(context.Background(), idOrName, container.StopOptions{}) +} + // RemoveContainer removes a container by its ID or name. func RemoveContainer(idOrName string) error { - return containerService.RemoveContainer(context.Background(), idOrName, container.RemoveOptions{}) + return containerService.RemoveContainer(context.Background(), idOrName, container.RemoveOptions{Force: true}) } -// GetContainerLogs retrieves the logs of a container. +// GetContainerLogs retrieves the logs of a container (one-shot). func GetContainerLogs(idOrName string) (string, error) { - out, err := containerService.ContainerLogs(context.Background(), idOrName, container.LogsOptions{ShowStdout: true, ShowStderr: true}) + out, err := containerService.ContainerLogs(context.Background(), idOrName, container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Tail: "500", + }) if err != nil { return "", fmt.Errorf("failed to get logs for container %s: %w", idOrName, err) } defer out.Close() - buf := new(strings.Builder) - _, err = io.Copy(buf, out) - if err != nil { - return "", fmt.Errorf("failed to read logs for container %s: %w", idOrName, err) + var buf strings.Builder + if _, err = stdcopy.StdCopy(&buf, &buf, out); err != nil { + // Fallback for TTY containers (no multiplexing header) + buf.Reset() + if _, err2 := io.Copy(&buf, out); err2 != nil { + return "", fmt.Errorf("failed to read logs: %w", err2) + } } return buf.String(), nil } -// InspectContainer inspects a container by its ID or name and returns its raw JSON output. +// StreamContainerLogs streams container logs line by line into ch, closing ch when done or ctx cancelled. +func StreamContainerLogs(ctx context.Context, idOrName string, ch chan<- string) { + defer close(ch) + + out, err := containerService.ContainerLogs(ctx, idOrName, container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + Tail: "200", + }) + if err != nil { + return + } + defer out.Close() + + pr, pw := io.Pipe() + go func() { + defer pw.Close() + if _, err := stdcopy.StdCopy(pw, pw, out); err != nil { + // TTY container — fallback to raw copy + if _, err2 := io.Copy(pw, out); err2 != nil { + return + } + } + }() + + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + case ch <- scanner.Text(): + } + } +} + +// InspectContainer inspects a container and returns raw JSON. func InspectContainer(idOrName string) (string, error) { inspect, err := containerService.ContainerInspect(context.Background(), idOrName) if err != nil { @@ -104,3 +226,142 @@ func InspectContainer(idOrName string) (string, error) { return string(jsonBytes), nil } + +// GetContainerDetails returns structured inspection data for the details view. +func GetContainerDetails(idOrName string) (ContainerDetails, error) { + inspect, err := containerService.ContainerInspect(context.Background(), idOrName) + if err != nil { + return ContainerDetails{}, fmt.Errorf("failed to inspect container %s: %w", idOrName, err) + } + + name := inspect.Name + if len(name) > 0 && name[0] == '/' { + name = name[1:] + } + + details := ContainerDetails{ + ID: inspect.ID[:12], + Name: name, + Image: inspect.Config.Image, + Command: strings.Join(inspect.Config.Cmd, " "), + Env: inspect.Config.Env, + State: inspect.State.Status, + Created: formatCreated(inspect.Created), + } + + for _, m := range inspect.Mounts { + details.Mounts = append(details.Mounts, Mount{ + Type: string(m.Type), + Source: m.Source, + Destination: m.Destination, + Mode: m.Mode, + RW: m.RW, + }) + } + + for netName, ep := range inspect.NetworkSettings.Networks { + details.Networks = append(details.Networks, NetworkEndpoint{ + Name: netName, + IPAddress: ep.IPAddress, + Gateway: ep.Gateway, + }) + } + + for portProto, bindings := range inspect.HostConfig.PortBindings { + portStr := string(portProto) + parts := strings.SplitN(portStr, "/", 2) + containerPort := parts[0] + protocol := "" + if len(parts) > 1 { + protocol = parts[1] + } + for _, b := range bindings { + details.Ports = append(details.Ports, PortBinding{ + ContainerPort: containerPort, + Protocol: protocol, + HostIP: b.HostIP, + HostPort: b.HostPort, + }) + } + } + + return details, nil +} + +// GetContainerStats returns one-shot CPU/memory stats for a container. +func GetContainerStats(idOrName string) (ContainerStat, error) { + resp, err := containerService.ContainerStats(context.Background(), idOrName, false) + if err != nil { + return ContainerStat{}, err + } + defer resp.Body.Close() + + var s statsJSON + if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { + return ContainerStat{}, err + } + + cpuDelta := float64(s.CPUStats.CPUUsage.TotalUsage) - float64(s.PreCPUStats.CPUUsage.TotalUsage) + systemDelta := float64(s.CPUStats.SystemCPUUsage) - float64(s.PreCPUStats.SystemCPUUsage) + numCPUs := float64(s.CPUStats.OnlineCPUs) + if numCPUs == 0 { + numCPUs = float64(len(s.CPUStats.CPUUsage.PercpuUsage)) + } + + var cpuPercent float64 + if systemDelta > 0 && cpuDelta > 0 { + cpuPercent = (cpuDelta / systemDelta) * numCPUs * 100.0 + } + + memUsage := s.MemoryStats.Usage + if cache, ok := s.MemoryStats.Stats["cache"]; ok { + memUsage -= cache + } + + return ContainerStat{ + CPUPercent: cpuPercent, + MemUsage: memUsage, + MemLimit: s.MemoryStats.Limit, + }, nil +} + +// formatCreated parses a Docker RFC3339 created timestamp into a human-readable age. +func formatCreated(s string) string { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return s + } + d := time.Since(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds ago", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} + +// formatPorts converts Docker port list to a compact string. +func formatPorts(ports []container.Port) string { + if len(ports) == 0 { + return "" + } + seen := make(map[string]bool) + var parts []string + for _, p := range ports { + var s string + if p.PublicPort > 0 { + s = fmt.Sprintf("%d->%d/%s", p.PublicPort, p.PrivatePort, p.Type) + } else { + s = fmt.Sprintf("%d/%s", p.PrivatePort, p.Type) + } + if !seen[s] { + seen[s] = true + parts = append(parts, s) + } + } + return strings.Join(parts, ", ") +} diff --git a/internal/controller/image.go b/internal/controller/image.go index 3cd28ed..2f53228 100644 --- a/internal/controller/image.go +++ b/internal/controller/image.go @@ -6,6 +6,7 @@ import ( "fmt" dockerImageTypes "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/filters" "github.com/rluders/berth/internal/engine" "github.com/rluders/berth/internal/service" ) @@ -59,3 +60,12 @@ func RemoveImage(idOrName string) error { _, err := imageService.ImageRemove(context.Background(), idOrName, dockerImageTypes.RemoveOptions{}) return err } + +// PruneImages removes dangling (unused) images. +func PruneImages() (string, error) { + report, err := systemService.ImagesPrune(context.Background(), filters.NewArgs()) + if err != nil { + return "", fmt.Errorf("failed to prune images: %w", err) + } + return fmt.Sprintf("Pruned %d image(s), reclaimed %d bytes", len(report.ImagesDeleted), report.SpaceReclaimed), nil +} diff --git a/internal/engine/client.go b/internal/engine/client.go index 602fa65..5e16ec3 100644 --- a/internal/engine/client.go +++ b/internal/engine/client.go @@ -2,14 +2,52 @@ package engine import ( + "fmt" + "os" + "path/filepath" + "github.com/docker/docker/client" ) -// NewClient creates a new Docker client. +// NewClient creates a Docker/Podman client using the detected engine. +// For Podman, uses the user socket path when DOCKER_HOST is not set. func NewClient() (*client.Client, error) { - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + opts := []client.Opt{client.WithAPIVersionNegotiation()} + + if detectedEngine == Podman && os.Getenv("DOCKER_HOST") == "" { + socketPath := podmanSocketPath() + if socketPath != "" { + opts = append(opts, client.WithHost("unix://"+socketPath)) + } + } else { + opts = append(opts, client.FromEnv) + } + + cli, err := client.NewClientWithOpts(opts...) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create client for %s: %w", detectedEngine, err) } return cli, nil } + +// podmanSocketPath returns the Podman socket path for the current user. +func podmanSocketPath() string { + // Rootless Podman (preferred) + if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" { + p := filepath.Join(xdg, "podman", "podman.sock") + if _, err := os.Stat(p); err == nil { + return p + } + } + // Fallback: /run/user//podman/podman.sock + uid := fmt.Sprintf("%d", os.Getuid()) + p := filepath.Join("/run/user", uid, "podman", "podman.sock") + if _, err := os.Stat(p); err == nil { + return p + } + // Root Podman + if _, err := os.Stat("/run/podman/podman.sock"); err == nil { + return "/run/podman/podman.sock" + } + return "" +} diff --git a/internal/service/container.go b/internal/service/container.go index e05abd0..77fe9ff 100644 --- a/internal/service/container.go +++ b/internal/service/container.go @@ -14,9 +14,11 @@ type ContainerService interface { ListContainers(ctx context.Context, options containerTypes.ListOptions) ([]containerTypes.Summary, error) StartContainer(ctx context.Context, containerID string, options containerTypes.StartOptions) error StopContainer(ctx context.Context, containerID string, options containerTypes.StopOptions) error + RestartContainer(ctx context.Context, containerID string, options containerTypes.StopOptions) error RemoveContainer(ctx context.Context, containerID string, options containerTypes.RemoveOptions) error ContainerLogs(ctx context.Context, containerID string, options containerTypes.LogsOptions) (io.ReadCloser, error) ContainerInspect(ctx context.Context, containerID string) (containerTypes.InspectResponse, error) + ContainerStats(ctx context.Context, containerID string, stream bool) (containerTypes.StatsResponseReader, error) } // dockerContainerService is a concrete implementation of ContainerService. @@ -48,6 +50,11 @@ func (s *dockerContainerService) StopContainer(ctx context.Context, containerID return s.client.ContainerStop(ctx, containerID, options) } +// RestartContainer restarts a container. +func (s *dockerContainerService) RestartContainer(ctx context.Context, containerID string, options containerTypes.StopOptions) error { + return s.client.ContainerRestart(ctx, containerID, options) +} + // RemoveContainer removes a container. func (s *dockerContainerService) RemoveContainer(ctx context.Context, containerID string, options containerTypes.RemoveOptions) error { return s.client.ContainerRemove(ctx, containerID, options) @@ -66,3 +73,8 @@ func (s *dockerContainerService) ContainerInspect(ctx context.Context, container } return inspect, nil } + +// ContainerStats returns one-shot or streaming stats for a container. +func (s *dockerContainerService) ContainerStats(ctx context.Context, containerID string, stream bool) (containerTypes.StatsResponseReader, error) { + return s.client.ContainerStats(ctx, containerID, stream) +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index a55e8b8..37095f9 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -1,13 +1,20 @@ package tui import ( + "context" "fmt" "log/slog" + "os" + "os/exec" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/rluders/berth/internal/controller" + "github.com/rluders/berth/internal/engine" ) +// ── Fetch commands ──────────────────────────────────────────────────────────── + func fetchContainersCmd() tea.Cmd { return func() tea.Msg { slog.Debug("fetchContainersCmd called") @@ -78,11 +85,22 @@ func fetchAllCmd() tea.Cmd { ) } +// ── Periodic tickers ────────────────────────────────────────────────────────── + +func statsTickCmd() tea.Cmd { + return tea.Tick(3*time.Second, func(time.Time) tea.Msg { return statsTickMsg{} }) +} + +func refreshTickCmd() tea.Cmd { + return tea.Tick(5*time.Second, func(time.Time) tea.Msg { return refreshTickMsg{} }) +} + +// ── Container action commands ───────────────────────────────────────────────── + func startContainerCmd(idOrName string) tea.Cmd { return func() tea.Msg { - slog.Debug("startContainerCmd called", "id", idOrName) + slog.Debug("startContainerCmd", "id", idOrName) if err := controller.StartContainer(idOrName); err != nil { - slog.Error("startContainerCmd error", "id", idOrName, "error", err) return errMsg{err} } return statusMsg(fmt.Sprintf("Container %s started.", idOrName)) @@ -91,116 +109,191 @@ func startContainerCmd(idOrName string) tea.Cmd { func stopContainerCmd(idOrName string) tea.Cmd { return func() tea.Msg { - slog.Debug("stopContainerCmd called", "id", idOrName) + slog.Debug("stopContainerCmd", "id", idOrName) if err := controller.StopContainer(idOrName); err != nil { - slog.Error("stopContainerCmd error", "id", idOrName, "error", err) return errMsg{err} } return statusMsg(fmt.Sprintf("Container %s stopped.", idOrName)) } } +func restartContainerCmd(idOrName string) tea.Cmd { + return func() tea.Msg { + slog.Debug("restartContainerCmd", "id", idOrName) + if err := controller.RestartContainer(idOrName); err != nil { + return errMsg{err} + } + return statusMsg(fmt.Sprintf("Container %s restarted.", idOrName)) + } +} + func removeContainerCmd(idOrName string) tea.Cmd { return func() tea.Msg { - slog.Debug("removeContainerCmd called", "id", idOrName) + slog.Debug("removeContainerCmd", "id", idOrName) if err := controller.RemoveContainer(idOrName); err != nil { - slog.Error("removeContainerCmd error", "id", idOrName, "error", err) return errMsg{err} } return statusMsg(fmt.Sprintf("Container %s removed.", idOrName)) } } -func getLogsCmd(idOrName string) tea.Cmd { +func fetchDetailsCmd(idOrName string) tea.Cmd { return func() tea.Msg { - slog.Debug("getLogsCmd called", "id", idOrName) - logs, err := controller.GetContainerLogs(idOrName) + slog.Debug("fetchDetailsCmd", "id", idOrName) + details, err := controller.GetContainerDetails(idOrName) if err != nil { - slog.Error("getLogsCmd error", "id", idOrName, "error", err) return errMsg{err} } - return logsMsg(logs) + return detailsMsg(details) } } func inspectContainerCmd(idOrName string) tea.Cmd { return func() tea.Msg { - slog.Debug("inspectContainerCmd called", "id", idOrName) + slog.Debug("inspectContainerCmd", "id", idOrName) output, err := controller.InspectContainer(idOrName) if err != nil { - slog.Error("inspectContainerCmd error", "id", idOrName, "error", err) return errMsg{err} } return inspectMsg(output) } } +// ── Log streaming ───────────────────────────────────────────────────────────── + +func startLogStreamCmd(id string) (chan string, context.CancelFunc, tea.Cmd) { + ch := make(chan string, 500) + ctx, cancel := context.WithCancel(context.Background()) + go controller.StreamContainerLogs(ctx, id, ch) + return ch, cancel, waitForLogLineCmd(ch) +} + +func waitForLogLineCmd(ch <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return logStreamDoneMsg{} + } + return logChunkMsg(line) + } +} + +// ── Stats ───────────────────────────────────────────────────────────────────── + +func fetchStatsCmd(ids []string) tea.Cmd { + return func() tea.Msg { + result := make(map[string]controller.ContainerStat) + for _, id := range ids { + stat, err := controller.GetContainerStats(id) + if err == nil { + result[id] = stat + } + } + return containerStatsMsg(result) + } +} + +// ── Image commands ──────────────────────────────────────────────────────────── + func removeImageCmd(idOrName string) tea.Cmd { return func() tea.Msg { - slog.Debug("removeImageCmd called", "id", idOrName) + slog.Debug("removeImageCmd", "id", idOrName) if err := controller.RemoveImage(idOrName); err != nil { - slog.Error("removeImageCmd error", "id", idOrName, "error", err) return errMsg{err} } return statusMsg(fmt.Sprintf("Image %s removed.", idOrName)) } } +func pruneImagesCmd() tea.Cmd { + return func() tea.Msg { + slog.Debug("pruneImagesCmd called") + msg, err := controller.PruneImages() + if err != nil { + return errMsg{err} + } + return statusMsg(msg) + } +} + +// ── Volume commands ─────────────────────────────────────────────────────────── + func removeVolumeCmd(name string) tea.Cmd { return func() tea.Msg { - slog.Debug("removeVolumeCmd called", "name", name) + slog.Debug("removeVolumeCmd", "name", name) if err := controller.RemoveVolume(name); err != nil { - slog.Error("removeVolumeCmd error", "name", name, "error", err) return errMsg{err} } return statusMsg(fmt.Sprintf("Volume %s removed.", name)) } } +// ── Network commands ────────────────────────────────────────────────────────── + func inspectNetworkCmd(idOrName string) tea.Cmd { return func() tea.Msg { - slog.Debug("inspectNetworkCmd called", "id", idOrName) + slog.Debug("inspectNetworkCmd", "id", idOrName) output, err := controller.InspectNetwork(idOrName) if err != nil { - slog.Error("inspectNetworkCmd error", "id", idOrName, "error", err) return errMsg{err} } return inspectMsg(output) } } +// ── Progress tick ───────────────────────────────────────────────────────────── + +func progressTickCmd() tea.Cmd { + return tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg { return progressTickMsg{} }) +} + +// ── System cleanup commands ─────────────────────────────────────────────────── + func basicCleanupCmd() tea.Cmd { return func() tea.Msg { - slog.Debug("basicCleanupCmd called") output, err := controller.BasicCleanup() if err != nil { - slog.Error("basicCleanupCmd error", "error", err) return errMsg{err} } - return statusMsg(fmt.Sprintf("Basic cleanup: %s", output)) + return progressMsg{percent: 1.0, label: "Basic cleanup: " + output, done: true} } } func advancedCleanupCmd() tea.Cmd { return func() tea.Msg { - slog.Debug("advancedCleanupCmd called") output, err := controller.AdvancedCleanup() if err != nil { - slog.Error("advancedCleanupCmd error", "error", err) return errMsg{err} } - return statusMsg(fmt.Sprintf("Advanced cleanup: %s", output)) + return progressMsg{percent: 1.0, label: "Advanced cleanup: " + output, done: true} } } func totalCleanupCmd() tea.Cmd { return func() tea.Msg { - slog.Debug("totalCleanupCmd called") output, err := controller.TotalCleanup() if err != nil { - slog.Error("totalCleanupCmd error", "error", err) return errMsg{err} } - return statusMsg(fmt.Sprintf("Total cleanup: %s", output)) + return progressMsg{percent: 1.0, label: "Total cleanup: " + output, done: true} } } + +// ── Exec shell ──────────────────────────────────────────────────────────────── + +func execShellCmd(containerID string) tea.Cmd { + enginePath := engine.GetEnginePath() + if enginePath == "" { + enginePath = "docker" + } + cmd := exec.Command(enginePath, "exec", "-it", containerID, "/bin/sh") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return tea.ExecProcess(cmd, func(err error) tea.Msg { + if err != nil { + return statusMsg("Exec ended: " + err.Error()) + } + return statusMsg("Exec session ended.") + }) +} diff --git a/internal/tui/keybindings.go b/internal/tui/keybindings.go new file mode 100644 index 0000000..3cf3a17 --- /dev/null +++ b/internal/tui/keybindings.go @@ -0,0 +1,236 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +// GlobalKeys holds key bindings available in all views. +type GlobalKeys struct { + Quit key.Binding + Help key.Binding + Back key.Binding + Tab1 key.Binding + Tab2 key.Binding + Tab3 key.Binding + Tab4 key.Binding + Tab5 key.Binding +} + +// ContainerKeys holds key bindings for the containers view. +type ContainerKeys struct { + Details key.Binding + Start key.Binding + Stop key.Binding + Restart key.Binding + Delete key.Binding + Logs key.Binding + Inspect key.Binding + Exec key.Binding + Filter key.Binding + Group key.Binding +} + +// ImageKeys holds key bindings for the images view. +type ImageKeys struct { + Delete key.Binding + Prune key.Binding + Filter key.Binding +} + +// VolumeKeys holds key bindings for the volumes view. +type VolumeKeys struct { + Delete key.Binding + Filter key.Binding +} + +// NetworkKeys holds key bindings for the networks view. +type NetworkKeys struct { + Inspect key.Binding +} + +// SystemKeys holds key bindings for the system view. +type SystemKeys struct { + BasicCleanup key.Binding + AdvancedCleanup key.Binding + TotalCleanup key.Binding +} + +// LogsKeys holds key bindings for the logs view. +type LogsKeys struct { + Pause key.Binding + Follow key.Binding + LineNumbers key.Binding +} + +// ConfirmKeys holds key bindings for the confirm dialog. +type ConfirmKeys struct { + Yes key.Binding +} + +// FilterKeys holds key bindings for the filter input. +type FilterKeys struct { + Submit key.Binding + Cancel key.Binding +} + +// Keys is the global key binding registry. +var Keys = struct { + Global GlobalKeys + Container ContainerKeys + Image ImageKeys + Volume VolumeKeys + Network NetworkKeys + System SystemKeys + Logs LogsKeys + Confirm ConfirmKeys + Filter FilterKeys +}{ + Global: GlobalKeys{ + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Back: key.NewBinding( + key.WithKeys("q", "esc"), + key.WithHelp("q/esc", "back/quit"), + ), + Tab1: key.NewBinding( + key.WithKeys("1"), + key.WithHelp("1", "containers"), + ), + Tab2: key.NewBinding( + key.WithKeys("2"), + key.WithHelp("2", "images"), + ), + Tab3: key.NewBinding( + key.WithKeys("3"), + key.WithHelp("3", "volumes"), + ), + Tab4: key.NewBinding( + key.WithKeys("4"), + key.WithHelp("4", "networks"), + ), + Tab5: key.NewBinding( + key.WithKeys("5"), + key.WithHelp("5", "system"), + ), + }, + Container: ContainerKeys{ + Details: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "details"), + ), + Start: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "start"), + ), + Stop: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "stop"), + ), + Restart: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "restart"), + ), + Delete: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "delete"), + ), + Logs: key.NewBinding( + key.WithKeys("l"), + key.WithHelp("l", "logs"), + ), + Inspect: key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "inspect"), + ), + Exec: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "exec shell"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + Group: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "group by compose"), + ), + }, + Image: ImageKeys{ + Delete: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "remove"), + ), + Prune: key.NewBinding( + key.WithKeys("P"), + key.WithHelp("P", "prune dangling"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + }, + Volume: VolumeKeys{ + Delete: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "remove"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + }, + Network: NetworkKeys{ + Inspect: key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "inspect"), + ), + }, + System: SystemKeys{ + BasicCleanup: key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "basic cleanup"), + ), + AdvancedCleanup: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "advanced cleanup"), + ), + TotalCleanup: key.NewBinding( + key.WithKeys("t"), + key.WithHelp("t", "total cleanup"), + ), + }, + Logs: LogsKeys{ + Pause: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "pause"), + ), + Follow: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "follow"), + ), + LineNumbers: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "line numbers"), + ), + }, + Confirm: ConfirmKeys{ + Yes: key.NewBinding( + key.WithKeys("y", "Y"), + key.WithHelp("y", "confirm"), + ), + }, + Filter: FilterKeys{ + Submit: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "apply filter"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "clear filter"), + ), + }, +} diff --git a/internal/tui/keymaps.go b/internal/tui/keymaps.go new file mode 100644 index 0000000..c98275a --- /dev/null +++ b/internal/tui/keymaps.go @@ -0,0 +1,138 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +// containersKeyMap implements help.KeyMap for the containers view. +type containersKeyMap struct{} + +func (containersKeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + Keys.Container.Details, + Keys.Container.Logs, + Keys.Container.Start, + Keys.Container.Stop, + Keys.Container.Delete, + Keys.Global.Help, + Keys.Global.Quit, + } +} + +func (containersKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {Keys.Container.Details, Keys.Container.Logs, Keys.Container.Inspect, Keys.Container.Exec}, + {Keys.Container.Start, Keys.Container.Stop, Keys.Container.Restart, Keys.Container.Delete}, + {Keys.Container.Filter, Keys.Container.Group}, + {Keys.Global.Tab1, Keys.Global.Tab2, Keys.Global.Tab3, Keys.Global.Tab4, Keys.Global.Tab5}, + {Keys.Global.Help, Keys.Global.Back}, + } +} + +// imagesKeyMap implements help.KeyMap for the images view. +type imagesKeyMap struct{} + +func (imagesKeyMap) ShortHelp() []key.Binding { + return []key.Binding{Keys.Image.Delete, Keys.Image.Prune, Keys.Image.Filter, Keys.Global.Help, Keys.Global.Quit} +} + +func (imagesKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {Keys.Image.Delete, Keys.Image.Prune, Keys.Image.Filter}, + {Keys.Global.Tab1, Keys.Global.Tab2, Keys.Global.Tab3, Keys.Global.Tab4, Keys.Global.Tab5}, + {Keys.Global.Help, Keys.Global.Quit}, + } +} + +// volumesKeyMap implements help.KeyMap for the volumes view. +type volumesKeyMap struct{} + +func (volumesKeyMap) ShortHelp() []key.Binding { + return []key.Binding{Keys.Volume.Delete, Keys.Volume.Filter, Keys.Global.Help, Keys.Global.Quit} +} + +func (volumesKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {Keys.Volume.Delete, Keys.Volume.Filter}, + {Keys.Global.Tab1, Keys.Global.Tab2, Keys.Global.Tab3, Keys.Global.Tab4, Keys.Global.Tab5}, + {Keys.Global.Help, Keys.Global.Quit}, + } +} + +// networksKeyMap implements help.KeyMap for the networks view. +type networksKeyMap struct{} + +func (networksKeyMap) ShortHelp() []key.Binding { + return []key.Binding{Keys.Network.Inspect, Keys.Global.Help, Keys.Global.Quit} +} + +func (networksKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {Keys.Network.Inspect}, + {Keys.Global.Tab1, Keys.Global.Tab2, Keys.Global.Tab3, Keys.Global.Tab4, Keys.Global.Tab5}, + {Keys.Global.Help, Keys.Global.Quit}, + } +} + +// systemKeyMap implements help.KeyMap for the system view. +type systemKeyMap struct{} + +func (systemKeyMap) ShortHelp() []key.Binding { + return []key.Binding{Keys.System.BasicCleanup, Keys.System.AdvancedCleanup, Keys.System.TotalCleanup, Keys.Global.Quit} +} + +func (systemKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {Keys.System.BasicCleanup, Keys.System.AdvancedCleanup, Keys.System.TotalCleanup}, + {Keys.Global.Help, Keys.Global.Quit}, + } +} + +// logsKeyMap implements help.KeyMap for the logs view. +type logsKeyMap struct{} + +func (logsKeyMap) ShortHelp() []key.Binding { + return []key.Binding{Keys.Logs.Pause, Keys.Logs.Follow, Keys.Global.Back} +} + +func (logsKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {Keys.Logs.Pause, Keys.Logs.Follow}, + {Keys.Global.Back, Keys.Global.Help}, + } +} + +// viewportKeyMap implements help.KeyMap for inspect/details views. +type viewportKeyMap struct{} + +func (viewportKeyMap) ShortHelp() []key.Binding { + return []key.Binding{Keys.Global.Back, Keys.Global.Help} +} + +func (viewportKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {Keys.Global.Back, Keys.Global.Help}, + } +} + +// currentKeyMap returns the help.KeyMap for the active view. +func (m Model) currentKeyMap() interface { + ShortHelp() []key.Binding + FullHelp() [][]key.Binding +} { + switch m.currentView { + case ContainersView: + return containersKeyMap{} + case ImagesView: + return imagesKeyMap{} + case VolumesView: + return volumesKeyMap{} + case NetworksView: + return networksKeyMap{} + case SystemView: + return systemKeyMap{} + case LogsView: + return logsKeyMap{} + case InspectView, DetailsView: + return viewportKeyMap{} + } + return containersKeyMap{} +} diff --git a/internal/tui/modal.go b/internal/tui/modal.go new file mode 100644 index 0000000..a656a2f --- /dev/null +++ b/internal/tui/modal.go @@ -0,0 +1,253 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ButtonKind controls button visual style. +type ButtonKind int + +const ( + ButtonKindPrimary ButtonKind = iota + ButtonKindDanger + ButtonKindSecondary +) + +// ModalButton is one button in a modal dialog. +type ModalButton struct { + Label string + Kind ButtonKind + // Cmd is the tea.Cmd to dispatch when this button is activated. + Cmd tea.Cmd +} + +// Modal is a centered dialog with a title, body text, and focusable buttons. +type Modal struct { + Title string + Body string + Buttons []ModalButton + focused int +} + +// NewConfirmModal creates a two-button danger confirm dialog. +func NewConfirmModal(title, body string, confirmCmd tea.Cmd) *Modal { + return &Modal{ + Title: title, + Body: body, + Buttons: []ModalButton{ + {Label: "Confirm", Kind: ButtonKindDanger, Cmd: confirmCmd}, + {Label: "Cancel", Kind: ButtonKindSecondary, Cmd: nil}, + }, + focused: 1, // default focus on Cancel (safer) + } +} + +// FocusNext moves focus to the next button (wraps). +func (m *Modal) FocusNext() { + m.focused = (m.focused + 1) % len(m.Buttons) +} + +// FocusPrev moves focus to the previous button (wraps). +func (m *Modal) FocusPrev() { + m.focused = (m.focused - 1 + len(m.Buttons)) % len(m.Buttons) +} + +// Activate returns the Cmd for the focused button (nil = cancel). +func (m *Modal) Activate() tea.Cmd { + if m.focused >= 0 && m.focused < len(m.Buttons) { + return m.Buttons[m.focused].Cmd + } + return nil +} + +// ActivateAt returns the Cmd for the button at index i (nil = cancel). +func (m *Modal) ActivateAt(i int) tea.Cmd { + if i >= 0 && i < len(m.Buttons) { + return m.Buttons[i].Cmd + } + return nil +} + +// View renders the modal box using the current theme. +func (m Modal) View(width int) string { + th := currentTheme + + title := th.ModalTitleStyle.Render(m.Title) + body := th.ModalBodyStyle.Render(m.Body) + + // Render buttons. + var btns []string + for i, b := range m.Buttons { + var s lipgloss.Style + if i == m.focused { + s = th.ButtonFocusedStyle + } else { + switch b.Kind { + case ButtonKindDanger: + s = th.ButtonDangerStyle + case ButtonKindPrimary: + s = th.ButtonPrimaryStyle + default: + s = th.ButtonSecondaryStyle + } + } + btns = append(btns, s.Render(b.Label)) + } + buttonRow := lipgloss.JoinHorizontal(lipgloss.Left, btns...) + hint := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Render("tab/← → to switch enter to confirm esc to cancel") + + inner := lipgloss.JoinVertical( + lipgloss.Left, + title, + body, + "", + buttonRow, + hint, + ) + + // Constrain box width. + boxW := width - 8 + if boxW < 40 { + boxW = 40 + } + + box := th.ModalBoxStyle.Width(boxW).Render(inner) + + // Center horizontally. + boxRenderedW := lipgloss.Width(box) + leftPad := (width - boxRenderedW) / 2 + if leftPad < 0 { + leftPad = 0 + } + + return lipgloss.NewStyle(). + PaddingLeft(leftPad). + Render(box) +} + +// modalKeys are the key bindings active while a modal is open. +var modalKeys = struct { + FocusNext key.Binding + FocusPrev key.Binding + Confirm key.Binding + Cancel key.Binding + Left key.Binding + Right key.Binding +}{ + FocusNext: key.NewBinding(key.WithKeys("tab")), + FocusPrev: key.NewBinding(key.WithKeys("shift+tab")), + Confirm: key.NewBinding(key.WithKeys("enter")), + Cancel: key.NewBinding(key.WithKeys("esc")), + Left: key.NewBinding(key.WithKeys("left", "h")), + Right: key.NewBinding(key.WithKeys("right", "l")), +} + +// handleModalKey processes key input when a modal is active. +func (m Model) handleModalKey(msg tea.KeyMsg) (Model, tea.Cmd) { + modal := m.modal + if modal == nil { + return m, nil + } + + switch { + case key.Matches(msg, modalKeys.Cancel): + m.modal = nil + m.statusMessage = "Cancelled." + return m, nil + case key.Matches(msg, modalKeys.Confirm): + cmd := modal.Activate() + m.modal = nil + if cmd == nil { + m.statusMessage = "Cancelled." + return m, nil + } + m.showSpinner = true + return m, cmd + case key.Matches(msg, modalKeys.FocusNext), key.Matches(msg, modalKeys.Right): + modal.FocusNext() + return m, nil + case key.Matches(msg, modalKeys.FocusPrev), key.Matches(msg, modalKeys.Left): + modal.FocusPrev() + return m, nil + } + + return m, nil +} + +// handleModalMouseClick checks if a mouse left-click lands on a modal button. +// buttonStartY is the terminal row where the button row starts. +func (m Model) handleModalMouseClick(msg tea.MouseMsg, buttonStartY, modalLeftX int) (Model, tea.Cmd) { + modal := m.modal + if modal == nil { + return m, nil + } + + // Only handle clicks on the button row. + if msg.Y != buttonStartY { + return m, nil + } + + // Walk buttons to find which was clicked. + cursor := modalLeftX + currentTheme.ModalBoxStyle.GetHorizontalPadding() + for i, b := range modal.Buttons { + var s lipgloss.Style + switch b.Kind { + case ButtonKindDanger: + s = currentTheme.ButtonDangerStyle + case ButtonKindPrimary: + s = currentTheme.ButtonPrimaryStyle + default: + s = currentTheme.ButtonSecondaryStyle + } + btnW := lipgloss.Width(s.Render(b.Label)) + if msg.X >= cursor && msg.X < cursor+btnW { + cmd := modal.ActivateAt(i) + m.modal = nil + if cmd == nil { + m.statusMessage = "Cancelled." + return m, nil + } + m.showSpinner = true + return m, cmd + } + cursor += btnW + } + + return m, nil +} + +// renderModal overlays the modal centered on a background string. +func (m Model) renderModal(bg string) string { + if m.modal == nil { + return bg + } + modalView := m.modal.View(m.width) + + bgLines := strings.Split(bg, "\n") + modalLines := strings.Split(modalView, "\n") + + // Center the modal vertically. + bgH := len(bgLines) + mH := len(modalLines) + startY := (bgH - mH) / 2 + if startY < 0 { + startY = 0 + } + + for i, line := range modalLines { + idx := startY + i + if idx < len(bgLines) { + bgLines[idx] = line + } else { + bgLines = append(bgLines, line) + } + } + + return strings.Join(bgLines, "\n") +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 279c3ab..c249528 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,45 +1,99 @@ package tui import ( + "context" "fmt" "log/slog" "strings" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/rluders/berth/internal/controller" "github.com/rluders/berth/internal/engine" + "github.com/rluders/berth/internal/utils" ) - var currentTheme = DefaultTheme() // Model represents the main application model. type Model struct { - engineType engine.EngineType - currentView ViewType - containerTable table.Model - imageTable table.Model - volumeTable table.Model - networkTable table.Model - systemInfo controller.SystemInfo - inspectViewPort viewport.Model - inspectReady bool - inspectRawContent string + engineType engine.EngineType + currentView ViewType + viewStack []ViewType + + // Tables + containerTable table.Model + imageTable table.Model + volumeTable table.Model + networkTable table.Model + + // Raw data (for filtering / grouping) + containers []controller.Container + images []controller.Image + volumes []controller.Volume + + // Container stats + containerStats map[string]controller.ContainerStat + + // Compose group toggle + groupByCompose bool + + // System info + systemInfo controller.SystemInfo + + // Inspect view + inspectViewPort viewport.Model + inspectReady bool + inspectRawContent string + currentInspectID string + + // Logs view logViewPort viewport.Model logReady bool - err error - statusMessage string - showSpinner bool - spinner spinner.Model - width int - height int + logLines []string + logFollowing bool + logCh chan string + logCancel context.CancelFunc currentLogContainerID string - currentInspectID string - viewStack []ViewType + showLineNumbers bool + + // Details view + detailsViewPort viewport.Model + detailsReady bool + currentDetailsID string + currentDetails controller.ContainerDetails + + // Search / filter + filterInput textinput.Model + filterActive bool + + // Modal dialog (replaces old confirmAction) + modal *Modal + + // Help overlay + showHelp bool + helpModel help.Model + + // Progress bar (cleanup / prune operations) + progressBar progress.Model + progressVisible bool + progressLabel string + progressDone bool + + // Status + err error + statusMessage string + showSpinner bool + spinner spinner.Model + + // Window + width int + height int } // InitialModel returns an initialized Model with default values. @@ -47,15 +101,7 @@ func InitialModel() Model { slog.Debug("InitialModel called") containerTable := table.New( - table.WithColumns([]table.Column{ - {Title: "ID", Width: 12}, - {Title: "Image", Width: 20}, - {Title: "Command", Width: 30}, - {Title: "Created", Width: 15}, - {Title: "Status", Width: 20}, - {Title: "Ports", Width: 20}, - {Title: "Names", Width: 20}, - }), + table.WithColumns(containerColumns()), table.WithFocused(true), table.WithHeight(0), ) @@ -102,24 +148,50 @@ func InitialModel() Model { volumeTable.SetStyles(s) networkTable.SetStyles(s) + fi := textinput.New() + fi.Placeholder = "filter..." + fi.CharLimit = 60 + return Model{ - engineType: engine.DetectEngine(), - currentView: ContainersView, - containerTable: containerTable, - imageTable: imageTable, - volumeTable: volumeTable, - networkTable: networkTable, - systemInfo: controller.SystemInfo{}, + engineType: engine.DetectEngine(), + currentView: ContainersView, + containerTable: containerTable, + imageTable: imageTable, + volumeTable: volumeTable, + networkTable: networkTable, + containerStats: make(map[string]controller.ContainerStat), + systemInfo: controller.SystemInfo{}, inspectViewPort: viewport.New(0, 0), logViewPort: viewport.New(0, 0), - spinner: spinner.New(), + detailsViewPort: viewport.New(0, 0), + logFollowing: true, + filterInput: fi, + spinner: spinner.New(), + helpModel: help.New(), + progressBar: progress.New( + progress.WithDefaultGradient(), + progress.WithoutPercentage(), + ), + } +} + +// containerColumns defines the container table columns (PRD order: name, status, image, ports, cpu, mem, uptime). +func containerColumns() []table.Column { + return []table.Column{ + {Title: "Name", Width: 22}, + {Title: "Status", Width: 16}, + {Title: "Image", Width: 24}, + {Title: "Ports", Width: 18}, + {Title: "CPU%", Width: 6}, + {Title: "Mem", Width: 10}, + {Title: "Age", Width: 7}, } } // Init initializes the Bubble Tea program. func (m Model) Init() tea.Cmd { slog.Debug("Init called") - return tea.Batch(fetchAllCmd(), m.spinner.Tick) + return tea.Batch(fetchAllCmd(), m.spinner.Tick, statsTickCmd(), refreshTickCmd()) } func (m Model) getViewName() string { @@ -137,32 +209,29 @@ func (m Model) getViewName() string { case InspectView: return fmt.Sprintf("Inspect %s", m.currentInspectID) case LogsView: - return fmt.Sprintf("Logs for %s", m.currentLogContainerID) + return fmt.Sprintf("Logs %s", m.currentLogContainerID) + case DetailsView: + return fmt.Sprintf("Details %s", m.currentDetailsID) } return "Unknown" } -func (m Model) getFooterHelp() string { - nav := "1:Containers • 2:Images • 3:Volumes • 4:Networks • 5:System" - switch m.currentView { - case ContainersView: - return nav + " • s:Start • x:Stop • d:Remove • l:Logs • i:Inspect • q:Quit" - case ImagesView: - return nav + " • d:Remove • q:Quit" - case VolumesView: - return nav + " • d:Remove • q:Quit" - case NetworksView: - return nav + " • i:Inspect • q:Quit" - case SystemView: - return nav + " • b:Basic Cleanup • a:Advanced Cleanup • t:Total Cleanup • q:Quit" - case InspectView, LogsView: - return "q/esc:Return • ↑/↓:Scroll" - } - return "q:Quit" -} func (m Model) headerText() string { - return fmt.Sprintf("Berth - %s - %s Engine", m.getViewName(), strings.ToUpper(string(m.engineType))) + eng := strings.ToUpper(string(m.engineType)) + view := m.getViewName() + extra := "" + if m.currentView == LogsView { + mode := "follow" + if !m.logFollowing { + mode = "paused" + } + extra = fmt.Sprintf(" [%s]", mode) + } + if m.groupByCompose && m.currentView == ContainersView { + extra = " [grouped]" + } + return fmt.Sprintf("Berth %s %s Engine%s", view, eng, extra) } func (m *Model) pushView(view ViewType) { @@ -179,19 +248,124 @@ func (m *Model) popView() { } } -// contentHeight calculates available height for the main content area -// following Golden Rule #1: subtract header + footer + app padding. +// contentHeight calculates available height for the main content area. func (m Model) contentHeight() int { h := m.height - h -= lipgloss.Height(currentTheme.HeaderStyle.Render(m.headerText())) - h -= currentTheme.FooterStyle.GetVerticalFrameSize() - h -= currentTheme.AppStyle.GetVerticalFrameSize() - // status message adds an extra line above the footer help - if m.statusMessage != "" { + h -= 1 // header row + h -= 1 // tab bar row + h -= 2 // footer (key hints + engine line) + if m.statusMessage != "" || m.showSpinner { + h -= 1 + } + if m.filterActive { h -= 1 } + if m.progressVisible { + h -= 2 // label + bar + } if h < 0 { return 0 } return h } + +// buildContainerRows produces filtered and optionally compose-grouped table rows. +func (m Model) buildContainerRows() []table.Row { + filter := strings.ToLower(m.filterInput.Value()) + + type group struct { + project string + rows []table.Row + } + var groups []group + projectIndex := map[string]int{} + + for _, c := range m.containers { + // Filter + if filter != "" { + haystack := strings.ToLower(c.Names + " " + c.Image + " " + c.Status) + if !strings.Contains(haystack, filter) { + continue + } + } + + stat := m.containerStats[c.ID] + cpuStr := fmt.Sprintf("%.1f", stat.CPUPercent) + memStr := "" + if stat.MemLimit > 0 { + memStr = utils.FormatBytes(stat.MemUsage) + } + ageStr := utils.FormatAge(c.CreatedAt) + + row := table.Row{ + c.Names, + StatusColor(c.Status), + c.Image, + c.Ports, + cpuStr, + memStr, + ageStr, + } + + if m.groupByCompose { + project := c.Labels["com.docker.compose.project"] + if project == "" { + project = "_" + } + if idx, ok := projectIndex[project]; ok { + groups[idx].rows = append(groups[idx].rows, row) + } else { + projectIndex[project] = len(groups) + groups = append(groups, group{project: project, rows: []table.Row{row}}) + } + } else { + groups = append(groups, group{rows: []table.Row{row}}) + } + } + + var rows []table.Row + if m.groupByCompose { + for _, g := range groups { + if g.project != "_" { + // Insert visual separator row for the project name + rows = append(rows, table.Row{"── " + g.project + " ──", "", "", "", "", "", ""}) + } + rows = append(rows, g.rows...) + } + } else { + for _, g := range groups { + rows = append(rows, g.rows...) + } + } + return rows +} + +// buildImageRows produces filtered image rows. +func (m Model) buildImageRows() []table.Row { + filter := strings.ToLower(m.filterInput.Value()) + var rows []table.Row + for _, img := range m.images { + if filter != "" { + if !strings.Contains(strings.ToLower(img.Repository+" "+img.Tag), filter) { + continue + } + } + rows = append(rows, table.Row{img.ID, img.Repository, img.Tag, img.Size, img.Created}) + } + return rows +} + +// buildVolumeRows produces filtered volume rows. +func (m Model) buildVolumeRows() []table.Row { + filter := strings.ToLower(m.filterInput.Value()) + var rows []table.Row + for _, v := range m.volumes { + if filter != "" { + if !strings.Contains(strings.ToLower(v.Name), filter) { + continue + } + } + rows = append(rows, table.Row{v.Name, v.Driver, v.Scope, v.Mountpoint}) + } + return rows +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 758ce92..def3f6c 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -1,25 +1,349 @@ package tui -import "github.com/charmbracelet/lipgloss" +import ( + "strings" -// Theme defines the color and styling for the application. + "github.com/charmbracelet/lipgloss" +) + +// Catppuccin Mocha palette +const ( + colorBase = "#1e1e2e" + colorMantle = "#181825" + colorSurface = "#313244" + colorOverlay = "#45475a" + colorText = "#cdd6f4" + colorSubtext = "#a6adc8" + colorMuted = "#6c7086" + + colorMauve = "#cba6f7" + colorBlue = "#89b4fa" + colorSky = "#89dceb" + colorGreen = "#a6e3a1" + colorYellow = "#f9e2af" + colorPeach = "#fab387" + colorRed = "#f38ba8" + colorTeal = "#94e2d5" + colorLavend = "#b4befe" + + colorCrust = "#11111b" +) + +// Theme defines all visual styles for the application. type Theme struct { - AppStyle lipgloss.Style - HeaderStyle lipgloss.Style - FooterStyle lipgloss.Style + // App chrome + AppStyle lipgloss.Style + AppBg lipgloss.Style + + // Header + HeaderStyle lipgloss.Style + HeaderLogoStyle lipgloss.Style + HeaderEngStyle lipgloss.Style + + // Tabs + TabBarStyle lipgloss.Style + ActiveTabStyle lipgloss.Style + InactiveTabStyle lipgloss.Style + TabCountStyle lipgloss.Style + + // Footer + FooterStyle lipgloss.Style + FooterKeyStyle lipgloss.Style + FooterDescStyle lipgloss.Style + + // Status StatusMessageStyle lipgloss.Style - TableSelectedStyle lipgloss.Style + StatusOKStyle lipgloss.Style + StatusErrStyle lipgloss.Style + SpinnerStyle lipgloss.Style + + // Tables TableHeaderStyle lipgloss.Style + TableSelectedStyle lipgloss.Style + TableRowStyle lipgloss.Style + TableRowAltStyle lipgloss.Style + + // Badges + BadgeRunningStyle lipgloss.Style + BadgeStoppedStyle lipgloss.Style + BadgePausedStyle lipgloss.Style + BadgeRestartStyle lipgloss.Style + BadgeCreatedStyle lipgloss.Style + + // Cards (details view) + CardStyle lipgloss.Style + CardTitleStyle lipgloss.Style + CardValueStyle lipgloss.Style + SectionStyle lipgloss.Style + + // Modal + ModalOverlayStyle lipgloss.Style + ModalBoxStyle lipgloss.Style + ModalTitleStyle lipgloss.Style + ModalBodyStyle lipgloss.Style + + // Buttons + ButtonPrimaryStyle lipgloss.Style + ButtonDangerStyle lipgloss.Style + ButtonSecondaryStyle lipgloss.Style + ButtonFocusedStyle lipgloss.Style + + // Filter input + FilterStyle lipgloss.Style + + // Log viewer + LogTimestampStyle lipgloss.Style + LogErrorStyle lipgloss.Style + LogWarnStyle lipgloss.Style + LogInfoStyle lipgloss.Style + LogDebugStyle lipgloss.Style + LogLineNumStyle lipgloss.Style + LogFollowStyle lipgloss.Style + LogPausedStyle lipgloss.Style + + // Viewport + ViewportStyle lipgloss.Style + + // Dividers + DividerStyle lipgloss.Style + + // Legacy (referenced by view.go / update.go) + ModalStyle lipgloss.Style } -// DefaultTheme returns a new Theme with default styles. +// DefaultTheme returns a new Theme using Catppuccin Mocha palette. func DefaultTheme() Theme { - return Theme{ - AppStyle: lipgloss.NewStyle().Padding(1, 2), - HeaderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1).Border(lipgloss.NormalBorder(), false, false, true, false), - FooterStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1).Border(lipgloss.NormalBorder(), true, false, false, false), - StatusMessageStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("170")).Padding(0, 1), - TableSelectedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")).Bold(false), - TableHeaderStyle: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).BorderBottom(true).Bold(false), + t := Theme{} + + // App chrome + t.AppStyle = lipgloss.NewStyle().Padding(0, 0) + t.AppBg = lipgloss.NewStyle().Background(lipgloss.Color(colorBase)) + + // Header + t.HeaderStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(colorMantle)). + Foreground(lipgloss.Color(colorText)). + Padding(0, 2). + Bold(false) + t.HeaderLogoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMauve)). + Bold(true) + t.HeaderEngStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtext)). + Background(lipgloss.Color(colorSurface)). + Padding(0, 1) + + // Tabs + t.TabBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(colorCrust)). + Padding(0, 0) + t.ActiveTabStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMauve)). + Background(lipgloss.Color(colorBase)). + Bold(true). + Padding(0, 2). + Border(lipgloss.Border{Bottom: "▔"}, false, false, true, false). + BorderForeground(lipgloss.Color(colorMauve)) + t.InactiveTabStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Background(lipgloss.Color(colorCrust)). + Padding(0, 2) + t.TabCountStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorOverlay)). + Background(lipgloss.Color(colorSurface)). + Padding(0, 1) + + // Footer + t.FooterStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(colorMantle)). + Foreground(lipgloss.Color(colorMuted)). + Padding(0, 2) + t.FooterKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMauve)). + Bold(true) + t.FooterDescStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtext)) + + // Status + t.StatusMessageStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorYellow)). + Padding(0, 2) + t.StatusOKStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorGreen)). + Padding(0, 2) + t.StatusErrStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorRed)). + Padding(0, 2) + t.SpinnerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMauve)) + + // Tables + t.TableHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMauve)). + Background(lipgloss.Color(colorMantle)). + Bold(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorSurface)). + BorderBottom(true) + t.TableSelectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorMauve)). + Bold(true) + t.TableRowStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)) + t.TableRowAltStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color(colorMantle)) + + // Badges + t.BadgeRunningStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorGreen)). + Padding(0, 1). + Bold(true) + t.BadgeStoppedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorRed)). + Padding(0, 1). + Bold(true) + t.BadgePausedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorYellow)). + Padding(0, 1). + Bold(true) + t.BadgeRestartStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorBlue)). + Padding(0, 1). + Bold(true) + t.BadgeCreatedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorTeal)). + Padding(0, 1). + Bold(true) + + // Cards + t.CardStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(colorSurface)). + Padding(0, 1). + Margin(0, 0, 1, 0) + t.CardTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMauve)). + Bold(true) + t.CardValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)) + t.SectionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorLavend)). + Bold(true). + MarginTop(1) + + // Modal + t.ModalBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(colorMauve)). + Background(lipgloss.Color(colorMantle)). + Padding(1, 3) + t.ModalTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMauve)). + Bold(true). + MarginBottom(1) + t.ModalBodyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)) + // Legacy alias + t.ModalStyle = t.ModalBoxStyle + + // Buttons + t.ButtonPrimaryStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorBlue)). + Padding(0, 2). + Margin(0, 1) + t.ButtonDangerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorRed)). + Padding(0, 2). + Margin(0, 1) + t.ButtonSecondaryStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color(colorSurface)). + Padding(0, 2). + Margin(0, 1) + t.ButtonFocusedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorMauve)). + Padding(0, 2). + Margin(0, 1). + Bold(true) + + // Filter + t.FilterStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color(colorSurface)). + Padding(0, 1) + + // Log viewer + t.LogTimestampStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)) + t.LogErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorRed)).Bold(true) + t.LogWarnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)) + t.LogInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorBlue)) + t.LogDebugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)) + t.LogLineNumStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOverlay)) + t.LogFollowStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorGreen)). + Padding(0, 1). + Bold(true) + t.LogPausedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorYellow)). + Padding(0, 1) + + // Viewport + t.ViewportStyle = lipgloss.NewStyle(). + Padding(0, 1) + + // Divider + t.DividerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSurface)) + + return t +} + +// StatusBadge returns a styled status badge string for a container status. +func StatusBadge(status string) string { + switch { + case strings.HasPrefix(status, "Up"), status == "running": + return currentTheme.BadgeRunningStyle.Render("▶ " + status) + case status == "paused": + return currentTheme.BadgePausedStyle.Render("⏸ " + status) + case status == "restarting": + return currentTheme.BadgeRestartStyle.Render("↻ " + status) + case status == "created": + return currentTheme.BadgeCreatedStyle.Render("● " + status) + default: + return currentTheme.BadgeStoppedStyle.Render("■ " + status) } } + +// StatusColor returns a lipgloss-styled string for a container status (plain text, no badge). +func StatusColor(status string) string { + switch { + case strings.HasPrefix(status, "Up"), status == "running": + return lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(status) + case status == "paused": + return lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Render(status) + case status == "restarting": + return lipgloss.NewStyle().Foreground(lipgloss.Color(colorBlue)).Render(status) + case status == "created": + return lipgloss.NewStyle().Foreground(lipgloss.Color(colorTeal)).Render(status) + default: + return lipgloss.NewStyle().Foreground(lipgloss.Color(colorRed)).Render(status) + } +} + +// FooterHint renders a key+description pair for the footer. +func FooterHint(k, desc string) string { + return currentTheme.FooterKeyStyle.Render(k) + + currentTheme.FooterDescStyle.Render(" "+desc) +} diff --git a/internal/tui/types.go b/internal/tui/types.go index 3e38fd8..01a2e25 100644 --- a/internal/tui/types.go +++ b/internal/tui/types.go @@ -15,19 +15,36 @@ const ( SystemView InspectView LogsView + DetailsView ) +// progressMsg drives the progress bar for long operations. +type progressMsg struct { + percent float64 + label string + done bool +} + +// progressTickMsg animates the progress bar while an operation runs. +type progressTickMsg struct{} + // Typed message types for the Update dispatcher. type ( - containerListMsg []controller.Container - imageListMsg []controller.Image - volumeListMsg []controller.Volume - networkListMsg []controller.Network - systemInfoMsg controller.SystemInfo - logsMsg string - inspectMsg string - statusMsg string - errMsg struct{ err error } + containerListMsg []controller.Container + imageListMsg []controller.Image + volumeListMsg []controller.Volume + networkListMsg []controller.Network + systemInfoMsg controller.SystemInfo + logsMsg string + logChunkMsg string + logStreamDoneMsg struct{} + inspectMsg string + detailsMsg controller.ContainerDetails + containerStatsMsg map[string]controller.ContainerStat + statsTickMsg struct{} + refreshTickMsg struct{} + statusMsg string + errMsg struct{ err error } ) func (e errMsg) Error() string { return e.err.Error() } diff --git a/internal/tui/update.go b/internal/tui/update.go index c28ae82..828c660 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,11 +1,15 @@ package tui import ( + "fmt" "log/slog" + "strings" + "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/rluders/berth/internal/controller" ) @@ -23,6 +27,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { newM, cmd := m.handleKeyMsg(msg) return newM, cmd + case tea.MouseMsg: + newM, cmd := m.handleMouseMsg(msg) + return newM, cmd + case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -41,45 +49,43 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.inspectViewPort.Height = contentH m.logViewPort.Width = viewW m.logViewPort.Height = contentH + m.detailsViewPort.Width = viewW + m.detailsViewPort.Height = contentH - // If inspect content arrived before window size, render it now. if m.currentView == InspectView && !m.inspectReady && m.inspectRawContent != "" { m.inspectViewPort.SetContent(prettyJSON(m.inspectRawContent)) m.inspectReady = true } + if m.currentView == DetailsView && !m.detailsReady && m.currentDetails.ID != "" { + m.detailsViewPort.SetContent(renderDetailsContent(m.currentDetails)) + m.detailsReady = true + } + + // ── Container list ──────────────────────────────────────────────────── case containerListMsg: - slog.Debug("containerListMsg received", "count", len(msg)) - rows := make([]table.Row, len(msg)) - for i, c := range msg { - rows[i] = table.Row{c.ID, c.Image, c.Command, c.Created, c.Status, c.Ports, c.Names} - } - m.containerTable.SetRows(rows) + slog.Debug("containerListMsg", "count", len(msg)) + m.containers = []controller.Container(msg) + m.containerTable.SetRows(m.buildContainerRows()) m.showSpinner = false m.statusMessage = "" case imageListMsg: - slog.Debug("imageListMsg received", "count", len(msg)) - rows := make([]table.Row, len(msg)) - for i, img := range msg { - rows[i] = table.Row{img.ID, img.Repository, img.Tag, img.Size, img.Created} - } - m.imageTable.SetRows(rows) + slog.Debug("imageListMsg", "count", len(msg)) + m.images = []controller.Image(msg) + m.imageTable.SetRows(m.buildImageRows()) m.showSpinner = false m.statusMessage = "" case volumeListMsg: - slog.Debug("volumeListMsg received", "count", len(msg)) - rows := make([]table.Row, len(msg)) - for i, vol := range msg { - rows[i] = table.Row{vol.Name, vol.Driver, vol.Scope, vol.Mountpoint} - } - m.volumeTable.SetRows(rows) + slog.Debug("volumeListMsg", "count", len(msg)) + m.volumes = []controller.Volume(msg) + m.volumeTable.SetRows(m.buildVolumeRows()) m.showSpinner = false m.statusMessage = "" case networkListMsg: - slog.Debug("networkListMsg received", "count", len(msg)) + slog.Debug("networkListMsg", "count", len(msg)) rows := make([]table.Row, len(msg)) for i, net := range msg { rows[i] = table.Row{net.ID, net.Name, net.Driver, net.Scope} @@ -89,13 +95,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMessage = "" case systemInfoMsg: - slog.Debug("systemInfoMsg received") m.systemInfo = controller.SystemInfo(msg) m.showSpinner = false m.statusMessage = "" + // ── Stats ───────────────────────────────────────────────────────────── + + case containerStatsMsg: + for id, stat := range msg { + m.containerStats[id] = stat + } + m.containerTable.SetRows(m.buildContainerRows()) + + case statsTickMsg: + var ids []string + for _, c := range m.containers { + if strings.HasPrefix(c.Status, "Up") || c.Status == "running" { + ids = append(ids, c.ID) + } + } + if len(ids) > 0 { + cmds = append(cmds, fetchStatsCmd(ids)) + } + cmds = append(cmds, statsTickCmd()) + + case refreshTickMsg: + cmds = append(cmds, fetchContainersCmd(), refreshTickCmd()) + + // ── Inspect ─────────────────────────────────────────────────────────── + case inspectMsg: - slog.Debug("inspectMsg received") m.showSpinner = false m.statusMessage = "" m.inspectRawContent = string(msg) @@ -103,27 +132,88 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.inspectViewPort.SetContent(prettyJSON(string(msg))) m.inspectReady = true } - // Trigger viewport to re-measure in case window size already arrived. cmds = append(cmds, func() tea.Msg { return tea.WindowSizeMsg{Width: m.width, Height: m.height} }) - case logsMsg: - slog.Debug("logsMsg received") + // ── Details ─────────────────────────────────────────────────────────── + + case detailsMsg: m.showSpinner = false m.statusMessage = "" - m.logViewPort.SetContent(string(msg)) - m.logViewPort.GotoBottom() - m.logReady = true + m.currentDetails = controller.ContainerDetails(msg) + if m.width > 0 { + m.detailsViewPort.SetContent(renderDetailsContent(m.currentDetails)) + m.detailsReady = true + } + cmds = append(cmds, func() tea.Msg { + return tea.WindowSizeMsg{Width: m.width, Height: m.height} + }) + + // ── Logs ────────────────────────────────────────────────────────────── + + case logChunkMsg: + m.logLines = append(m.logLines, string(msg)) + if len(m.logLines) > 10000 { + m.logLines = m.logLines[len(m.logLines)-5000:] + } + m.logViewPort.SetContent(buildColorizedLogContent(m.logLines, m.showLineNumbers)) + if m.logFollowing { + m.logViewPort.GotoBottom() + } + if m.logCh != nil { + cmds = append(cmds, waitForLogLineCmd(m.logCh)) + } + + case logStreamDoneMsg: + m.logCh = nil + m.logCancel = nil + + // ── Progress bar ────────────────────────────────────────────────────── + + case progressMsg: + if msg.done { + cmds = append(cmds, m.progressBar.SetPercent(1.0)) + m.progressLabel = msg.label + m.progressDone = true + m.showSpinner = false + // Emit statusMsg after bar reaches 100%. + cmds = append(cmds, func() tea.Msg { return statusMsg(msg.label) }) + } else { + m.progressVisible = true + m.progressLabel = msg.label + cmds = append(cmds, m.progressBar.SetPercent(msg.percent)) + } + + case progressTickMsg: + if m.progressVisible && !m.progressDone { + // Animate toward 0.85 while waiting for actual completion. + next := m.progressBar.Percent() + 0.04 + if next > 0.85 { + next = 0.85 + } + cmds = append(cmds, m.progressBar.SetPercent(next), progressTickCmd()) + } + + case progress.FrameMsg: + raw, cmd := m.progressBar.Update(msg) + if pb, ok := raw.(progress.Model); ok { + m.progressBar = pb + } + cmds = append(cmds, cmd) + + // ── Status / errors ─────────────────────────────────────────────────── case statusMsg: - slog.Debug("statusMsg received", "msg", string(msg)) + slog.Debug("statusMsg", "msg", string(msg)) m.statusMessage = string(msg) m.showSpinner = false + m.progressVisible = false + m.progressDone = false cmds = append(cmds, fetchAllCmd()) case errMsg: - slog.Error("errMsg received", "error", msg.err) + slog.Error("errMsg", "error", msg.err) m.err = msg.err m.showSpinner = false m.statusMessage = "" @@ -131,3 +221,99 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } + +// renderDetailsContent formats ContainerDetails into a card-based scrollable view. +func renderDetailsContent(d controller.ContainerDetails) string { + th := currentTheme + + field := func(label, value string) string { + l := th.CardTitleStyle.Render(fmt.Sprintf("%-12s", label)) + v := th.CardValueStyle.Render(value) + return " " + l + " " + v + } + + section := func(title string, lines []string) string { + header := th.SectionStyle.Render("▸ " + title) + body := strings.Join(lines, "\n") + return th.CardStyle.Render(header + "\n" + body) + } + + // ── Container section ────────────────────────────────────────────────── + stateBadge := StatusBadge(d.State) + infoLines := []string{ + field("ID", d.ID), + field("Name", d.Name), + field("Image", d.Image), + field("Command", d.Command), + field("State", stateBadge), + field("Created", d.Created), + } + + // ── Environment section ──────────────────────────────────────────────── + envLines := []string{} + if len(d.Env) == 0 { + envLines = append(envLines, " "+th.CardValueStyle.Foreground(lipgloss.Color(colorMuted)).Render("(none)")) + } else { + for _, e := range d.Env { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + masked := strings.Repeat("•", len(parts[1])) + if len(masked) > 12 { + masked = masked[:12] + } + envLines = append(envLines, field(parts[0], masked)) + } else { + envLines = append(envLines, " "+e) + } + } + } + + // ── Ports section ────────────────────────────────────────────────────── + portLines := []string{} + if len(d.Ports) == 0 { + portLines = append(portLines, " "+th.CardValueStyle.Foreground(lipgloss.Color(colorMuted)).Render("(none)")) + } else { + for _, p := range d.Ports { + hostIP := p.HostIP + if hostIP == "" { + hostIP = "0.0.0.0" + } + line := fmt.Sprintf("%s/%s → %s:%s", p.ContainerPort, p.Protocol, hostIP, p.HostPort) + portLines = append(portLines, " "+th.CardValueStyle.Render(line)) + } + } + + // ── Mounts section ───────────────────────────────────────────────────── + mountLines := []string{} + if len(d.Mounts) == 0 { + mountLines = append(mountLines, " "+th.CardValueStyle.Foreground(lipgloss.Color(colorMuted)).Render("(none)")) + } else { + for _, mt := range d.Mounts { + rw := th.LogDebugStyle.Render("ro") + if mt.RW { + rw = th.LogInfoStyle.Render("rw") + } + line := fmt.Sprintf("[%s] %s → %s (%s)", mt.Type, mt.Source, mt.Destination, rw) + mountLines = append(mountLines, " "+th.CardValueStyle.Render(line)) + } + } + + // ── Networks section ─────────────────────────────────────────────────── + netLines := []string{} + if len(d.Networks) == 0 { + netLines = append(netLines, " "+th.CardValueStyle.Foreground(lipgloss.Color(colorMuted)).Render("(none)")) + } else { + for _, n := range d.Networks { + line := fmt.Sprintf("%s IP: %s GW: %s", n.Name, n.IPAddress, n.Gateway) + netLines = append(netLines, " "+th.CardValueStyle.Render(line)) + } + } + + return strings.Join([]string{ + section("Container", infoLines), + section("Environment", envLines), + section("Ports", portLines), + section("Mounts", mountLines), + section("Networks", netLines), + }, "\n") +} diff --git a/internal/tui/update_keyboard.go b/internal/tui/update_keyboard.go index 1537526..c06f96b 100644 --- a/internal/tui/update_keyboard.go +++ b/internal/tui/update_keyboard.go @@ -3,8 +3,10 @@ package tui import ( "bytes" "encoding/json" + "fmt" "log/slog" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" ) @@ -12,35 +14,59 @@ import ( func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { slog.Debug("handleKeyMsg", "key", msg.String()) - // Global keys - switch msg.String() { - case "ctrl+c": + // Modal dialog intercepts all keys. + if m.modal != nil { + return m.handleModalKey(msg) + } + + // Help overlay: any key dismisses it. + if m.showHelp { + m.showHelp = false + return m, nil + } + + // Filter input intercepts typing when active. + if m.filterActive { + return m.handleFilterKey(msg) + } + + // Global keys. + switch { + case key.Matches(msg, Keys.Global.Quit): return m, tea.Quit - case "q", "esc": - if m.currentView == InspectView || m.currentView == LogsView { + case key.Matches(msg, Keys.Global.Help): + m.showHelp = true + return m, nil + case key.Matches(msg, Keys.Global.Back): + switch m.currentView { + case InspectView, DetailsView: + m.popView() + return m, nil + case LogsView: + m.stopLogStream() m.popView() m.logReady = false return m, nil } return m, tea.Quit - case "1": + case key.Matches(msg, Keys.Global.Tab1): m.currentView = ContainersView return m, nil - case "2": + case key.Matches(msg, Keys.Global.Tab2): m.currentView = ImagesView return m, nil - case "3": + case key.Matches(msg, Keys.Global.Tab3): m.currentView = VolumesView return m, nil - case "4": + case key.Matches(msg, Keys.Global.Tab4): m.currentView = NetworksView return m, nil - case "5": + case key.Matches(msg, Keys.Global.Tab5): m.currentView = SystemView return m, nil } - // Per-view keys + // Per-view keys. switch m.currentView { case ContainersView: return m.handleContainersKey(msg) @@ -56,65 +82,159 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (Model, tea.Cmd) { return m.handleInspectKey(msg) case LogsView: return m.handleLogsKey(msg) + case DetailsView: + return m.handleDetailsKey(msg) } return m, nil } +func (m Model) handleFilterKey(msg tea.KeyMsg) (Model, tea.Cmd) { + switch { + case key.Matches(msg, Keys.Filter.Cancel), key.Matches(msg, Keys.Filter.Submit): + m.filterActive = false + m.filterInput.Blur() + m.rebuildFilteredTables() + return m, nil + default: + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + m.rebuildFilteredTables() + return m, cmd + } +} + +func (m *Model) rebuildFilteredTables() { + switch m.currentView { + case ContainersView: + m.containerTable.SetRows(m.buildContainerRows()) + case ImagesView: + m.imageTable.SetRows(m.buildImageRows()) + case VolumesView: + m.volumeTable.SetRows(m.buildVolumeRows()) + } +} + func (m Model) handleContainersKey(msg tea.KeyMsg) (Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd m.containerTable, cmd = m.containerTable.Update(msg) cmds = append(cmds, cmd) + switch { + case key.Matches(msg, Keys.Container.Filter): + m.filterActive = true + m.filterInput.Focus() + return m, tea.Batch(cmds...) + case key.Matches(msg, Keys.Container.Group): + m.groupByCompose = !m.groupByCompose + m.containerTable.SetRows(m.buildContainerRows()) + return m, tea.Batch(cmds...) + } + if len(m.containerTable.SelectedRow()) == 0 { return m, tea.Batch(cmds...) } - id := m.containerTable.SelectedRow()[0] + // The Names column is [0] in the layout. + name := m.containerTable.SelectedRow()[0] + // Skip group-header rows (they start with "── "). + if len(name) > 3 && name[:3] == "── " { + return m, tea.Batch(cmds...) + } - switch msg.String() { - case "s": - m.statusMessage = "Starting container " + id + "..." + // Resolve ID from raw containers list. + id := m.resolveContainerID(name) + if id == "" { + id = name + } + + switch { + case key.Matches(msg, Keys.Container.Details): + m.pushView(DetailsView) + m.currentDetailsID = id + m.detailsReady = false + m.statusMessage = "Loading details..." + m.showSpinner = true + cmds = append(cmds, fetchDetailsCmd(id), m.spinner.Tick) + case key.Matches(msg, Keys.Container.Start): + m.statusMessage = fmt.Sprintf("docker start %s", name) m.showSpinner = true cmds = append(cmds, startContainerCmd(id), m.spinner.Tick) - case "x": - m.statusMessage = "Stopping container " + id + "..." + case key.Matches(msg, Keys.Container.Stop): + m.statusMessage = fmt.Sprintf("docker stop %s", name) m.showSpinner = true cmds = append(cmds, stopContainerCmd(id), m.spinner.Tick) - case "d": - m.statusMessage = "Removing container " + id + "..." + case key.Matches(msg, Keys.Container.Restart): + m.statusMessage = fmt.Sprintf("docker restart %s", name) m.showSpinner = true - cmds = append(cmds, removeContainerCmd(id), m.spinner.Tick) - case "l": - m.pushView(LogsView) - m.logReady = false + cmds = append(cmds, restartContainerCmd(id), m.spinner.Tick) + case key.Matches(msg, Keys.Container.Delete): + m.modal = NewConfirmModal( + "Delete Container", + fmt.Sprintf("Delete container %s?\nThis action cannot be undone.", name), + tea.Batch(removeContainerCmd(id), m.spinner.Tick), + ) + m.showSpinner = false + case key.Matches(msg, Keys.Container.Logs): + m.stopLogStream() + m.logLines = nil + m.logFollowing = true m.currentLogContainerID = id - m.statusMessage = "Fetching logs for " + id + "..." - m.showSpinner = true - cmds = append(cmds, getLogsCmd(id), m.spinner.Tick) - case "i": + m.pushView(LogsView) + m.logReady = true + ch, cancel, waitCmd := startLogStreamCmd(id) + m.logCh = ch + m.logCancel = cancel + cmds = append(cmds, waitCmd) + case key.Matches(msg, Keys.Container.Inspect): m.pushView(InspectView) m.currentInspectID = id m.inspectReady = false - m.statusMessage = "Inspecting container " + id + "..." + m.statusMessage = fmt.Sprintf("docker inspect %s", name) m.showSpinner = true cmds = append(cmds, inspectContainerCmd(id), m.spinner.Tick) + case key.Matches(msg, Keys.Container.Exec): + cmds = append(cmds, execShellCmd(id)) } return m, tea.Batch(cmds...) } +// resolveContainerID finds a container's full ID by display name. +func (m Model) resolveContainerID(name string) string { + for _, c := range m.containers { + if c.Names == name { + return c.ID + } + } + return "" +} + func (m Model) handleImagesKey(msg tea.KeyMsg) (Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd m.imageTable, cmd = m.imageTable.Update(msg) cmds = append(cmds, cmd) - if msg.String() == "d" && len(m.imageTable.SelectedRow()) > 0 { - id := m.imageTable.SelectedRow()[0] - m.statusMessage = "Removing image " + id + "..." - m.showSpinner = true - cmds = append(cmds, removeImageCmd(id), m.spinner.Tick) + switch { + case key.Matches(msg, Keys.Image.Filter): + m.filterActive = true + m.filterInput.Focus() + case key.Matches(msg, Keys.Image.Delete): + if len(m.imageTable.SelectedRow()) > 0 { + id := m.imageTable.SelectedRow()[0] + m.modal = NewConfirmModal( + "Remove Image", + fmt.Sprintf("Remove image %s?\nThis action cannot be undone.", id), + tea.Batch(removeImageCmd(id), m.spinner.Tick), + ) + } + case key.Matches(msg, Keys.Image.Prune): + m.modal = NewConfirmModal( + "Prune Images", + "Remove all dangling images?\nThis action cannot be undone.", + tea.Batch(pruneImagesCmd(), m.spinner.Tick), + ) } return m, tea.Batch(cmds...) @@ -126,11 +246,19 @@ func (m Model) handleVolumesKey(msg tea.KeyMsg) (Model, tea.Cmd) { m.volumeTable, cmd = m.volumeTable.Update(msg) cmds = append(cmds, cmd) - if msg.String() == "d" && len(m.volumeTable.SelectedRow()) > 0 { - name := m.volumeTable.SelectedRow()[0] - m.statusMessage = "Removing volume " + name + "..." - m.showSpinner = true - cmds = append(cmds, removeVolumeCmd(name), m.spinner.Tick) + switch { + case key.Matches(msg, Keys.Volume.Filter): + m.filterActive = true + m.filterInput.Focus() + case key.Matches(msg, Keys.Volume.Delete): + if len(m.volumeTable.SelectedRow()) > 0 { + name := m.volumeTable.SelectedRow()[0] + m.modal = NewConfirmModal( + "Remove Volume", + fmt.Sprintf("Remove volume %s?\nThis action cannot be undone.", name), + tea.Batch(removeVolumeCmd(name), m.spinner.Tick), + ) + } } return m, tea.Batch(cmds...) @@ -142,12 +270,12 @@ func (m Model) handleNetworksKey(msg tea.KeyMsg) (Model, tea.Cmd) { m.networkTable, cmd = m.networkTable.Update(msg) cmds = append(cmds, cmd) - if msg.String() == "i" && len(m.networkTable.SelectedRow()) > 0 { + if key.Matches(msg, Keys.Network.Inspect) && len(m.networkTable.SelectedRow()) > 0 { id := m.networkTable.SelectedRow()[0] m.pushView(InspectView) m.currentInspectID = id m.inspectReady = false - m.statusMessage = "Inspecting network " + id + "..." + m.statusMessage = fmt.Sprintf("docker network inspect %s", id) m.showSpinner = true cmds = append(cmds, inspectNetworkCmd(id), m.spinner.Tick) } @@ -156,19 +284,37 @@ func (m Model) handleNetworksKey(msg tea.KeyMsg) (Model, tea.Cmd) { } func (m Model) handleSystemKey(msg tea.KeyMsg) (Model, tea.Cmd) { - switch msg.String() { - case "b": - m.statusMessage = "Performing basic cleanup..." - m.showSpinner = true - return m, tea.Batch(basicCleanupCmd(), m.spinner.Tick) - case "a": - m.statusMessage = "Performing advanced cleanup..." - m.showSpinner = true - return m, tea.Batch(advancedCleanupCmd(), m.spinner.Tick) - case "t": - m.statusMessage = "Performing total cleanup..." - m.showSpinner = true - return m, tea.Batch(totalCleanupCmd(), m.spinner.Tick) + switch { + case key.Matches(msg, Keys.System.BasicCleanup): + m.modal = NewConfirmModal( + "Basic Cleanup", + "Prune stopped containers, unused networks, and dangling images.", + tea.Batch( + basicCleanupCmd(), + func() tea.Msg { return progressMsg{percent: 0.05, label: "Running basic cleanup...", done: false} }, + progressTickCmd(), + ), + ) + case key.Matches(msg, Keys.System.AdvancedCleanup): + m.modal = NewConfirmModal( + "Advanced Cleanup", + "Prune everything in basic cleanup plus unused volumes.", + tea.Batch( + advancedCleanupCmd(), + func() tea.Msg { return progressMsg{percent: 0.05, label: "Running advanced cleanup...", done: false} }, + progressTickCmd(), + ), + ) + case key.Matches(msg, Keys.System.TotalCleanup): + m.modal = NewConfirmModal( + "Total Cleanup", + "Remove ALL unused resources including volumes.\nThis action cannot be undone.", + tea.Batch( + totalCleanupCmd(), + func() tea.Msg { return progressMsg{percent: 0.05, label: "Running total cleanup...", done: false} }, + progressTickCmd(), + ), + ) } return m, nil } @@ -180,11 +326,39 @@ func (m Model) handleInspectKey(msg tea.KeyMsg) (Model, tea.Cmd) { } func (m Model) handleLogsKey(msg tea.KeyMsg) (Model, tea.Cmd) { + switch { + case key.Matches(msg, Keys.Logs.Pause): + m.logFollowing = false + return m, nil + case key.Matches(msg, Keys.Logs.Follow): + m.logFollowing = true + m.logViewPort.GotoBottom() + return m, nil + case key.Matches(msg, Keys.Logs.LineNumbers): + m.showLineNumbers = !m.showLineNumbers + m.logViewPort.SetContent(buildColorizedLogContent(m.logLines, m.showLineNumbers)) + return m, nil + } var cmd tea.Cmd m.logViewPort, cmd = m.logViewPort.Update(msg) return m, cmd } +func (m Model) handleDetailsKey(msg tea.KeyMsg) (Model, tea.Cmd) { + var cmd tea.Cmd + m.detailsViewPort, cmd = m.detailsViewPort.Update(msg) + return m, cmd +} + +// stopLogStream cancels and cleans up the log stream goroutine. +func (m *Model) stopLogStream() { + if m.logCancel != nil { + m.logCancel() + m.logCancel = nil + } + m.logCh = nil +} + // prettyJSON formats raw JSON content, falling back to raw on error. func prettyJSON(raw string) string { var buf bytes.Buffer diff --git a/internal/tui/update_mouse.go b/internal/tui/update_mouse.go new file mode 100644 index 0000000..389a308 --- /dev/null +++ b/internal/tui/update_mouse.go @@ -0,0 +1,181 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// handleMouseMsg dispatches mouse events to the appropriate handler. +func (m Model) handleMouseMsg(msg tea.MouseMsg) (Model, tea.Cmd) { + // Ignore mouse while modal or filter input is active. + if m.modal != nil || m.filterActive { + return m, nil + } + + switch msg.Button { + case tea.MouseButtonWheelUp: + return m.handleScrollUp() + case tea.MouseButtonWheelDown: + return m.handleScrollDown() + case tea.MouseButtonLeft: + return m.handleLeftClick(msg) + } + + return m, nil +} + +func (m Model) handleScrollUp() (Model, tea.Cmd) { + switch m.currentView { + case ContainersView: + var cmd tea.Cmd + m.containerTable, cmd = m.containerTable.Update(tea.KeyMsg{Type: tea.KeyUp}) + return m, cmd + case ImagesView: + var cmd tea.Cmd + m.imageTable, cmd = m.imageTable.Update(tea.KeyMsg{Type: tea.KeyUp}) + return m, cmd + case VolumesView: + var cmd tea.Cmd + m.volumeTable, cmd = m.volumeTable.Update(tea.KeyMsg{Type: tea.KeyUp}) + return m, cmd + case NetworksView: + var cmd tea.Cmd + m.networkTable, cmd = m.networkTable.Update(tea.KeyMsg{Type: tea.KeyUp}) + return m, cmd + case InspectView: + m.inspectViewPort.LineUp(3) + case LogsView: + m.logFollowing = false + m.logViewPort.LineUp(3) + case DetailsView: + m.detailsViewPort.LineUp(3) + } + return m, nil +} + +func (m Model) handleScrollDown() (Model, tea.Cmd) { + switch m.currentView { + case ContainersView: + var cmd tea.Cmd + m.containerTable, cmd = m.containerTable.Update(tea.KeyMsg{Type: tea.KeyDown}) + return m, cmd + case ImagesView: + var cmd tea.Cmd + m.imageTable, cmd = m.imageTable.Update(tea.KeyMsg{Type: tea.KeyDown}) + return m, cmd + case VolumesView: + var cmd tea.Cmd + m.volumeTable, cmd = m.volumeTable.Update(tea.KeyMsg{Type: tea.KeyDown}) + return m, cmd + case NetworksView: + var cmd tea.Cmd + m.networkTable, cmd = m.networkTable.Update(tea.KeyMsg{Type: tea.KeyDown}) + return m, cmd + case InspectView: + m.inspectViewPort.LineDown(3) + case LogsView: + m.logViewPort.LineDown(3) + case DetailsView: + m.detailsViewPort.LineDown(3) + } + return m, nil +} + +func (m Model) handleLeftClick(msg tea.MouseMsg) (Model, tea.Cmd) { + // Calculate header height to determine if click landed on tab bar. + headerH := lipgloss.Height(currentTheme.HeaderStyle.Render(m.headerText())) + tabBarH := 1 // tab bar is 1 line, rendered after header in Task 8 + + // Click on header area: check for tab bar clicks. + if msg.Y >= headerH && msg.Y < headerH+tabBarH { + return m.handleTabClick(msg.X) + } + + // Click in content area: handle table row selection. + contentStartY := headerH + tabBarH + if msg.Y >= contentStartY { + return m.handleTableClick(msg.Y-contentStartY, msg.X) + } + + return m, nil +} + +// handleTabClick maps an X coordinate to a tab and switches views. +// Tab positions are computed to match the renderTabBar() layout in view.go. +func (m Model) handleTabClick(x int) (Model, tea.Cmd) { + tabs := []struct { + label string + view ViewType + }{ + {"Containers", ContainersView}, + {"Images", ImagesView}, + {"Volumes", VolumesView}, + {"Networks", NetworksView}, + {"System", SystemView}, + } + + // Each tab is rendered as "