diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51398d5..9a5125a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go-version: [ '1.24', 'stable' ] + go-version: [ '1.26', 'stable' ] steps: - uses: actions/checkout@v4 @@ -24,10 +24,12 @@ jobs: check-latest: true - name: Generate mocks - run: make generate-mocks + run: | + go install github.com/vektra/mockery/v3@latest + mockery --config .mockery.yaml - name: Run golangci-lint (latest) - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v9 with: version: latest args: --timeout=5m @@ -37,7 +39,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go-version: [ '1.24', 'stable' ] + go-version: [ '1.26', 'stable' ] steps: - uses: actions/checkout@v4 @@ -50,13 +52,20 @@ jobs: cache: true - name: Generate mocks - run: make generate-mocks + run: | + go install github.com/vektra/mockery/v3@latest + mockery --config .mockery.yaml - name: Verify dependencies run: go mod tidy && git diff --exit-code go.mod go.sum - name: Build - run: make build + run: go build -o berth ./cmd/berth - name: Test - run: make test + run: go test ./... -coverprofile=coverage.txt + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfa118c..97e1696 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,10 +15,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup Go 1.24 (or higher) + - name: Setup Go 1.26 uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: '1.26' check-latest: true # - name: Run tests diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..c769ab9 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,18 @@ +FROM docker.io/golangci/golangci-lint:v2.12.1 AS golangci-lint + +FROM golang:1.26-alpine + +RUN apk add --no-cache git curl + +# golangci-lint for local Podman lint runs. +COPY --from=golangci-lint /usr/bin/golangci-lint /usr/local/bin/golangci-lint + +# mockery (matches CI: go install github.com/vektra/mockery/v3@latest) +RUN go install github.com/vektra/mockery/v3@latest + +# pre-cache module deps โ€” layer cache survives image rebuilds when go.mod unchanged +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +WORKDIR /app diff --git a/Makefile b/Makefile index f9729e8..6f7f1ec 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,10 @@ -GO := go -APP_NAME := berth -APP_PATH := ./cmd/berth +GO := go +APP_NAME := berth +APP_PATH := ./cmd/berth +PODMAN_IMAGE := berth-dev +PODMAN_RUN := podman run --rm -v $(shell pwd):/app:Z -w /app $(PODMAN_IMAGE) -.PHONY: all build run clean test lint help +.PHONY: all build run clean test lint help podman-image podman-build podman-test podman-lint docker-image docker-build docker-test docker-lint all: build @@ -36,13 +38,38 @@ lint: # go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest golangci-lint run ./... +podman-image: + @echo "Building Podman dev image (Go 1.26)..." + podman build -t $(PODMAN_IMAGE) -f Dockerfile.dev . + +podman-build: podman-image + @echo "Building $(APP_NAME) in Podman..." + $(PODMAN_RUN) go build -o $(APP_NAME) $(APP_PATH) + +podman-test: podman-image + @echo "Running tests in Podman..." + $(PODMAN_RUN) sh -c "mockery --config .mockery.yaml && go test ./..." + +podman-lint: podman-image + @echo "Running lint in Podman..." + $(PODMAN_RUN) sh -c "mockery --config .mockery.yaml && golangci-lint run --timeout=5m ./..." + +docker-image: podman-image +docker-build: podman-build +docker-test: podman-test +docker-lint: podman-lint + help: @echo "Usage: make " @echo "\nCommands:" - @echo " all : Builds the application (default)" - @echo " build : Compiles the application binary" - @echo " run : Runs the application" - @echo " clean : Removes build artifacts and the application binary" - @echo " test : Runs all tests" - @echo " lint : Runs go fmt and go vet" - @echo " help : Displays this help message" + @echo " all : Builds the application (default)" + @echo " build : Compiles the application binary" + @echo " run : Runs the application" + @echo " clean : Removes build artifacts and the application binary" + @echo " test : Runs all tests" + @echo " lint : Runs golangci-lint" + @echo " podman-image : Builds Podman dev image (Go 1.26)" + @echo " podman-build : Builds the binary inside Podman" + @echo " podman-test : Runs tests inside Podman" + @echo " podman-lint : Runs lint inside Podman" + @echo " help : Displays this help message" diff --git a/README.md b/README.md index 6b70de0..b145770 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ -
+

+ canery logo +

-# ๐Ÿšข Berth โ€“ Terminal UI for Containers +
-[![Go Version](https://img.shields.io/badge/go-1.24-blue?logo=go)](https://golang.org) +[![CI](https://github.com/rluders/berth/actions/workflows/ci.yml/badge.svg)](https://github.com/rluders/berth/actions/workflows/ci.yml) +![Go](https://img.shields.io/badge/go-1.26+-00ADD8?logo=go&logoColor=white) +[![Go Report Card](https://goreportcard.com/badge/github.com/rluders/berth)](https://goreportcard.com/report/github.com/rluders/berth) +[![GitHub release](https://img.shields.io/github/v/release/rluders/berth?sort=semver)](https://github.com/rluders/berth/releases) +[![codecov](https://codecov.io/gh/rluders/berth/graph/badge.svg)](https://codecov.io/gh/rluders/berth) [![License](https://img.shields.io/github/license/rluders/berth)](LICENSE) -[![Build](https://img.shields.io/badge/build-passing-brightgreen)]() -[![Status](https://img.shields.io/badge/status-alpha-orange)]() [![Made with Bubbletea](https://img.shields.io/badge/made%20with-bubbletea-ff69b4?logo=github)](https://github.com/charmbracelet/bubbletea) -> **Berth** is a terminal-based UI to manage your containers, images, volumes, networks, and system usage โ€” with support for **Docker** and **Podman**. -> ๐Ÿง  Name origin: In maritime terms, a **berth** is a designated place where a ship is docked โ€” just like containers in your stack. Clean, organized, and under control. +**Berth** is a terminal-based UI to manage your containers, images, volumes, networks, and system usage โ€” with support for **Docker** and **Podman**. Name origin: In maritime terms, a **berth** is a designated place where a ship is docked โ€” just like containers in your stack. Clean, organized, and under control.
@@ -31,7 +34,7 @@ Berth is a comprehensive terminal user interface (TUI) application built in Go, ### Prerequisites -- [Go](https://golang.org/doc/install) (version 1.24 or higher recommended) +- [Go](https://golang.org/doc/install) (version 1.26 or higher recommended) - [Docker](https://docs.docker.com/get-docker/) or [Podman](https://podman.io/docs/installation) installed and running ### Steps @@ -54,39 +57,72 @@ make run Berth provides an intuitive keyboard-driven interface. -### ๐ŸŽน Navigation +### ๐ŸŽน Global Keys -* `1` โ€” Containers View -* `2` โ€” Images View -* `3` โ€” Volumes View -* `4` โ€” Networks View -* `5` โ€” System View +| Key | Action | +| --------- | ------------------- | +| `1` | Containers view | +| `2` | Images view | +| `3` | Volumes view | +| `4` | Networks view | +| `5` | System view | +| `?` | Toggle help overlay | +| `q` / `esc` | Back / quit | +| `ctrl+c` | Quit | ### ๐Ÿ› ๏ธ Container Actions -| Key | Action | -| --- | ------------------------ | -| `s` | Start selected container | -| `x` | Stop selected container | -| `d` | Remove container | -| `l` | View logs | -| `i` | Inspect container | - -### ๐Ÿ“ฆ Image & Volume Actions - -* `d` โ€” Remove selected image or volume +| Key | Action | +| ------- | ------------------------- | +| `enter` | Container details | +| `s` | Start container | +| `x` | Stop container | +| `r` | Restart container | +| `d` | Remove container | +| `l` | View logs | +| `i` | Inspect container | +| `e` | Exec shell | +| `/` | Filter containers | +| `g` | Toggle group by compose | +| `โ†’` | Expand compose group | +| `โ†` | Collapse compose group | + +### ๐Ÿ“ฆ Image Actions + +| Key | Action | +| --- | -------------------- | +| `d` | Remove image | +| `P` | Prune dangling images | +| `/` | Filter images | + +### ๐Ÿ’พ Volume Actions + +| Key | Action | +| --- | ------------- | +| `d` | Remove volume | +| `/` | Filter volumes | + +### ๐ŸŒ Network Actions + +| Key | Action | +| --- | --------------- | +| `i` | Inspect network | ### ๐Ÿงผ System Cleanup -| Key | Action | -| --- | ---------------- | -| `b` | Basic Cleanup | -| `a` | Advanced Cleanup | -| `t` | Total Cleanup | +| Key | Action | +| --- | --------------------------------------------------- | +| `b` | Basic cleanup โ€” stopped containers, unused networks, dangling images | +| `a` | Advanced cleanup โ€” basic + unused volumes | +| `t` | Total cleanup โ€” all unused resources | -### ๐Ÿ”™ Back / Exit +### ๐Ÿ“‹ Logs View -* `q` or `esc` โ€” Return to the previous view or quit the application from the main views. +| Key | Action | +| --- | ------------------- | +| `p` | Pause log stream | +| `f` | Follow (tail) logs | +| `n` | Toggle line numbers | ## ๐Ÿ› ๏ธ Technology Stack @@ -99,14 +135,14 @@ Berth provides an intuitive keyboard-driven interface. ``` . -โ”œโ”€โ”€ cmd/ # CLI entry point (e.g., main.go) +โ”œโ”€โ”€ cmd/ # CLI entry point โ”œโ”€โ”€ internal/ -โ”‚ โ”œโ”€โ”€ tui/ # All Bubbletea models/views/components -โ”‚ โ”œโ”€โ”€ engine/ # Docker/Podman abstraction layer -โ”‚ โ”œโ”€โ”€ controller/ # Logic for container/image/volume actions -โ”‚ โ”œโ”€โ”€ state/ # Global state models -โ”‚ โ””โ”€โ”€ utils/ # Helpers: formatting, exec wrappers, etc. -โ”œโ”€โ”€ assets/ # Logo, themes, maybe future plugins +โ”‚ โ”œโ”€โ”€ tui/ # Bubble Tea models, views, and components +โ”‚ โ”œโ”€โ”€ engine/ # Docker/Podman client abstraction +โ”‚ โ”œโ”€โ”€ service/ # Service layer (container, image, volume, network, system) +โ”‚ โ”œโ”€โ”€ controller/ # Action handlers (start, stop, remove, inspect, โ€ฆ) +โ”‚ โ””โ”€โ”€ utils/ # Formatting helpers and exec wrappers +โ”œโ”€โ”€ docs/ # Assets (logo, screenshots) โ”œโ”€โ”€ go.mod โ””โ”€โ”€ README.md ``` diff --git a/cmd/berth/main.go b/cmd/berth/main.go index ed1fce7..4516bef 100644 --- a/cmd/berth/main.go +++ b/cmd/berth/main.go @@ -6,7 +6,7 @@ import ( "log/slog" "os" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/rluders/berth/internal/tui" ) @@ -18,7 +18,11 @@ func main() { fmt.Printf("Error opening log file: %v\n", err) os.Exit(1) } - defer logFile.Close() + defer func() { + if err := logFile.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Error closing log file: %v\n", err) + } + }() handler := slog.NewTextHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug}) slog.SetDefault(slog.New(handler)) @@ -42,7 +46,7 @@ func main() { } }() slog.Debug("Initializing Bubble Tea program...") - program = tea.NewProgram(tui.InitialModel(), tea.WithAltScreen()) + program = tea.NewProgram(tui.InitialModel()) }() slog.Debug("Running Bubble Tea program...") diff --git a/docs/assets/berth-logo.png b/docs/assets/berth-logo.png new file mode 100644 index 0000000..f83e98e Binary files /dev/null and b/docs/assets/berth-logo.png differ diff --git a/go.mod b/go.mod index 664de74..5e85216 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,28 @@ module github.com/rluders/berth -go 1.24.2 +go 1.26.0 require ( - github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.6 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/docker/docker v28.3.3+incompatible + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.3 + github.com/charmbracelet/x/ansi v0.11.7 + 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/atotto/clipboard v0.1.4 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // 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 @@ -25,22 +30,16 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect 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/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // 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 github.com/morikuni/aec v1.0.0 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -53,9 +52,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..9b20834 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,39 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= 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/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= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +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-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= 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/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -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/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/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/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/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/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +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/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +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/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= @@ -34,14 +44,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 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= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -49,29 +57,21 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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/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-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/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-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= @@ -82,12 +82,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -97,7 +93,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -109,12 +104,10 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= @@ -135,47 +128,20 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -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/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= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -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= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -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/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/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= diff --git a/internal/controller/compose.go b/internal/controller/compose.go new file mode 100644 index 0000000..b8fdda5 --- /dev/null +++ b/internal/controller/compose.go @@ -0,0 +1,71 @@ +package controller + +import ( + "bufio" + "context" + "io" + "os/exec" +) + +// StreamCompose runs a compose command and fans stdout+stderr line-by-line into ch. +// ch is closed when the process exits or ctx is cancelled. +func StreamCompose(ctx context.Context, project, workDir string, ch chan<- string, args ...string) error { + baseArgs := []string{"compose", "-p", project} + cmd := exec.CommandContext(ctx, "docker", append(baseArgs, args...)...) + if workDir != "" { + cmd.Dir = workDir + } + + pr, pw := io.Pipe() + cmd.Stdout = pw + cmd.Stderr = pw + + if err := cmd.Start(); err != nil { + close(ch) + return err + } + + go func() { + if err := pw.CloseWithError(cmd.Wait()); err != nil { + return + } + }() + + go func() { + defer close(ch) + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + case ch <- scanner.Text(): + } + } + }() + + return nil +} + +func ComposeUp(ctx context.Context, project, workDir string, ch chan<- string) error { + return StreamCompose(ctx, project, workDir, ch, "up", "-d") +} + +func ComposeUpBuild(ctx context.Context, project, workDir string, ch chan<- string) error { + return StreamCompose(ctx, project, workDir, ch, "up", "-d", "--build") +} + +func ComposeRecreate(ctx context.Context, project, workDir string, ch chan<- string) error { + return StreamCompose(ctx, project, workDir, ch, "up", "-d", "--force-recreate") +} + +func ComposeDown(ctx context.Context, project, workDir string, ch chan<- string) error { + return StreamCompose(ctx, project, workDir, ch, "down") +} + +func ComposePull(ctx context.Context, project, workDir string, ch chan<- string) error { + return StreamCompose(ctx, project, workDir, ch, "pull") +} + +func ComposeBuild(ctx context.Context, project, workDir string, ch chan<- string) error { + return StreamCompose(ctx, project, workDir, ch, "build") +} diff --git a/internal/controller/compose_test.go b/internal/controller/compose_test.go new file mode 100644 index 0000000..76f3cc1 --- /dev/null +++ b/internal/controller/compose_test.go @@ -0,0 +1,88 @@ +package controller + +import ( + "context" + "testing" + "time" + + "github.com/rluders/berth/internal/engine" + "github.com/stretchr/testify/assert" +) + +func TestStreamCompose_closesChanOnExit(t *testing.T) { + // Use a project name that will fail fast (docker compose -p X version exits quickly). + ctx := context.Background() + ch := make(chan string, 64) + + err := StreamCompose(ctx, "berth-test-nonexistent", "", ch, "version") + // StreamCompose may or may not error depending on docker availability. + // What matters: ch must be closed after output drains. + if err != nil { + // Channel closed inside StreamCompose on Start failure. + _, ok := <-ch + assert.False(t, ok, "channel must be closed on start error") + return + } + + // Drain and verify channel eventually closes. + done := make(chan struct{}) + go func() { + defer close(done) + for range ch { + } + }() + + select { + case <-done: + // channel closed โ€” pass + case <-time.After(10 * time.Second): + t.Fatal("channel not closed within 10s") + } +} + +func TestStreamCompose_ctxCancelStopsStream(t *testing.T) { + if engine.DetectEngine() == engine.Unknown { + t.Skip("docker not available") + } + + ctx, cancel := context.WithCancel(context.Background()) + ch := make(chan string, 64) + + err := StreamCompose(ctx, "berth-test-cancel", "", ch, "version") + if err != nil { + // docker compose plugin not available โ€” drain closed channel and skip. + for range ch { + } + t.Skipf("docker compose not available: %v", err) + } + + // Cancel immediately โ€” channel must close without blocking. + cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + for range ch { + } + }() + + select { + case <-done: + // pass + case <-time.After(5 * time.Second): + t.Fatal("channel not closed after context cancel") + } +} + +func TestComposeUp_callsStreamCompose(t *testing.T) { + // Smoke test: ComposeUp returns a function; calling it attempts compose up. + // We verify the function signature compiles and error type is sensible. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + ch := make(chan string, 64) + + // This will fail (no compose project) but must not panic. + _ = ComposeUp(ctx, "berth-noproject", "/tmp", ch) + for range ch { + } +} diff --git a/internal/controller/container.go b/internal/controller/container.go index 9abe8dc..57492bb 100644 --- a/internal/controller/container.go +++ b/internal/controller/container.go @@ -2,13 +2,18 @@ package controller import ( + "bufio" "context" "encoding/json" "fmt" "io" + "os/exec" "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 +23,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 +30,83 @@ 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 + State 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 +118,17 @@ 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, + State: c.State, + Ports: ports, + Names: strings.TrimPrefix(strings.Join(c.Names, ","), "/"), + Labels: c.Labels, }) } @@ -68,29 +145,87 @@ 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. -func GetContainerLogs(idOrName string) (string, error) { - out, err := containerService.ContainerLogs(context.Background(), idOrName, container.LogsOptions{ShowStdout: true, ShowStderr: true}) +// GetContainerLogs retrieves the logs of a container (one-shot). +func GetContainerLogs(idOrName string) (logs string, err error) { + 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() + defer func() { + if closeErr := out.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("failed to close logs for container %s: %w", idOrName, closeErr) + } + }() - 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 func() { + // StreamContainerLogs has no error return; close failure cannot be surfaced to callers. + _ = out.Close() + }() + + pr, pw := io.Pipe() + go func() { + var copyErr error + if _, err := stdcopy.StdCopy(pw, pw, out); err != nil { + // TTY container โ€” fallback to raw copy + if _, err2 := io.Copy(pw, out); err2 != nil { + copyErr = err2 + } + } + if err := pw.CloseWithError(copyErr); err != 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 +239,155 @@ 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) (stat ContainerStat, err error) { + resp, err := containerService.ContainerStats(context.Background(), idOrName, false) + if err != nil { + return ContainerStat{}, err + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("failed to close stats response for container %s: %w", idOrName, closeErr) + } + }() + + 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)) + } +} + +// ExecShell returns an exec.Cmd that opens an interactive shell in the container. +func ExecShell(containerID string) *exec.Cmd { + enginePath := engine.GetEnginePath() + if enginePath == "" { + enginePath = "docker" + } + return exec.Command(enginePath, "exec", "-it", containerID, "/bin/sh") +} + +// 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..c620820 100644 --- a/internal/controller/image.go +++ b/internal/controller/image.go @@ -5,6 +5,7 @@ import ( "context" "fmt" + "github.com/docker/docker/api/types/filters" dockerImageTypes "github.com/docker/docker/api/types/image" "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/controller/logs_stream.go b/internal/controller/logs_stream.go new file mode 100644 index 0000000..5250202 --- /dev/null +++ b/internal/controller/logs_stream.go @@ -0,0 +1,36 @@ +package controller + +import ( + "context" + "sync" +) + +// LogEntry carries a single log line with its source container name. +type LogEntry struct { + ContainerName string + Line string +} + +// StreamMultiContainerLogs fans out one goroutine per container, writing LogEntry +// values to ch. ch is closed when all goroutines finish or ctx is cancelled. +func StreamMultiContainerLogs(ctx context.Context, containers []Container, ch chan<- LogEntry) { + defer close(ch) + + var wg sync.WaitGroup + for _, c := range containers { + wg.Add(1) + go func(c Container) { + defer wg.Done() + lineCh := make(chan string, 100) + go StreamContainerLogs(ctx, c.ID, lineCh) + for line := range lineCh { + select { + case <-ctx.Done(): + return + case ch <- LogEntry{ContainerName: c.Names, Line: line}: + } + } + }(c) + } + wg.Wait() +} diff --git a/internal/controller/system.go b/internal/controller/system.go index 17fb32d..ff4e775 100644 --- a/internal/controller/system.go +++ b/internal/controller/system.go @@ -64,21 +64,21 @@ func BasicCleanup() (string, error) { _, err := systemService.ContainersPrune(context.Background(), filters.Args{}) if err != nil { - output.WriteString(fmt.Sprintf("Failed to prune containers: %s\n", err)) + fmt.Fprintf(&output, "Failed to prune containers: %s\n", err) } else { output.WriteString("Containers pruned successfully\n") } _, err = systemService.NetworksPrune(context.Background(), filters.Args{}) if err != nil { - output.WriteString(fmt.Sprintf("Failed to prune networks: %s\n", err)) + fmt.Fprintf(&output, "Failed to prune networks: %s\n", err) } else { output.WriteString("Networks pruned successfully\n") } _, err = systemService.ImagesPrune(context.Background(), filters.Args{}) if err != nil { - output.WriteString(fmt.Sprintf("Failed to prune images: %s\n", err)) + fmt.Fprintf(&output, "Failed to prune images: %s\n", err) } else { output.WriteString("Images pruned successfully\n") } @@ -92,7 +92,7 @@ func AdvancedCleanup() (string, error) { _, err := systemService.VolumesPrune(context.Background(), filters.Args{}) if err != nil { - output.WriteString(fmt.Sprintf("Failed to prune volumes: %s\n", err)) + fmt.Fprintf(&output, "Failed to prune volumes: %s\n", err) } else { output.WriteString("Volumes pruned successfully\n") } @@ -101,7 +101,7 @@ func AdvancedCleanup() (string, error) { args.Add("dangling", "true") _, err = systemService.ImagesPrune(context.Background(), args) if err != nil { - output.WriteString(fmt.Sprintf("Failed to prune dangling images: %s\n", err)) + fmt.Fprintf(&output, "Failed to prune dangling images: %s\n", err) } else { output.WriteString("Dangling images pruned successfully\n") } 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/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..ba3bd66 --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,44 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectEngine_returnsKnownType(t *testing.T) { + engine := DetectEngine() + assert.Contains(t, []EngineType{Docker, Podman, Unknown}, engine, + "detected engine must be Docker, Podman, or Unknown") +} + +func TestGetEnginePath_emptyWhenUnknown(t *testing.T) { + if DetectEngine() == Unknown { + assert.Empty(t, GetEnginePath(), "engine path must be empty when engine is Unknown") + } +} + +func TestGetEnginePath_nonEmptyWhenDetected(t *testing.T) { + if DetectEngine() != Unknown { + assert.NotEmpty(t, GetEnginePath(), "engine path must be set when engine is detected") + } +} + +func TestGetEnginePath_consistentWithDetect(t *testing.T) { + engine := DetectEngine() + path := GetEnginePath() + if engine == Unknown { + assert.Empty(t, path) + } else { + assert.NotEmpty(t, path) + } +} + +func TestRunEngineCommand_failsWhenUnknown(t *testing.T) { + if DetectEngine() != Unknown { + t.Skip("engine is available โ€” skipping unknown-engine error test") + } + _, _, err := RunEngineCommand("version") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no container engine detected") +} 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/column.go b/internal/tui/column.go new file mode 100644 index 0000000..598f432 --- /dev/null +++ b/internal/tui/column.go @@ -0,0 +1,156 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/table" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// AlignType controls horizontal text alignment within a column. +type AlignType int + +const ( + AlignLeft AlignType = iota + AlignRight +) + +// Column describes a column's layout properties and its computed width. +// Width is zero in the package-level specs; set by BuildColumns. +type Column struct { + Header string + MinWidth int // flexible: expands with terminal (Fixed must be 0) + Fixed int // fixed: exact char width (MinWidth must be 0) + Align AlignType + Width int // computed by BuildColumns +} + +// containerCols defines the canonical column specs for the containers table. +var containerCols = []Column{ + {Header: "Name", MinWidth: 20, Align: AlignLeft}, + {Header: "Status", Fixed: 14, Align: AlignLeft}, + {Header: "Image", MinWidth: 20, Align: AlignLeft}, + {Header: "Ports", Fixed: 18, Align: AlignLeft}, + {Header: "CPU%", Fixed: 6, Align: AlignRight}, + {Header: "Mem", Fixed: 10, Align: AlignRight}, + {Header: "Age", Fixed: 6, Align: AlignRight}, +} + +var imageCols = []Column{ + {Header: "ID", Fixed: 14, Align: AlignLeft}, + {Header: "Repository", MinWidth: 30, Align: AlignLeft}, + {Header: "Tag", MinWidth: 20, Align: AlignLeft}, + {Header: "Size", Fixed: 12, Align: AlignRight}, + {Header: "Created", Fixed: 12, Align: AlignRight}, +} + +var volumeCols = []Column{ + {Header: "Name", MinWidth: 30, Align: AlignLeft}, + {Header: "Driver", Fixed: 12, Align: AlignLeft}, + {Header: "Scope", Fixed: 10, Align: AlignLeft}, + {Header: "Mountpoint", MinWidth: 60, Align: AlignLeft}, +} + +var networkCols = []Column{ + {Header: "ID", MinWidth: 20, Align: AlignLeft}, + {Header: "Name", MinWidth: 30, Align: AlignLeft}, + {Header: "Driver", Fixed: 12, Align: AlignLeft}, + {Header: "Scope", Fixed: 10, Align: AlignLeft}, +} + +// BuildColumns returns a copy of specs with Width computed for the given +// terminal width. Fixed columns keep their exact size; flexible columns share +// the remaining budget proportionally. Any rounding remainder goes to the last +// flexible column so the table always fills 100% of the available width. +func BuildColumns(width int, specs []Column) []Column { + fixedSum, totalMinFlex := 0, 0 + for _, c := range specs { + if c.Fixed > 0 { + fixedSum += c.Fixed + } else { + totalMinFlex += c.MinWidth + } + } + + // Bubble Tea table adds 1-space padding on each side per column. + paddingOverhead := len(specs) * 2 + flexBudget := width - fixedSum - paddingOverhead + if flexBudget < totalMinFlex { + flexBudget = totalMinFlex + } + + cols := make([]Column, len(specs)) + copy(cols, specs) + + assigned := 0 + lastFlexIdx := -1 + for i, c := range cols { + if c.Fixed > 0 { + cols[i].Width = c.Fixed + } else { + w := flexBudget * c.MinWidth / totalMinFlex + if w < c.MinWidth { + w = c.MinWidth + } + cols[i].Width = w + assigned += w + lastFlexIdx = i + } + } + // Give rounding remainder to last flex column โ†’ fills 100% width. + if lastFlexIdx >= 0 { + remainder := flexBudget - assigned + if remainder > 0 { + cols[lastFlexIdx].Width += remainder + } + } + return cols +} + +func tableColumns(width int, specs []Column) []table.Column { + cols := BuildColumns(width, specs) + tableCols := make([]table.Column, len(cols)) + for i, col := range cols { + tableCols[i] = table.Column{Title: col.Header, Width: col.Width} + } + return tableCols +} + +// renderCell truncates to width (ANSI-safe) then applies lipgloss padding/alignment. +// Truncate happens before styling so ANSI escape codes from styled values (e.g. +// FormatStatus) are measured correctly and not re-counted by the style engine. +func renderCell(value string, width int, align AlignType) string { + value = ansi.Truncate(value, width, "โ€ฆ") + + style := lipgloss.NewStyle().Width(width) + if align == AlignRight { + style = style.Align(lipgloss.Right) + } else { + style = style.Align(lipgloss.Left) + } + return style.Render(value) +} + +// RenderRow builds a row of pre-styled cells using renderCell for each column. +func RenderRow(cols []Column, values []string) []string { + row := make([]string, len(cols)) + for i, v := range values { + row[i] = renderCell(v, cols[i].Width, cols[i].Align) + } + return row +} + +// padHeader returns a plain-text header string padded to exactly width chars. +// Headers are always ASCII so len() == visible width. +func padHeader(value string, width int, align AlignType) string { + vw := len(value) + if vw >= width { + return value[:width] + } + pad := strings.Repeat(" ", width-vw) + if align == AlignRight { + return pad + value + } + return value + pad +} diff --git a/internal/tui/column_test.go b/internal/tui/column_test.go new file mode 100644 index 0000000..106f842 --- /dev/null +++ b/internal/tui/column_test.go @@ -0,0 +1,47 @@ +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildColumns_fillsWidthBudget(t *testing.T) { + cols := BuildColumns(121, imageCols) + + assert.Equal(t, 121, paddedColumnWidth(cols)) +} + +func TestBuildColumns_fillsWidthBudgetForAllTableSpecs(t *testing.T) { + for name, specs := range map[string][]Column{ + "containers": containerCols, + "images": imageCols, + "volumes": volumeCols, + "networks": networkCols, + } { + t.Run(name, func(t *testing.T) { + cols := BuildColumns(140, specs) + + assert.Equal(t, 140, paddedColumnWidth(cols)) + }) + } +} + +func TestTableColumns_preservesTitlesAndPositiveWidths(t *testing.T) { + cols := tableColumns(120, volumeCols) + + require.Len(t, cols, len(volumeCols)) + for i, col := range cols { + assert.Equal(t, volumeCols[i].Header, col.Title) + assert.Positive(t, col.Width) + } +} + +func paddedColumnWidth(cols []Column) int { + total := len(cols) * 2 + for _, col := range cols { + total += col.Width + } + return total +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..6179512 --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,406 @@ +package tui + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/rluders/berth/internal/controller" +) + +// โ”€โ”€ Fetch commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +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(), + ) +} + +// โ”€โ”€ 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", "id", idOrName) + if err := controller.StartContainer(idOrName); err != nil { + return errMsg{err} + } + return statusMsg(fmt.Sprintf("Container %s started.", idOrName)) + } +} + +func stopContainerCmd(idOrName string) tea.Cmd { + return func() tea.Msg { + slog.Debug("stopContainerCmd", "id", idOrName) + if err := controller.StopContainer(idOrName); err != nil { + 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", "id", idOrName) + if err := controller.RemoveContainer(idOrName); err != nil { + return errMsg{err} + } + return statusMsg(fmt.Sprintf("Container %s removed.", idOrName)) + } +} + +func startGroupContainersCmd(containers []controller.Container) tea.Cmd { + return func() tea.Msg { + var errs []string + for _, c := range containers { + if err := controller.StartContainer(c.ID); err != nil { + errs = append(errs, err.Error()) + } + } + if len(errs) > 0 { + return errMsg{fmt.Errorf("start errors: %s", strings.Join(errs, "; "))} + } + return statusMsg(fmt.Sprintf("Group started (%d containers).", len(containers))) + } +} + +func stopGroupContainersCmd(containers []controller.Container) tea.Cmd { + return func() tea.Msg { + var errs []string + for _, c := range containers { + if err := controller.StopContainer(c.ID); err != nil { + errs = append(errs, err.Error()) + } + } + if len(errs) > 0 { + return errMsg{fmt.Errorf("stop errors: %s", strings.Join(errs, "; "))} + } + return statusMsg(fmt.Sprintf("Group stopped (%d containers).", len(containers))) + } +} + +func restartGroupContainersCmd(containers []controller.Container) tea.Cmd { + return func() tea.Msg { + var errs []string + for _, c := range containers { + if err := controller.RestartContainer(c.ID); err != nil { + errs = append(errs, err.Error()) + } + } + if len(errs) > 0 { + return errMsg{fmt.Errorf("restart errors: %s", strings.Join(errs, "; "))} + } + return statusMsg(fmt.Sprintf("Group restarted (%d containers).", len(containers))) + } +} + +func fetchDetailsCmd(idOrName string) tea.Cmd { + return func() tea.Msg { + slog.Debug("fetchDetailsCmd", "id", idOrName) + details, err := controller.GetContainerDetails(idOrName) + if err != nil { + return errMsg{err} + } + return detailsMsg(details) + } +} + +func inspectContainerCmd(idOrName string) tea.Cmd { + return func() tea.Msg { + slog.Debug("inspectContainerCmd", "id", idOrName) + output, err := controller.InspectContainer(idOrName) + if err != nil { + 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 startGroupLogStreamCmd(containers []controller.Container) (chan string, context.CancelFunc, tea.Cmd) { + ch := make(chan string, 500) + ctx, cancel := context.WithCancel(context.Background()) + entryCh := make(chan controller.LogEntry, 500) + go controller.StreamMultiContainerLogs(ctx, containers, entryCh) + go func() { + defer close(ch) + for entry := range entryCh { + ch <- "[" + entry.ContainerName + "] " + entry.Line + } + }() + 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", "id", idOrName) + if err := controller.RemoveImage(idOrName); err != nil { + 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", "name", name) + if err := controller.RemoveVolume(name); err != nil { + 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", "id", idOrName) + output, err := controller.InspectNetwork(idOrName) + if err != nil { + 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 { + output, err := controller.BasicCleanup() + if err != nil { + return errMsg{err} + } + return progressMsg{percent: 1.0, label: "Basic cleanup: " + output, done: true} + } +} + +func advancedCleanupCmd() tea.Cmd { + return func() tea.Msg { + output, err := controller.AdvancedCleanup() + if err != nil { + return errMsg{err} + } + return progressMsg{percent: 1.0, label: "Advanced cleanup: " + output, done: true} + } +} + +func totalCleanupCmd() tea.Cmd { + return func() tea.Msg { + output, err := controller.TotalCleanup() + if err != nil { + return errMsg{err} + } + return progressMsg{percent: 1.0, label: "Total cleanup: " + output, done: true} + } +} + +// โ”€โ”€ Compose commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// composeStreamCmd starts a compose operation and returns the first streamed line as a message. +// Subsequent lines are self-scheduled via readNextComposeLineCmd. +func composeStreamCmd(ctx context.Context, project, workDir string, fn func(context.Context, string, string, chan<- string) error) tea.Cmd { + return func() tea.Msg { + ch := make(chan string, 64) + if err := fn(ctx, project, workDir, ch); err != nil { + return composeDoneMsg{project: project, err: err} + } + line, ok := <-ch + if !ok { + return composeDoneMsg{project: project} + } + return composeOutputMsg{project: project, line: line, ch: ch} + } +} + +// readNextComposeLineCmd reads the next line from an in-progress compose stream. +func readNextComposeLineCmd(ch <-chan string, project string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return composeDoneMsg{project: project} + } + return composeOutputMsg{project: project, line: line, ch: ch} + } +} + +func composeUpCmd(ctx context.Context, project, workDir string) tea.Cmd { + return composeStreamCmd(ctx, project, workDir, controller.ComposeUp) +} + +func composeUpBuildCmd(ctx context.Context, project, workDir string) tea.Cmd { + return composeStreamCmd(ctx, project, workDir, controller.ComposeUpBuild) +} + +func composeRecreateCmd(ctx context.Context, project, workDir string) tea.Cmd { + return composeStreamCmd(ctx, project, workDir, controller.ComposeRecreate) +} + +func composeDownCmd(ctx context.Context, project, workDir string) tea.Cmd { + return composeStreamCmd(ctx, project, workDir, controller.ComposeDown) +} + +func composePullCmd(ctx context.Context, project, workDir string) tea.Cmd { + return composeStreamCmd(ctx, project, workDir, controller.ComposePull) +} + +func composeBuildCmd(ctx context.Context, project, workDir string) tea.Cmd { + return composeStreamCmd(ctx, project, workDir, controller.ComposeBuild) +} + +// โ”€โ”€ Exec shell โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func execShellCmd(containerID string) tea.Cmd { + cmd := controller.ExecShell(containerID) + 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/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/container_group.go b/internal/tui/container_group.go new file mode 100644 index 0000000..8fab34e --- /dev/null +++ b/internal/tui/container_group.go @@ -0,0 +1,107 @@ +package tui + +import ( + "github.com/rluders/berth/internal/controller" +) + +type RowType int + +const ( + RowTypeGroup RowType = iota // compose project header row + RowTypeContainer // individual container row +) + +// Row is the canonical unit of the visible containers list. +type Row struct { + Type RowType + GroupID string // project name; empty for standalone containers + Name string + Collapsed bool // group rows: current collapse state + Containers []controller.Container // group rows: member containers + Container *controller.Container // container rows: the container +} + +type composeGroup struct { + project string + containers []controller.Container +} + +// BuildRows computes the flat visible row list from containers and collapse state. +func BuildRows(containers []controller.Container, collapsed map[string]bool) []Row { + groups, standalone := buildComposeGroups(containers) + + var rows []Row + for _, g := range groups { + isCollapsed := collapsed[g.project] + rows = append(rows, Row{ + Type: RowTypeGroup, + GroupID: g.project, + Name: g.project, + Collapsed: isCollapsed, + Containers: g.containers, + }) + if !isCollapsed { + for _, c := range g.containers { + c := c + rows = append(rows, Row{ + Type: RowTypeContainer, + GroupID: g.project, + Name: c.Names, + Container: &c, + }) + } + } + } + for _, c := range standalone { + c := c + rows = append(rows, Row{ + Type: RowTypeContainer, + GroupID: "", + Name: c.Names, + Container: &c, + }) + } + return rows +} + +// buildComposeGroups partitions containers into ordered compose groups and a +// standalone slice (containers without the compose project label). +func buildComposeGroups(containers []controller.Container) (groups []composeGroup, standalone []controller.Container) { + projectIndex := map[string]int{} + for _, c := range containers { + project := c.Labels["com.docker.compose.project"] + if project == "" { + standalone = append(standalone, c) + continue + } + if idx, ok := projectIndex[project]; ok { + groups[idx].containers = append(groups[idx].containers, c) + } else { + projectIndex[project] = len(groups) + groups = append(groups, composeGroup{project: project, containers: []controller.Container{c}}) + } + } + return +} + +// groupAggStatus counts running vs total containers in a group. +func groupAggStatus(containers []controller.Container) (running, total int) { + for _, c := range containers { + total++ + if c.State == "running" { + running++ + } + } + return +} + +// findGroupContainers returns all containers belonging to the given compose project. +func findGroupContainers(containers []controller.Container, project string) []controller.Container { + var result []controller.Container + for _, c := range containers { + if c.Labels["com.docker.compose.project"] == project { + result = append(result, c) + } + } + return result +} 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/keybindings.go b/internal/tui/keybindings.go new file mode 100644 index 0000000..d6c42d9 --- /dev/null +++ b/internal/tui/keybindings.go @@ -0,0 +1,293 @@ +package tui + +import "charm.land/bubbles/v2/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 + TabNext key.Binding + TabPrev 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 + Expand key.Binding + Collapse key.Binding + QuickActions key.Binding +} + +// ComposeKeys holds key bindings for compose project-level actions. +type ComposeKeys struct { + Up key.Binding + UpBuild key.Binding + Recreate key.Binding + Down key.Binding + Pull key.Binding + Build 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 + Compose ComposeKeys + 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"), + ), + TabNext: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next tab"), + ), + TabPrev: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev tab"), + ), + }, + 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"), + ), + Expand: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("โ†’", "expand group"), + ), + Collapse: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("โ†", "collapse group"), + ), + QuickActions: key.NewBinding( + key.WithKeys("space"), + key.WithHelp("space", "actions"), + ), + }, + Compose: ComposeKeys{ + Up: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "compose up"), + ), + UpBuild: key.NewBinding( + key.WithKeys("U"), + key.WithHelp("U", "compose up --build"), + ), + Recreate: key.NewBinding( + key.WithKeys("R"), + key.WithHelp("R", "compose recreate"), + ), + Down: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "compose down"), + ), + Pull: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "compose pull"), + ), + Build: key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "compose build"), + ), + }, + 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..ae08116 --- /dev/null +++ b/internal/tui/keymaps.go @@ -0,0 +1,141 @@ +package tui + +import "charm.land/bubbles/v2/key" + +// containersKeyMap implements help.KeyMap for the containers view. +type containersKeyMap struct{} + +func (containersKeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + Keys.Container.QuickActions, + 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.QuickActions, 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.Expand, Keys.Container.Collapse}, + {Keys.Compose.Up, Keys.Compose.UpBuild, Keys.Compose.Recreate, Keys.Compose.Down}, + {Keys.Compose.Pull, Keys.Compose.Build}, + {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..9ce9aeb --- /dev/null +++ b/internal/tui/modal.go @@ -0,0 +1,211 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// 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", "q")), + 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.KeyPressMsg) (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 +} + +// 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 new file mode 100644 index 0000000..af40e11 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,410 @@ +package tui + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/progress" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/table" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "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 + viewStack []ViewType + + // Container viewport (replaces table.Model โ€” bypasses double-styling that hides Status) + containerVP viewport.Model + containerCursor int + + // Tables (images, volumes, networks still use bubbles/table) + 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 + + // Accordion state + collapsedGroups map[string]bool + rows []Row + + // System info + systemInfo controller.SystemInfo + + // Inspect view + inspectViewPort viewport.Model + inspectReady bool + inspectRawContent string + currentInspectID string + + // Logs view + logViewPort viewport.Model + logReady bool + logLines []string + logFollowing bool + logCh chan string + logCancel context.CancelFunc + currentLogContainerID string + currentLogGroupName string + 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 + + // Quick actions overlay (space key) + quickMenu *QuickMenu + + // 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 + + // Computed columns for the containers table (recomputed on resize). + builtCols []Column + + // Last action key pressed in ContainersView (drives command preview). + lastActionKey string + + // Compose streaming state + composeOutput []string // rolling 200-line buffer of streamed compose output + composeCancel context.CancelFunc // cancels the running compose operation, nil when idle +} + +// InitialModel returns an initialized Model with default values. +func InitialModel() Model { + slog.Debug("InitialModel called") + + initCols := BuildColumns(116, containerCols) // 120-col default until first WindowSizeMsg + + imageTable := table.New( + table.WithColumns(tableColumns(120, imageCols)), + table.WithFocused(false), + table.WithHeight(0), + ) + + volumeTable := table.New( + table.WithColumns(tableColumns(120, volumeCols)), + table.WithFocused(false), + table.WithHeight(0), + ) + + networkTable := table.New( + table.WithColumns(tableColumns(120, networkCols)), + table.WithFocused(false), + table.WithHeight(0), + ) + + s := tableStyles() + imageTable.SetStyles(s) + volumeTable.SetStyles(s) + networkTable.SetStyles(s) + + fi := textinput.New() + fi.Placeholder = "filter..." + fi.CharLimit = 60 + + return Model{ + engineType: engine.DetectEngine(), + currentView: ContainersView, + containerVP: viewport.New(), + builtCols: initCols, + imageTable: imageTable, + volumeTable: volumeTable, + networkTable: networkTable, + containerStats: make(map[string]controller.ContainerStat), + collapsedGroups: loadedCollapsedGroups(), + systemInfo: controller.SystemInfo{}, + inspectViewPort: viewport.New(), + logViewPort: viewport.New(), + detailsViewPort: viewport.New(), + logFollowing: true, + filterInput: fi, + spinner: spinner.New(), + helpModel: help.New(), + progressBar: progress.New( + progress.WithDefaultBlend(), + progress.WithoutPercentage(), + ), + } +} + +// Init initializes the Bubble Tea program. +func (m Model) Init() tea.Cmd { + slog.Debug("Init called") + return tea.Batch(fetchAllCmd(), m.spinner.Tick, statsTickCmd(), refreshTickCmd()) +} + +func tableStyles() table.Styles { + s := table.DefaultStyles() + s.Header = currentTheme.TableHeaderStyle.Padding(0, 1) + s.Selected = currentTheme.TableSelectedStyle + return s +} + +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: + if m.currentLogGroupName != "" { + return fmt.Sprintf("Logs %s", m.currentLogGroupName) + } + return fmt.Sprintf("Logs %s", m.currentLogContainerID) + case DetailsView: + return fmt.Sprintf("Details %s", m.currentDetailsID) + } + return "Unknown" +} + +func (m Model) headerText() string { + 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) + } + return fmt.Sprintf("Berth %s %s Engine%s", view, eng, extra) +} + +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. +func (m Model) contentHeight() int { + h := m.height + h -= 1 // header row + h -= 1 // tab bar row + h -= 2 // footer (key hints + base) + if m.currentView == ContainersView { + h -= 1 // command preview 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 +} + +// recomputeRows applies filter, rebuilds m.rows via BuildRows, and syncs the viewport. +func (m *Model) recomputeRows() { + filter := strings.ToLower(m.filterInput.Value()) + var filtered []controller.Container + for _, c := range m.containers { + if filter != "" { + haystack := strings.ToLower(c.Names + " " + c.Image + " " + c.Status + " " + c.State) + if !strings.Contains(haystack, filter) { + continue + } + } + filtered = append(filtered, c) + } + m.rows = BuildRows(filtered, m.collapsedGroups) + // Clamp cursor after filter may reduce row count. + if len(m.rows) > 0 && m.containerCursor >= len(m.rows) { + m.containerCursor = len(m.rows) - 1 + } + m.syncContainerViewport() +} + +// renderContainerHeader returns a styled header line for the containers viewport. +func (m Model) renderContainerHeader() string { + cells := make([]string, len(m.builtCols)) + for i, col := range m.builtCols { + cells[i] = renderCell(padHeader(col.Header, col.Width, col.Align), col.Width, col.Align) + } + return currentTheme.TableHeaderStyle.Width(m.width).Render(strings.Join(cells, " ")) +} + +// renderContainerViewRow renders one row as a full-width string with optional selection highlight. +func (m Model) renderContainerViewRow(row Row, selected bool) string { + var values []string + switch row.Type { + case RowTypeGroup: + running, total := groupAggStatus(row.Containers) + label := GroupStatusColor(running, total) + prefix := "โ–ผ " + if row.Collapsed { + prefix = "โ–ถ " + } + values = []string{currentTheme.GroupHeaderStyle.Render(prefix + row.GroupID), label, "", "", "", "", ""} + + case RowTypeContainer: + c := row.Container + cpuStr := "-" + memStr := "-" + if c.State == "running" { + stat, ok := m.containerStats[c.ID] + if !ok { + cpuStr = "..." + memStr = "..." + } else { + cpuStr = fmt.Sprintf("%.1f", stat.CPUPercent) + if stat.MemLimit > 0 { + memStr = utils.FormatBytes(stat.MemUsage) + } else { + memStr = "..." + } + } + } + name := c.Names + if row.GroupID != "" { + name = currentTheme.GroupChildStyle.Render(" โ€บ " + c.Names) + } + values = []string{ + name, + FormatStatus(c.State), + simplifyImage(c.Image), + c.Ports, + cpuStr, + memStr, + utils.FormatAge(c.CreatedAt), + } + } + + line := strings.Join(RenderRow(m.builtCols, values), " ") + if selected { + // Strip ANSI from pre-styled cells so selection background renders uniformly. + line = currentTheme.TableSelectedStyle.Width(m.width).Render(ansi.Strip(line)) + } + return line +} + +// syncContainerViewport re-renders all rows into the viewport and scrolls to keep cursor visible. +func (m *Model) syncContainerViewport() { + lines := make([]string, len(m.rows)) + for i, row := range m.rows { + lines[i] = m.renderContainerViewRow(row, i == m.containerCursor) + } + m.containerVP.SetContent(strings.Join(lines, "\n")) + // Ensure cursor is visible. + if m.containerCursor < m.containerVP.YOffset() { + m.containerVP.SetYOffset(m.containerCursor) + } else if m.containerVP.Height() > 0 && m.containerCursor >= m.containerVP.YOffset()+m.containerVP.Height() { + m.containerVP.SetYOffset(m.containerCursor - m.containerVP.Height() + 1) + } +} + +// moveContainerCursor moves the cursor by delta, clamped to valid row range. +func (m *Model) moveContainerCursor(delta int) { + n := len(m.rows) + if n == 0 { + return + } + m.containerCursor = max(0, min(m.containerCursor+delta, n-1)) + m.syncContainerViewport() +} + +// simplifyImage strips registry/org prefixes, returning only the last path +// segment (image name + tag). e.g. docker.io/library/postgres:16 โ†’ postgres:16 +func simplifyImage(img string) string { + parts := strings.Split(img, "/") + return parts[len(parts)-1] +} + +// 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/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..233c9d4 --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,50 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" +) + +func TestInitialModel_defaultView(t *testing.T) { + m := InitialModel() + assert.Equal(t, ContainersView, m.currentView) +} + +func TestInitialModel_mapsInitialized(t *testing.T) { + m := InitialModel() + assert.NotNil(t, m.containerStats) + assert.NotNil(t, m.collapsedGroups) +} + +func TestInitialModel_spinnerReady(t *testing.T) { + m := InitialModel() + cmd := m.Init() + assert.NotNil(t, cmd) +} + +func TestRenderContainerHeader_fillsModelWidth(t *testing.T) { + m := InitialModel() + result, _ := updateModel(t, m, windowSize(120, 40)) + + for _, line := range strings.Split(result.renderContainerHeader(), "\n") { + assert.Equal(t, result.width, lipgloss.Width(line)) + } +} + +func TestRenderContainerSelectedRow_fillsModelWidth(t *testing.T) { + m := InitialModel() + result, _ := updateModel(t, m, windowSize(120, 40)) + row := Row{Type: RowTypeGroup, GroupID: "project"} + + line := result.renderContainerViewRow(row, true) + + assert.Equal(t, result.width, lipgloss.Width(line)) +} + +func windowSize(width, height int) tea.WindowSizeMsg { + return tea.WindowSizeMsg{Width: width, Height: height} +} 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/quick_menu.go b/internal/tui/quick_menu.go new file mode 100644 index 0000000..a2f70cb --- /dev/null +++ b/internal/tui/quick_menu.go @@ -0,0 +1,240 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// QuickMenuItem is one selectable action in the quick actions overlay. +type QuickMenuItem struct { + Label string + Key string + Action func(m Model) (Model, tea.Cmd) +} + +// QuickMenu is a vertical action selection overlay for a container. +type QuickMenu struct { + Title string + Items []QuickMenuItem + focused int +} + +// FocusNext moves selection down (wraps). +func (q *QuickMenu) FocusNext() { + q.focused = (q.focused + 1) % len(q.Items) +} + +// FocusPrev moves selection up (wraps). +func (q *QuickMenu) FocusPrev() { + q.focused = (q.focused - 1 + len(q.Items)) % len(q.Items) +} + +// Activate calls the focused item's action. +func (q *QuickMenu) Activate(m Model) (Model, tea.Cmd) { + if q.focused >= 0 && q.focused < len(q.Items) { + return q.Items[q.focused].Action(m) + } + return m, nil +} + +// View renders the quick menu box. +func (q QuickMenu) View(width int) string { + th := currentTheme + + title := th.ModalTitleStyle.Render(q.Title) + + var lines []string + for i, item := range q.Items { + keyBadge := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Render(fmt.Sprintf("[%s]", item.Key)) + + label := item.Label + if i == q.focused { + row := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorBase)). + Background(lipgloss.Color(colorMauve)). + Bold(true). + Padding(0, 1). + Render(fmt.Sprintf("%s %s", keyBadge, label)) + lines = append(lines, row) + } else { + row := lipgloss.NewStyle(). + Padding(0, 1). + Render(keyBadge + " " + label) + lines = append(lines, row) + } + } + + hint := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Render("โ†‘/โ†“ select enter run esc close") + + inner := lipgloss.JoinVertical( + lipgloss.Left, + title, + "", + strings.Join(lines, "\n"), + "", + hint, + ) + + boxW := 36 + if width-8 > boxW { + boxW = width - 8 + } + if boxW > 48 { + boxW = 48 + } + + box := th.ModalBoxStyle.Width(boxW).Render(inner) + + boxRenderedW := lipgloss.Width(box) + leftPad := (width - boxRenderedW) / 2 + if leftPad < 0 { + leftPad = 0 + } + + return lipgloss.NewStyle().PaddingLeft(leftPad).Render(box) +} + +var quickMenuKeys = struct { + Up key.Binding + Down key.Binding + Confirm key.Binding + Cancel key.Binding +}{ + Up: key.NewBinding(key.WithKeys("up", "k")), + Down: key.NewBinding(key.WithKeys("down", "j")), + Confirm: key.NewBinding(key.WithKeys("enter")), + Cancel: key.NewBinding(key.WithKeys("esc", "q")), +} + +// handleQuickMenuKey processes key input when the quick menu is open. +func (m Model) handleQuickMenuKey(msg tea.KeyPressMsg) (Model, tea.Cmd) { + qm := m.quickMenu + if qm == nil { + return m, nil + } + + switch { + case key.Matches(msg, quickMenuKeys.Cancel): + m.quickMenu = nil + return m, nil + case key.Matches(msg, quickMenuKeys.Up): + qm.FocusPrev() + return m, nil + case key.Matches(msg, quickMenuKeys.Down): + qm.FocusNext() + return m, nil + case key.Matches(msg, quickMenuKeys.Confirm): + m.quickMenu = nil + return qm.Activate(m) + default: + for _, item := range qm.Items { + if msg.String() == item.Key { + m.quickMenu = nil + return item.Action(m) + } + } + } + + return m, nil +} + +// renderQuickMenu overlays the quick menu centered on a background string. +func (m Model) renderQuickMenu(bg string) string { + if m.quickMenu == nil { + return bg + } + menuView := m.quickMenu.View(m.width) + + bgLines := strings.Split(bg, "\n") + menuLines := strings.Split(menuView, "\n") + + bgH := len(bgLines) + mH := len(menuLines) + startY := (bgH - mH) / 2 + if startY < 0 { + startY = 0 + } + + for i, line := range menuLines { + idx := startY + i + if idx < len(bgLines) { + bgLines[idx] = line + } else { + bgLines = append(bgLines, line) + } + } + + return strings.Join(bgLines, "\n") +} + +// NewContainerQuickMenu builds a quick actions menu for a container row. +func NewContainerQuickMenu(id, name string) *QuickMenu { + return &QuickMenu{ + Title: fmt.Sprintf("Actions %s", name), + Items: []QuickMenuItem{ + { + Label: "Logs", + Key: "l", + Action: func(m Model) (Model, tea.Cmd) { + m.stopLogStream() + m.logLines = nil + m.logFollowing = true + m.currentLogContainerID = id + m.currentLogGroupName = "" + m.pushView(LogsView) + m.logReady = true + ch, cancel, waitCmd := startLogStreamCmd(id) + m.logCh = ch + m.logCancel = cancel + return m, waitCmd + }, + }, + { + Label: "Exec shell", + Key: "e", + Action: func(m Model) (Model, tea.Cmd) { + return m, execShellCmd(id) + }, + }, + { + Label: "Restart", + Key: "r", + Action: func(m Model) (Model, tea.Cmd) { + m.statusMessage = fmt.Sprintf("docker restart %s", name) + m.showSpinner = true + return m, tea.Batch(restartContainerCmd(id), m.spinner.Tick) + }, + }, + { + Label: "Stop", + Key: "x", + Action: func(m Model) (Model, tea.Cmd) { + m.statusMessage = fmt.Sprintf("docker stop %s", name) + m.showSpinner = true + return m, tea.Batch(stopContainerCmd(id), m.spinner.Tick) + }, + }, + { + Label: "Delete", + Key: "d", + Action: func(m Model) (Model, tea.Cmd) { + 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 + return m, nil + }, + }, + }, + } +} diff --git a/internal/tui/state_persistence.go b/internal/tui/state_persistence.go new file mode 100644 index 0000000..2c3d4c7 --- /dev/null +++ b/internal/tui/state_persistence.go @@ -0,0 +1,64 @@ +package tui + +import ( + "encoding/json" + "log/slog" + "os" + "path/filepath" +) + +type persistedState struct { + CollapsedGroups map[string]bool `json:"collapsedGroups"` +} + +func stateFilePath() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(cacheDir, "berth", "state.json"), nil +} + +func loadState() persistedState { + path, err := stateFilePath() + if err != nil { + return persistedState{} + } + data, err := os.ReadFile(path) + if err != nil { + return persistedState{} + } + var s persistedState + if err := json.Unmarshal(data, &s); err != nil { + slog.Debug("loadState: parse error", "err", err) + return persistedState{} + } + return s +} + +func loadedCollapsedGroups() map[string]bool { + if m := loadState().CollapsedGroups; m != nil { + return m + } + return make(map[string]bool) +} + +func saveState(s persistedState) { + path, err := stateFilePath() + if err != nil { + slog.Debug("saveState: cache dir error", "err", err) + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + slog.Debug("saveState: mkdir error", "err", err) + return + } + data, err := json.Marshal(s) + if err != nil { + slog.Debug("saveState: marshal error", "err", err) + return + } + if err := os.WriteFile(path, data, 0o640); err != nil { + slog.Debug("saveState: write error", "err", err) + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..b06f62e --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,406 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" +) + +// 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 { + // 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 + CommandPreviewStyle lipgloss.Style + + // Status + StatusMessageStyle 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 + + // Container accordion + GroupHeaderStyle lipgloss.Style + GroupChildStyle lipgloss.Style + + // Legacy (referenced by view.go / update.go) + ModalStyle lipgloss.Style +} + +// DefaultTheme returns a new Theme using Catppuccin Mocha palette. +func DefaultTheme() Theme { + 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)) + t.CommandPreviewStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Italic(true) + + // 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)) + + // Container accordion + t.GroupHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMauve)). + Bold(true) + t.GroupChildStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtext)) + + return t +} + +var ( + styleStatusRunning = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)) + styleStatusStopped = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtext)) + styleStatusRestarting = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)) + styleStatusPaused = lipgloss.NewStyle().Foreground(lipgloss.Color(colorBlue)) + styleStatusDead = lipgloss.NewStyle().Foreground(lipgloss.Color(colorRed)) + styleStatusDim = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)) +) + +func normalizeStatus(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + switch s { + case "up": + return "running" + case "stopped": + return "exited" + } + return s +} + +// FormatStatus returns a lipgloss-styled icon + state string for a container state. +func FormatStatus(state string) string { + state = normalizeStatus(state) + switch state { + case "running": + return styleStatusRunning.Render("โ— " + state) + case "restarting": + return styleStatusRestarting.Render("โ— " + state) + case "paused": + return styleStatusPaused.Render("โ— " + state) + case "dead": + return styleStatusDead.Render("โ— " + state) + case "exited": + return styleStatusStopped.Render("โ—‹ " + state) + case "created": + return styleStatusDim.Render("โ—‹ " + state) + default: + return styleStatusDim.Render("โ— " + state) + } +} + +// 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 delegates to FormatStatus for backward compatibility. +func StatusColor(state string) string { return FormatStatus(state) } + +// GroupStatusColor returns a lipgloss-styled aggregate status for a compose group. +func GroupStatusColor(running, total int) string { + label := fmt.Sprintf("%d/%d", running, total) + switch { + case running == total: + return lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render("โ— " + label) + case running > 0: + return lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Render("โ—‘ " + label) + default: + return lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtext)).Render("โ—‹ " + label) + } +} + +// 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/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/theme.go b/internal/tui/theme.go deleted file mode 100644 index 5bd3fd3..0000000 --- a/internal/tui/theme.go +++ /dev/null @@ -1,26 +0,0 @@ -// Package tui provides the Terminal User Interface for Berth. -package tui - -import "github.com/charmbracelet/lipgloss" - -// Theme defines the color and styling for the application. -type Theme struct { - AppStyle lipgloss.Style - HeaderStyle lipgloss.Style - FooterStyle lipgloss.Style - StatusMessageStyle lipgloss.Style - TableSelectedStyle lipgloss.Style - TableHeaderStyle lipgloss.Style -} - -// DefaultTheme returns a new Theme with default styles. -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), - } -} \ No newline at end of file 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..ca4e66b --- /dev/null +++ b/internal/tui/types.go @@ -0,0 +1,61 @@ +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 + 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 + logChunkMsg string + logStreamDoneMsg struct{} + inspectMsg string + detailsMsg controller.ContainerDetails + containerStatsMsg map[string]controller.ContainerStat + statsTickMsg struct{} + refreshTickMsg struct{} + statusMsg string + errMsg struct{ err error } + + // composeOutputMsg carries one streamed line from an ongoing compose operation. + composeOutputMsg struct { + project string + line string + ch <-chan string + } + // composeDoneMsg signals a compose operation completed (with or without error). + composeDoneMsg struct { + project string + 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..bb92a74 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,260 +1,85 @@ -// Package tui provides the Terminal User Interface for Berth. package tui import ( - "bytes" - "encoding/json" - "fmt" - - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/rluders/berth/internal/controller" + "charm.land/bubbles/v2/progress" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" ) -// Update handles incoming messages and updates the model accordingly. +// Update dispatches incoming messages to the appropriate handler. 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) + return m, 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 - } + case tea.KeyPressMsg: + return m.handleKeyMsg(msg) - 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) - } + case tea.MouseMsg: + return m.handleMouseMsg(msg) case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height + return m.handleWindowSizeMsg(msg) + + case containerListMsg: + return m.handleContainerListMsg(msg) + + case imageListMsg: + return m.handleImageListMsg(msg) + + case volumeListMsg: + return m.handleVolumeListMsg(msg) + + case networkListMsg: + return m.handleNetworkListMsg(msg) + + case systemInfoMsg: + return m.handleSystemInfoMsg(msg) + + case containerStatsMsg: + return m.handleContainerStatsMsg(msg) + + case statsTickMsg: + return m.handleStatsTickMsg() + + case refreshTickMsg: + return m.handleRefreshTickMsg() + + case inspectMsg: + return m.handleInspectMsg(msg) + + case detailsMsg: + return m.handleDetailsMsg(msg) + + case logChunkMsg: + return m.handleLogChunkMsg(msg) + + case logStreamDoneMsg: + return m.handleLogStreamDoneMsg() + + case progressMsg: + return m.handleProgressMsg(msg) + + case progressTickMsg: + return m.handleProgressTickMsg() + + case progress.FrameMsg: + return m.handleProgressFrameMsg(msg) - // Adjust table heights based on window size - tableHeight := msg.Height - 10 // Header, footer, status, and some padding - if tableHeight < 0 { - tableHeight = 0 - } - m.containerTable.SetHeight(tableHeight) - m.imageTable.SetHeight(tableHeight) - m.volumeTable.SetHeight(tableHeight) - m.networkTable.SetHeight(tableHeight) + case statusMsg: + return m.handleStatusMsg(msg) - // 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()) - } - 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 composeOutputMsg: + return m.handleComposeOutputMsg(msg) - case []controller.Container: - 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) - m.showSpinner = false - m.statusMessage = "" - case []controller.Image: - 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) - m.showSpinner = false - m.statusMessage = "" - case []controller.Volume: - 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) - m.showSpinner = false - m.statusMessage = "" - case []controller.Network: - rows := make([]table.Row, len(msg)) - for i, net := range msg { - rows[i] = table.Row{net.ID, net.Name, net.Driver, net.Scope} - } - m.networkTable.SetRows(rows) - m.showSpinner = false - m.statusMessage = "" - case controller.SystemInfo: - m.systemInfo = msg - m.showSpinner = false - m.statusMessage = "" - case string: // For logs or inspect output - m.statusMessage = "" - m.showSpinner = false - if m.currentView == InspectView { + case composeDoneMsg: + return m.handleComposeDoneMsg(msg) - // 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()) - } - 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 - m.showSpinner = false - m.statusMessage = "" - case statusMsg: // For status messages after actions - m.statusMessage = string(msg) - m.showSpinner = false - cmds = append(cmds, tea.Batch(fetchContainersCmd(), fetchImagesCmd(), fetchVolumesCmd(), fetchNetworksCmd(), fetchSystemInfoCmd())) + case errMsg: + return m.handleErrMsg(msg) } - return m, tea.Batch(cmds...) + return m, nil } diff --git a/internal/tui/update_handlers.go b/internal/tui/update_handlers.go new file mode 100644 index 0000000..dcce8f8 --- /dev/null +++ b/internal/tui/update_handlers.go @@ -0,0 +1,371 @@ +package tui + +import ( + "fmt" + "log/slog" + "strings" + + "charm.land/bubbles/v2/progress" + "charm.land/bubbles/v2/table" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/rluders/berth/internal/controller" +) + +func (m Model) handleWindowSizeMsg(msg tea.WindowSizeMsg) (Model, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + + contentH := m.contentHeight() + m.syncTableSizes(msg.Width, contentH) + + viewW := msg.Width - currentTheme.AppStyle.GetHorizontalFrameSize() - 4 + if viewW < 0 { + viewW = 0 + } + m.inspectViewPort.SetWidth(viewW) + m.inspectViewPort.SetHeight(contentH) + m.logViewPort.SetWidth(viewW) + m.logViewPort.SetHeight(contentH) + m.detailsViewPort.SetWidth(viewW) + m.detailsViewPort.SetHeight(contentH) + + m.syncContainerViewport() + + 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 + } + + return m, nil +} + +func (m *Model) syncTableSizes(width, contentH int) { + if width < 0 { + width = 0 + } + + m.builtCols = BuildColumns(width, containerCols) + m.containerVP.SetWidth(width) + + // Table header renders 2 lines (text + BorderBottom). Always compute using + // ContainersView content height (which subtracts the command-preview footer line). + containerContentH := contentH + if m.currentView != ContainersView { + containerContentH-- // ContainersView subtracts extra line for command preview + } + m.containerVP.SetHeight(max(0, containerContentH-2)) // -2: table header text + border bottom + + m.imageTable.SetWidth(width) + m.imageTable.SetHeight(contentH) + m.imageTable.SetColumns(tableColumns(width, imageCols)) + + m.volumeTable.SetWidth(width) + m.volumeTable.SetHeight(contentH) + m.volumeTable.SetColumns(tableColumns(width, volumeCols)) + + m.networkTable.SetWidth(width) + m.networkTable.SetHeight(contentH) + m.networkTable.SetColumns(tableColumns(width, networkCols)) +} + +func (m Model) handleContainerListMsg(msg containerListMsg) (Model, tea.Cmd) { + slog.Debug("containerListMsg", "count", len(msg)) + m.containers = []controller.Container(msg) + m.recomputeRows() + m.showSpinner = false + m.statusMessage = "" + return m, nil +} + +func (m Model) handleImageListMsg(msg imageListMsg) (Model, tea.Cmd) { + slog.Debug("imageListMsg", "count", len(msg)) + m.images = []controller.Image(msg) + m.imageTable.SetRows(m.buildImageRows()) + m.showSpinner = false + m.statusMessage = "" + return m, nil +} + +func (m Model) handleVolumeListMsg(msg volumeListMsg) (Model, tea.Cmd) { + slog.Debug("volumeListMsg", "count", len(msg)) + m.volumes = []controller.Volume(msg) + m.volumeTable.SetRows(m.buildVolumeRows()) + m.showSpinner = false + m.statusMessage = "" + return m, nil +} + +func (m Model) handleNetworkListMsg(msg networkListMsg) (Model, tea.Cmd) { + 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} + } + m.networkTable.SetRows(rows) + m.showSpinner = false + m.statusMessage = "" + return m, nil +} + +func (m Model) handleSystemInfoMsg(msg systemInfoMsg) (Model, tea.Cmd) { + m.systemInfo = controller.SystemInfo(msg) + m.showSpinner = false + m.statusMessage = "" + return m, nil +} + +func (m Model) handleContainerStatsMsg(msg containerStatsMsg) (Model, tea.Cmd) { + for id, stat := range msg { + m.containerStats[id] = stat + } + running := map[string]bool{} + for _, c := range m.containers { + if c.State == "running" { + running[c.ID] = true + } + } + for id := range m.containerStats { + if !running[id] { + delete(m.containerStats, id) + } + } + m.recomputeRows() + return m, nil +} + +func (m Model) handleStatsTickMsg() (Model, tea.Cmd) { + var ids []string + for _, c := range m.containers { + if strings.HasPrefix(c.Status, "Up") || c.Status == "running" { + ids = append(ids, c.ID) + } + } + var cmds []tea.Cmd + if len(ids) > 0 { + cmds = append(cmds, fetchStatsCmd(ids)) + } + cmds = append(cmds, statsTickCmd()) + return m, tea.Batch(cmds...) +} + +func (m Model) handleRefreshTickMsg() (Model, tea.Cmd) { + return m, tea.Batch(fetchContainersCmd(), refreshTickCmd()) +} + +func (m Model) handleInspectMsg(msg inspectMsg) (Model, tea.Cmd) { + m.showSpinner = false + m.statusMessage = "" + m.inspectRawContent = string(msg) + if m.width > 0 { + m.inspectViewPort.SetContent(prettyJSON(string(msg))) + m.inspectReady = true + } + return m, func() tea.Msg { + return tea.WindowSizeMsg{Width: m.width, Height: m.height} + } +} + +func (m Model) handleDetailsMsg(msg detailsMsg) (Model, tea.Cmd) { + m.showSpinner = false + m.statusMessage = "" + m.currentDetails = controller.ContainerDetails(msg) + if m.width > 0 { + m.detailsViewPort.SetContent(renderDetailsContent(m.currentDetails)) + m.detailsReady = true + } + return m, func() tea.Msg { + return tea.WindowSizeMsg{Width: m.width, Height: m.height} + } +} + +func (m Model) handleLogChunkMsg(msg logChunkMsg) (Model, tea.Cmd) { + 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 { + return m, waitForLogLineCmd(m.logCh) + } + return m, nil +} + +func (m Model) handleLogStreamDoneMsg() (Model, tea.Cmd) { + m.logCh = nil + m.logCancel = nil + return m, nil +} + +func (m Model) handleProgressMsg(msg progressMsg) (Model, tea.Cmd) { + if msg.done { + m.progressLabel = msg.label + m.progressDone = true + m.showSpinner = false + return m, tea.Batch( + m.progressBar.SetPercent(1.0), + func() tea.Msg { return statusMsg(msg.label) }, + ) + } + m.progressVisible = true + m.progressLabel = msg.label + return m, m.progressBar.SetPercent(msg.percent) +} + +func (m Model) handleProgressTickMsg() (Model, tea.Cmd) { + if !m.progressVisible || m.progressDone { + return m, nil + } + // Animate toward 0.85 while waiting for actual completion. + next := m.progressBar.Percent() + 0.04 + if next > 0.85 { + next = 0.85 + } + return m, tea.Batch(m.progressBar.SetPercent(next), progressTickCmd()) +} + +func (m Model) handleProgressFrameMsg(msg progress.FrameMsg) (Model, tea.Cmd) { + pb, cmd := m.progressBar.Update(msg) + m.progressBar = pb + return m, cmd +} + +func (m Model) handleStatusMsg(msg statusMsg) (Model, tea.Cmd) { + slog.Debug("statusMsg", "msg", string(msg)) + m.statusMessage = string(msg) + m.showSpinner = false + m.progressVisible = false + m.progressDone = false + return m, fetchAllCmd() +} + +func (m Model) handleComposeOutputMsg(msg composeOutputMsg) (Model, tea.Cmd) { + m.composeOutput = append(m.composeOutput, msg.line) + if len(m.composeOutput) > 200 { + m.composeOutput = m.composeOutput[1:] + } + m.statusMessage = msg.line + return m, readNextComposeLineCmd(msg.ch, msg.project) +} + +func (m Model) handleComposeDoneMsg(msg composeDoneMsg) (Model, tea.Cmd) { + m.showSpinner = false + m.composeCancel = nil + if msg.err != nil { + m.statusMessage = fmt.Sprintf("[%s] compose failed: %v", msg.project, msg.err) + } else { + m.statusMessage = fmt.Sprintf("[%s] compose done.", msg.project) + } + return m, fetchContainersCmd() +} + +func (m Model) handleErrMsg(msg errMsg) (Model, tea.Cmd) { + slog.Error("errMsg", "error", msg.err) + m.err = msg.err + m.showSpinner = false + m.statusMessage = "" + return m, nil +} + +// 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_handlers_test.go b/internal/tui/update_handlers_test.go new file mode 100644 index 0000000..52feed8 --- /dev/null +++ b/internal/tui/update_handlers_test.go @@ -0,0 +1,213 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + "charm.land/bubbles/v2/table" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/rluders/berth/internal/controller" + "github.com/stretchr/testify/assert" +) + +func TestHandleContainerListMsg_setsContainers(t *testing.T) { + m := InitialModel() + containers := []controller.Container{{ID: "abc123", Names: "test", State: "running"}} + + result, cmd := updateModel(t, m, containerListMsg(containers)) + + assert.Len(t, result.containers, 1) + assert.Equal(t, "abc123", result.containers[0].ID) + assert.False(t, result.showSpinner) + assert.Empty(t, result.statusMessage) + assert.Nil(t, cmd) +} + +func TestHandleContainerListMsg_empty(t *testing.T) { + m := InitialModel() + m.showSpinner = true + + result, _ := updateModel(t, m, containerListMsg(nil)) + + assert.Empty(t, result.containers) + assert.False(t, result.showSpinner) +} + +func TestHandleImageListMsg_setsImages(t *testing.T) { + m := InitialModel() + images := []controller.Image{{ID: "img1", Repository: "nginx", Tag: "latest"}} + + result, cmd := updateModel(t, m, imageListMsg(images)) + + assert.Len(t, result.images, 1) + assert.False(t, result.showSpinner) + assert.Nil(t, cmd) +} + +func TestHandleErrMsg_setsError(t *testing.T) { + m := InitialModel() + m.showSpinner = true + m.statusMessage = "doing stuff" + testErr := errors.New("something broke") + + result, cmd := updateModel(t, m, errMsg{err: testErr}) + + assert.Equal(t, testErr, result.err) + assert.False(t, result.showSpinner) + assert.Empty(t, result.statusMessage) + assert.Nil(t, cmd) +} + +func TestHandleComposeOutputMsg_appendsToBuffer(t *testing.T) { + m := InitialModel() + ch := make(chan string, 1) + close(ch) + + result, _ := updateModel(t, m, composeOutputMsg{project: "myapp", line: "Pulling image...", ch: ch}) + + assert.Len(t, result.composeOutput, 1) + assert.Equal(t, "Pulling image...", result.composeOutput[0]) + assert.Equal(t, "Pulling image...", result.statusMessage) +} + +func TestHandleComposeOutputMsg_rollingBuffer(t *testing.T) { + m := InitialModel() + m.composeOutput = make([]string, 200) + ch := make(chan string, 1) + close(ch) + + result, _ := updateModel(t, m, composeOutputMsg{project: "myapp", line: "new line", ch: ch}) + + assert.Len(t, result.composeOutput, 200, "buffer must not exceed 200 entries") + assert.Equal(t, "new line", result.composeOutput[199]) +} + +func TestHandleComposeDoneMsg_success(t *testing.T) { + m := InitialModel() + m.showSpinner = true + cancelCalled := false + m.composeCancel = func() { cancelCalled = true } + + result, cmd := updateModel(t, m, composeDoneMsg{project: "myapp"}) + + assert.False(t, result.showSpinner) + assert.Nil(t, result.composeCancel) + assert.Equal(t, "[myapp] compose done.", result.statusMessage) + assert.NotNil(t, cmd) + _ = cancelCalled +} + +func TestHandleComposeDoneMsg_withError(t *testing.T) { + m := InitialModel() + m.showSpinner = true + + result, _ := updateModel(t, m, composeDoneMsg{project: "myapp", err: errors.New("exit 1")}) + + assert.False(t, result.showSpinner) + assert.Contains(t, result.statusMessage, "[myapp] compose failed") + assert.Contains(t, result.statusMessage, "exit 1") +} + +func TestHandleWindowSizeMsg_setsWidthHeight(t *testing.T) { + m := InitialModel() + + result, cmd := updateModel(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}) + + assert.Equal(t, 120, result.width) + assert.Equal(t, 40, result.height) + assert.Nil(t, cmd) +} + +func TestHandleWindowSizeMsg_setsListTableWidths(t *testing.T) { + m := InitialModel() + + result, _ := updateModel(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}) + + assert.Equal(t, 120, result.imageTable.Width()) + assert.Equal(t, 120, result.volumeTable.Width()) + assert.Equal(t, 120, result.networkTable.Width()) +} + +func TestHandleWindowSizeMsg_setsBubblesTableHeaderWidths(t *testing.T) { + m := InitialModel() + + result, _ := updateModel(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}) + + for name, view := range map[string]string{ + "images": result.imageTable.View(), + "volumes": result.volumeTable.View(), + "networks": result.networkTable.View(), + } { + t.Run(name, func(t *testing.T) { + lines := strings.Split(view, "\n") + + assert.GreaterOrEqual(t, len(lines), 2) + assert.Equal(t, 120, lipgloss.Width(lines[0])) + assert.Equal(t, 120, lipgloss.Width(lines[1])) + }) + } +} + +func TestHandleWindowSizeMsg_growsFlexibleColumns(t *testing.T) { + m := InitialModel() + + narrow, _ := updateModel(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}) + wide, _ := updateModel(t, narrow, tea.WindowSizeMsg{Width: 180, Height: 40}) + + assert.Greater(t, columnWidthByTitle(t, wide.imageTable.Columns(), "Repository"), columnWidthByTitle(t, narrow.imageTable.Columns(), "Repository")) + assert.Greater(t, columnWidthByTitle(t, wide.volumeTable.Columns(), "Mountpoint"), columnWidthByTitle(t, narrow.volumeTable.Columns(), "Mountpoint")) + assert.Greater(t, columnWidthByTitle(t, wide.networkTable.Columns(), "Name"), columnWidthByTitle(t, narrow.networkTable.Columns(), "Name")) +} + +func TestHandleWindowSizeMsg_narrowTablesKeepPositiveColumns(t *testing.T) { + m := InitialModel() + + result, _ := updateModel(t, m, tea.WindowSizeMsg{Width: 40, Height: 20}) + + for _, cols := range [][]table.Column{ + result.imageTable.Columns(), + result.volumeTable.Columns(), + result.networkTable.Columns(), + } { + for _, col := range cols { + assert.Positive(t, col.Width) + } + } +} + +func columnWidthByTitle(t *testing.T, cols []table.Column, title string) int { + t.Helper() + for _, col := range cols { + if col.Title == title { + return col.Width + } + } + t.Fatalf("column %q not found", title) + return 0 +} + +func TestHandleLogChunkMsg_appendsLine(t *testing.T) { + m := InitialModel() + + result, cmd := updateModel(t, m, logChunkMsg("2024-01-01 INFO started")) + + assert.Len(t, result.logLines, 1) + assert.Equal(t, "2024-01-01 INFO started", result.logLines[0]) + assert.Nil(t, cmd) +} + +func TestHandleLogStreamDoneMsg_clearsChannel(t *testing.T) { + m := InitialModel() + m.logCh = make(chan string) + cancelCalled := false + m.logCancel = func() { cancelCalled = true } + + result, cmd := updateModel(t, m, logStreamDoneMsg{}) + + assert.Nil(t, result.logCh) + assert.Nil(t, result.logCancel) + assert.Nil(t, cmd) + _ = cancelCalled +} diff --git a/internal/tui/update_keyboard.go b/internal/tui/update_keyboard.go new file mode 100644 index 0000000..099d988 --- /dev/null +++ b/internal/tui/update_keyboard.go @@ -0,0 +1,572 @@ +package tui + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +var mainTabs = []ViewType{ContainersView, ImagesView, VolumesView, NetworksView, SystemView} + +// handleKeyMsg dispatches keyboard events to the appropriate handler. +func (m Model) handleKeyMsg(msg tea.KeyPressMsg) (Model, tea.Cmd) { + slog.Debug("handleKeyMsg", "key", msg.String()) + + // Quick menu intercepts all keys when open. + if m.quickMenu != nil { + return m.handleQuickMenuKey(msg) + } + + // 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 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 + m.currentLogGroupName = "" + return m, nil + } + return m, tea.Quit + case key.Matches(msg, Keys.Global.Tab1): + m.leaveLogView() + m.currentView = ContainersView + return m, nil + case key.Matches(msg, Keys.Global.Tab2): + m.leaveLogView() + m.currentView = ImagesView + return m, nil + case key.Matches(msg, Keys.Global.Tab3): + m.leaveLogView() + m.currentView = VolumesView + return m, nil + case key.Matches(msg, Keys.Global.Tab4): + m.leaveLogView() + m.currentView = NetworksView + return m, nil + case key.Matches(msg, Keys.Global.Tab5): + m.leaveLogView() + m.currentView = SystemView + return m, nil + case key.Matches(msg, Keys.Global.TabNext): + m.leaveLogView() + return m.cycleTab(+1), nil + case key.Matches(msg, Keys.Global.TabPrev): + m.leaveLogView() + return m.cycleTab(-1), 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) + case DetailsView: + return m.handleDetailsKey(msg) + } + + return m, nil +} + +func (m Model) handleFilterKey(msg tea.KeyPressMsg) (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.recomputeRows() + case ImagesView: + m.imageTable.SetRows(m.buildImageRows()) + case VolumesView: + m.volumeTable.SetRows(m.buildVolumeRows()) + } +} + +func (m Model) handleContainersKey(msg tea.KeyPressMsg) (Model, tea.Cmd) { + var cmds []tea.Cmd + + // Movement keys โ€” handled manually since we no longer use bubbles/table. + switch msg.String() { + case "up", "k": + m.moveContainerCursor(-1) + m.lastActionKey = "" + return m, nil + case "down", "j": + m.moveContainerCursor(+1) + m.lastActionKey = "" + return m, nil + case "home": + if len(m.rows) > 0 { + m.containerCursor = 0 + m.syncContainerViewport() + } + m.lastActionKey = "" + return m, nil + case "G", "end": + if len(m.rows) > 0 { + m.containerCursor = len(m.rows) - 1 + m.syncContainerViewport() + } + m.lastActionKey = "" + return m, nil + case "pgup", "ctrl+b": + step := max(1, m.containerVP.Height()) + m.moveContainerCursor(-step) + m.lastActionKey = "" + return m, nil + case "pgdown", "ctrl+f": + step := max(1, m.containerVP.Height()) + m.moveContainerCursor(+step) + m.lastActionKey = "" + return m, nil + } + + // Track last action key for command preview; movement keys reset to default. + switch msg.String() { + case "s", "x", "r", "d", "l", "i", "e", "u", "U", "R", "p", "b": + m.lastActionKey = msg.String() + default: + m.lastActionKey = "" + } + + if key.Matches(msg, Keys.Container.Filter) { + m.filterActive = true + m.filterInput.Focus() + return m, tea.Batch(cmds...) + } + + if len(m.rows) == 0 { + return m, tea.Batch(cmds...) + } + + idx := m.containerCursor + if idx < 0 || idx >= len(m.rows) { + return m, tea.Batch(cmds...) + } + row := m.rows[idx] + + switch row.Type { + case RowTypeGroup: + switch { + case key.Matches(msg, Keys.Container.Details): + m.collapsedGroups[row.GroupID] = !m.collapsedGroups[row.GroupID] + m.recomputeRows() + saveState(persistedState{CollapsedGroups: m.collapsedGroups}) + case key.Matches(msg, Keys.Container.Expand): + delete(m.collapsedGroups, row.GroupID) + m.recomputeRows() + saveState(persistedState{CollapsedGroups: m.collapsedGroups}) + case key.Matches(msg, Keys.Container.Collapse): + m.collapsedGroups[row.GroupID] = true + m.recomputeRows() + saveState(persistedState{CollapsedGroups: m.collapsedGroups}) + case key.Matches(msg, Keys.Container.Start): + m.statusMessage = fmt.Sprintf("docker start [%s]", row.GroupID) + m.showSpinner = true + cmds = append(cmds, startGroupContainersCmd(row.Containers), m.spinner.Tick) + case key.Matches(msg, Keys.Container.Stop): + m.statusMessage = fmt.Sprintf("docker stop [%s]", row.GroupID) + m.showSpinner = true + cmds = append(cmds, stopGroupContainersCmd(row.Containers), m.spinner.Tick) + case key.Matches(msg, Keys.Container.Restart): + m.statusMessage = fmt.Sprintf("docker restart [%s]", row.GroupID) + m.showSpinner = true + cmds = append(cmds, restartGroupContainersCmd(row.Containers), m.spinner.Tick) + case key.Matches(msg, Keys.Container.Delete): + var removeCmds []tea.Cmd + for _, c := range row.Containers { + removeCmds = append(removeCmds, removeContainerCmd(c.ID)) + } + removeCmds = append(removeCmds, m.spinner.Tick) + m.modal = NewConfirmModal( + "Delete Group", + fmt.Sprintf("Delete all containers in %s?\nThis action cannot be undone.", row.GroupID), + tea.Batch(removeCmds...), + ) + m.showSpinner = false + case key.Matches(msg, Keys.Container.Logs): + m.stopLogStream() + m.logLines = nil + m.logFollowing = true + m.currentLogGroupName = row.GroupID + m.currentLogContainerID = "" + group := findGroupContainers(m.containers, row.GroupID) + m.pushView(LogsView) + m.logReady = true + ch, cancel, waitCmd := startGroupLogStreamCmd(group) + m.logCh = ch + m.logCancel = cancel + cmds = append(cmds, waitCmd) + case key.Matches(msg, Keys.Container.QuickActions): + m.statusMessage = "Group: use s/x/r/d to start/stop/restart/delete all containers" + default: + workDir := m.composeWorkDir(row.GroupID) + return m.dispatchComposeAction(msg, row.GroupID, workDir, cmds) + } + + case RowTypeContainer: + if key.Matches(msg, Keys.Container.Collapse) && row.GroupID != "" { + m.collapsedGroups[row.GroupID] = true + m.recomputeRows() + return m, tea.Batch(cmds...) + } + if key.Matches(msg, Keys.Container.QuickActions) { + m.quickMenu = NewContainerQuickMenu(row.Container.ID, row.Container.Names) + return m, nil + } + return m.dispatchContainerAction(msg, row.Container.ID, row.Container.Names, cmds) + } + + return m, tea.Batch(cmds...) +} + +// dispatchContainerAction handles action keys (Details, Start, Stop, etc.) for a resolved container. +func (m Model) dispatchContainerAction(msg tea.KeyPressMsg, id, name string, cmds []tea.Cmd) (Model, tea.Cmd) { + switch { + case key.Matches(msg, Keys.Container.Details): + m.pushView(DetailsView) + m.currentDetailsID = id + m.detailsReady = false + m.statusMessage = fmt.Sprintf("Loading details %s...", name) + 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 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 key.Matches(msg, Keys.Container.Restart): + m.statusMessage = fmt.Sprintf("docker restart %s", name) + m.showSpinner = true + 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.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 = 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...) +} + +// composeWorkDir returns the working directory for a compose project by checking container labels. +func (m Model) composeWorkDir(project string) string { + for _, c := range m.containers { + if c.Labels["com.docker.compose.project"] == project { + if wd := c.Labels["com.docker.compose.project.working_dir"]; wd != "" { + return wd + } + } + } + return "" +} + +// startComposeOp cancels any running compose op, creates a fresh context, and returns the cmd. +func (m *Model) startComposeOp(project, workDir string, fn func(context.Context, string, string) tea.Cmd) tea.Cmd { + if m.composeCancel != nil { + m.composeCancel() + } + ctx, cancel := context.WithCancel(context.Background()) + m.composeCancel = cancel + m.showSpinner = true + m.composeOutput = nil + return fn(ctx, project, workDir) +} + +// dispatchComposeAction handles compose project-level action keys when a group row is selected. +func (m Model) dispatchComposeAction(msg tea.KeyPressMsg, project, workDir string, cmds []tea.Cmd) (Model, tea.Cmd) { + switch { + case key.Matches(msg, Keys.Compose.Up): + m.statusMessage = fmt.Sprintf("docker compose up -d [%s]", project) + cmds = append(cmds, m.startComposeOp(project, workDir, composeUpCmd), m.spinner.Tick) + case key.Matches(msg, Keys.Compose.UpBuild): + m.statusMessage = fmt.Sprintf("docker compose up -d --build [%s]", project) + cmds = append(cmds, m.startComposeOp(project, workDir, composeUpBuildCmd), m.spinner.Tick) + case key.Matches(msg, Keys.Compose.Recreate): + ctx, cancel := context.WithCancel(context.Background()) + if m.composeCancel != nil { + m.composeCancel() + } + m.composeCancel = cancel + m.composeOutput = nil + m.modal = NewConfirmModal( + "Force Recreate", + fmt.Sprintf("docker compose up -d --force-recreate\nProject: %s", project), + tea.Batch(composeRecreateCmd(ctx, project, workDir), m.spinner.Tick), + ) + case key.Matches(msg, Keys.Compose.Down): + ctx, cancel := context.WithCancel(context.Background()) + if m.composeCancel != nil { + m.composeCancel() + } + m.composeCancel = cancel + m.composeOutput = nil + m.modal = NewConfirmModal( + "Compose Down", + fmt.Sprintf("docker compose down\nProject: %s", project), + tea.Batch(composeDownCmd(ctx, project, workDir), m.spinner.Tick), + ) + case key.Matches(msg, Keys.Compose.Pull): + m.statusMessage = fmt.Sprintf("docker compose pull [%s]", project) + cmds = append(cmds, m.startComposeOp(project, workDir, composePullCmd), m.spinner.Tick) + case key.Matches(msg, Keys.Compose.Build): + m.statusMessage = fmt.Sprintf("docker compose build [%s]", project) + cmds = append(cmds, m.startComposeOp(project, workDir, composeBuildCmd), m.spinner.Tick) + } + return m, tea.Batch(cmds...) +} + +func (m Model) handleImagesKey(msg tea.KeyPressMsg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + m.imageTable, cmd = m.imageTable.Update(msg) + cmds = append(cmds, cmd) + + 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...) +} + +func (m Model) handleVolumesKey(msg tea.KeyPressMsg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + m.volumeTable, cmd = m.volumeTable.Update(msg) + cmds = append(cmds, cmd) + + 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...) +} + +func (m Model) handleNetworksKey(msg tea.KeyPressMsg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + m.networkTable, cmd = m.networkTable.Update(msg) + cmds = append(cmds, cmd) + + 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 = fmt.Sprintf("docker network inspect %s", id) + m.showSpinner = true + cmds = append(cmds, inspectNetworkCmd(id), m.spinner.Tick) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) handleSystemKey(msg tea.KeyPressMsg) (Model, tea.Cmd) { + 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 +} + +func (m Model) handleInspectKey(msg tea.KeyPressMsg) (Model, tea.Cmd) { + var cmd tea.Cmd + m.inspectViewPort, cmd = m.inspectViewPort.Update(msg) + return m, cmd +} + +func (m Model) handleLogsKey(msg tea.KeyPressMsg) (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.KeyPressMsg) (Model, tea.Cmd) { + var cmd tea.Cmd + m.detailsViewPort, cmd = m.detailsViewPort.Update(msg) + return m, cmd +} + +func (m Model) cycleTab(delta int) Model { + for i, v := range mainTabs { + if v == m.currentView { + m.currentView = mainTabs[(i+delta+len(mainTabs))%len(mainTabs)] + return m + } + } + return m +} + +// leaveLogView stops the log stream and resets log state when navigating away via tab switch. +func (m *Model) leaveLogView() { + if m.currentView != LogsView { + return + } + m.stopLogStream() + m.logReady = false + m.currentLogGroupName = "" +} + +// 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 + 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/update_mouse.go b/internal/tui/update_mouse.go new file mode 100644 index 0000000..90aa128 --- /dev/null +++ b/internal/tui/update_mouse.go @@ -0,0 +1,188 @@ +package tui + +import ( + "fmt" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// 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.Mouse().Button { + case tea.MouseWheelUp: + return m.handleScrollUp() + case tea.MouseWheelDown: + return m.handleScrollDown() + case tea.MouseLeft: + return m.handleLeftClick(msg) + } + + return m, nil +} + +func (m Model) handleScrollUp() (Model, tea.Cmd) { + switch m.currentView { + case ContainersView: + m.containerVP.ScrollUp(3) + return m, nil + case ImagesView: + var cmd tea.Cmd + m.imageTable, cmd = m.imageTable.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + return m, cmd + case VolumesView: + var cmd tea.Cmd + m.volumeTable, cmd = m.volumeTable.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + return m, cmd + case NetworksView: + var cmd tea.Cmd + m.networkTable, cmd = m.networkTable.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + return m, cmd + case InspectView: + m.inspectViewPort.ScrollUp(3) + case LogsView: + m.logFollowing = false + m.logViewPort.ScrollUp(3) + case DetailsView: + m.detailsViewPort.ScrollUp(3) + } + return m, nil +} + +func (m Model) handleScrollDown() (Model, tea.Cmd) { + switch m.currentView { + case ContainersView: + m.containerVP.ScrollDown(3) + return m, nil + case ImagesView: + var cmd tea.Cmd + m.imageTable, cmd = m.imageTable.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + return m, cmd + case VolumesView: + var cmd tea.Cmd + m.volumeTable, cmd = m.volumeTable.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + return m, cmd + case NetworksView: + var cmd tea.Cmd + m.networkTable, cmd = m.networkTable.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + return m, cmd + case InspectView: + m.inspectViewPort.ScrollDown(3) + case LogsView: + m.logViewPort.ScrollDown(3) + case DetailsView: + m.detailsViewPort.ScrollDown(3) + } + return m, nil +} + +func (m Model) handleLeftClick(msg tea.MouseMsg) (Model, tea.Cmd) { + mouse := msg.Mouse() + // 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 mouse.Y >= headerH && mouse.Y < headerH+tabBarH { + return m.handleTabClick(mouse.X) + } + + // Click in content area: handle table row selection. + contentStartY := headerH + tabBarH + if mouse.Y >= contentStartY { + return m.handleTableClick(mouse.Y-contentStartY, mouse.X) + } + + return m, nil +} + +// handleTabClick maps an X coordinate to a tab and switches views. +// Replicates renderTabBar() label+style logic to get accurate widths via lipgloss.Width(). +func (m Model) handleTabClick(x int) (Model, tea.Cmd) { + th := currentTheme + tabs := []struct { + label string + count int + view ViewType + }{ + {"Containers", len(m.containers), ContainersView}, + {"Images", len(m.images), ImagesView}, + {"Volumes", len(m.volumes), VolumesView}, + {"Networks", 0, NetworksView}, + {"System", 0, SystemView}, + } + + cursor := 0 + for _, tab := range tabs { + label := tab.label + if tab.count > 0 { + label += " " + lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorOverlay)). + Render(fmt.Sprintf("%d", tab.count)) + } + var rendered string + if m.currentView == tab.view { + rendered = th.ActiveTabStyle.Render(label) + } else { + rendered = th.InactiveTabStyle.Render(label) + } + w := lipgloss.Width(rendered) + if x >= cursor && x < cursor+w { + m.currentView = tab.view + return m, nil + } + cursor += w + } + + return m, nil +} + +// handleTableClick maps a Y offset (relative to table start) to a row selection. +func (m Model) handleTableClick(relY, _ int) (Model, tea.Cmd) { + // Row 0 = table header; data rows start at relY == 1. + if relY <= 0 { + return m, nil + } + rowIndex := relY - 1 // 0-based data row index + + switch m.currentView { + case ContainersView: + // relY==0 is the header line; data rows start at relY==1. + dataIdx := rowIndex + m.containerVP.YOffset() + if dataIdx >= 0 && dataIdx < len(m.rows) { + m.containerCursor = dataIdx + m.syncContainerViewport() + } + case ImagesView: + rows := m.imageTable.Rows() + if rowIndex < len(rows) { + m.imageTable.GotoTop() + for i := 0; i < rowIndex; i++ { + m.imageTable, _ = m.imageTable.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + } + } + case VolumesView: + rows := m.volumeTable.Rows() + if rowIndex < len(rows) { + m.volumeTable.GotoTop() + for i := 0; i < rowIndex; i++ { + m.volumeTable, _ = m.volumeTable.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + } + } + case NetworksView: + rows := m.networkTable.Rows() + if rowIndex < len(rows) { + m.networkTable.GotoTop() + for i := 0; i < rowIndex; i++ { + m.networkTable, _ = m.networkTable.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + } + } + } + + return m, nil +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go new file mode 100644 index 0000000..e341da0 --- /dev/null +++ b/internal/tui/update_test.go @@ -0,0 +1,27 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// updateModel is a test helper that calls Update and returns the concrete Model. +func updateModel(t *testing.T, m Model, msg tea.Msg) (Model, tea.Cmd) { + t.Helper() + newM, cmd := m.Update(msg) + result, ok := newM.(Model) + require.True(t, ok, "Update must return Model") + return result, cmd +} + +func TestUpdate_unknownMsgPreservesModel(t *testing.T) { + m := InitialModel() + + result, cmd := updateModel(t, m, struct{}{}) + + assert.Equal(t, m.currentView, result.currentView) + assert.Nil(t, cmd) +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 4bfa622..b6d4945 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -3,55 +3,459 @@ package tui import ( "fmt" - "github.com/charmbracelet/lipgloss" "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) +// colorizeLogLine applies color coding based on log level keywords. +func colorizeLogLine(line string) string { + th := currentTheme + lower := strings.ToLower(line) + + // Detect timestamp prefix (common formats: "2024-01-01" or "[12:34:56]") + // and style it differently. + switch { + case strings.Contains(lower, "error") || strings.Contains(lower, "fatal") || strings.Contains(lower, "panic") || strings.Contains(lower, "critical"): + return th.LogErrorStyle.Render(line) + case strings.Contains(lower, "warn") || strings.Contains(lower, "warning"): + return th.LogWarnStyle.Render(line) + case strings.Contains(lower, "info") || strings.Contains(lower, "notice"): + return th.LogInfoStyle.Render(line) + case strings.Contains(lower, "debug") || strings.Contains(lower, "trace"): + return th.LogDebugStyle.Render(line) + default: + return line + } +} + +// buildColorizedLogContent formats all log lines with colors and optional line numbers. +func buildColorizedLogContent(lines []string, showLineNumbers bool) string { + if len(lines) == 0 { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Render(" Waiting for log output...") + } + + th := currentTheme + var sb strings.Builder + for i, line := range lines { + if showLineNumbers { + num := th.LogLineNumStyle.Render(fmt.Sprintf("%4d โ”‚ ", i+1)) + sb.WriteString(num + colorizeLogLine(line) + "\n") + } else { + sb.WriteString(colorizeLogLine(line) + "\n") + } + } + return sb.String() +} + // View renders the main TUI view. -func (m Model) View() string { +func (m Model) View() tea.View { + var content string if m.err != nil { - return fmt.Sprintf("Error: %v", m.err) + content = currentTheme.ModalBoxStyle.Render( + currentTheme.LogErrorStyle.Render("Error: "+m.err.Error()) + + "\n\nPress q to quit.", + ) + } else { + header := m.renderHeader() + tabBar := m.renderTabBar() + + if m.showHelp { + content = lipgloss.JoinVertical(lipgloss.Top, header, tabBar, m.renderHelp()) + } else { + body := m.renderContent() + if m.quickMenu != nil { + body = m.renderQuickMenu(body) + } else if m.modal != nil { + body = m.renderModal(body) + } + footer := m.renderFooter() + content = lipgloss.JoinVertical( + lipgloss.Top, + header, + tabBar, + lipgloss.NewStyle(). + Width(m.width). + Height(m.contentHeight()). + Render(body), + footer, + ) + } + } + + v := tea.NewView(content) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v +} + +// renderHeader renders the top bar with logo, view name, and engine badge. +func (m Model) renderHeader() string { + th := currentTheme + + logo := th.HeaderLogoStyle.Render("berth") + + viewName := "" + switch m.currentView { + case InspectView: + viewName = " โ€บ inspect " + m.currentInspectID + case LogsView: + viewName = " โ€บ logs " + m.currentLogContainerID + case DetailsView: + viewName = " โ€บ details " + m.currentDetailsID + } + + left := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)). + Render(logo + viewName) + + eng := strings.ToUpper(string(m.engineType)) + right := th.HeaderEngStyle.Render("โฌก " + eng) + + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) - 4 + if gap < 1 { + gap = 1 + } + spacer := strings.Repeat(" ", gap) + + return th.HeaderStyle.Width(m.width).Render(left + spacer + right) +} + +// renderTabBar renders the tab navigation row. +func (m Model) renderTabBar() string { + th := currentTheme + + type tabDef struct { + label string + view ViewType + count int + } + + tabs := []tabDef{ + {"Containers", ContainersView, len(m.containers)}, + {"Images", ImagesView, len(m.images)}, + {"Volumes", VolumesView, len(m.volumes)}, + {"Networks", NetworksView, 0}, + {"System", SystemView, 0}, + } + + var rendered []string + for _, tab := range tabs { + label := tab.label + if tab.count > 0 { + label += " " + lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorOverlay)). + Render(fmt.Sprintf("%d", tab.count)) + } + if m.currentView == tab.view { + rendered = append(rendered, th.ActiveTabStyle.Render(label)) + } else { + rendered = append(rendered, th.InactiveTabStyle.Render(label)) + } } - header := currentTheme.HeaderStyle.Render(fmt.Sprintf("Berth - %s - %s Engine", m.getViewName(), strings.ToUpper(string(m.engineType)))) + bar := lipgloss.JoinHorizontal(lipgloss.Top, rendered...) + // Fill remaining width with tab bar background. + barW := lipgloss.Width(bar) + if m.width > barW { + fill := th.TabBarStyle.Width(m.width - barW).Render("") + bar = lipgloss.JoinHorizontal(lipgloss.Top, bar, fill) + } + return bar +} - content := "" +// renderContent returns the main body for the current view. +func (m Model) renderContent() string { switch m.currentView { case ContainersView: - content = m.containerTable.View() + return m.renderContainerHeader() + "\n" + m.containerVP.View() case ImagesView: - content = m.imageTable.View() + return m.imageTable.View() case VolumesView: - content = m.volumeTable.View() + return m.volumeTable.View() case NetworksView: - content = m.networkTable.View() + return m.networkTable.View() case SystemView: - content = fmt.Sprintf(" Containers: %d (Running: %d, Paused: %d, Stopped: %d)\n", m.systemInfo.Containers, m.systemInfo.Running, m.systemInfo.Paused, m.systemInfo.Stopped) + - fmt.Sprintf(" Images: %d\n", m.systemInfo.Images) + - fmt.Sprintf(" Volumes: %d\n", m.systemInfo.Volumes) + - fmt.Sprintf(" Networks: %d\n", m.systemInfo.Networks) + - fmt.Sprintf(" Disk Usage: %s\n", m.systemInfo.DiskUsage) + return m.renderSystem() case InspectView: - content = m.inspectViewPort.View() + return m.inspectViewPort.View() case LogsView: - content = m.logViewPort.View() + return m.renderLogsView() + case DetailsView: + return m.detailsViewPort.View() + } + return "" +} + +// renderLogsView renders the log viewport with a follow/pause indicator bar. +func (m Model) renderLogsView() string { + th := currentTheme + + title := m.currentLogContainerID + if m.currentLogGroupName != "" { + title = m.currentLogGroupName + } + titleBar := lipgloss.NewStyle(). + Padding(0, 1). + Bold(true). + Render("Logs: " + title) + + var badge string + if m.logFollowing { + badge = th.LogFollowStyle.Render("โ–ถ LIVE") + } else { + badge = th.LogPausedStyle.Render("โธ PAUSED") + } + + numBadge := "" + if m.showLineNumbers { + numBadge = " " + lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Render("[line numbers on]") + } + + indicator := lipgloss.NewStyle(). + Padding(0, 1). + Render(badge + numBadge) + + return lipgloss.JoinVertical(lipgloss.Left, titleBar, indicator, m.logViewPort.View()) +} + +// renderSystem renders a dashboard-style system info view. +func (m Model) renderSystem() string { + th := currentTheme + si := m.systemInfo + + stat := func(label string, val int, style lipgloss.Style) string { + return " " + th.CardTitleStyle.Render(fmt.Sprintf("%-12s", label)) + + style.Render(fmt.Sprintf("%d", val)) } - footerContent := m.getFooterHelp() - if m.statusMessage != "" { + // โ”€โ”€ Containers card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + containerCard := th.CardStyle.Render( + th.SectionStyle.Render("โ–ธ Containers") + "\n" + + stat("Running", si.Running, th.BadgeRunningStyle) + "\n" + + stat("Paused", si.Paused, th.BadgePausedStyle) + "\n" + + stat("Stopped", si.Stopped, th.BadgeStoppedStyle) + "\n" + + " " + th.CardTitleStyle.Render(fmt.Sprintf("%-12s", "Total")) + + th.CardValueStyle.Render(fmt.Sprintf("%d", si.Containers)), + ) + + // โ”€โ”€ Resources card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + resourceCard := th.CardStyle.Render( + th.SectionStyle.Render("โ–ธ Resources") + "\n" + + " " + th.CardTitleStyle.Render(fmt.Sprintf("%-12s", "Images")) + + th.CardValueStyle.Render(fmt.Sprintf("%d", si.Images)) + "\n" + + " " + th.CardTitleStyle.Render(fmt.Sprintf("%-12s", "Volumes")) + + th.CardValueStyle.Render(fmt.Sprintf("%d", si.Volumes)) + "\n" + + " " + th.CardTitleStyle.Render(fmt.Sprintf("%-12s", "Networks")) + + th.CardValueStyle.Render(fmt.Sprintf("%d", si.Networks)) + "\n" + + " " + th.CardTitleStyle.Render(fmt.Sprintf("%-12s", "Disk Usage")) + + th.CardValueStyle.Render(si.DiskUsage), + ) + + // โ”€โ”€ Cleanup actions card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + bBtn := th.ButtonSecondaryStyle.Render("b Basic cleanup") + aBtn := th.ButtonSecondaryStyle.Render("a Advanced cleanup") + tBtn := th.ButtonDangerStyle.Render("t Total cleanup") + actionsCard := th.CardStyle.Render( + th.SectionStyle.Render("โ–ธ Cleanup Actions") + "\n\n" + + " " + bBtn + "\n" + + " " + lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Render(" Prune stopped containers, unused networks, dangling images") + "\n\n" + + " " + aBtn + "\n" + + " " + lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Render(" + unused volumes") + "\n\n" + + " " + tBtn + "\n" + + " " + lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Render(" Remove ALL unused resources"), + ) + + return lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.JoinHorizontal(lipgloss.Top, containerCard, " ", resourceCard), + actionsCard, + ) +} + +// BuildCommandPreview returns the docker/compose command equivalent for the current selection. +func (m Model) BuildCommandPreview() string { + if m.currentView != ContainersView { + return "" + } + if len(m.rows) == 0 { + return " " + } + idx := m.containerCursor + if idx < 0 || idx >= len(m.rows) { + return " " + } + row := m.rows[idx] + + if row.Type == RowTypeGroup { + project := row.GroupID + switch m.lastActionKey { + case "U": + return fmt.Sprintf("docker compose -p %s up -d --build", project) + case "R": + return fmt.Sprintf("docker compose -p %s up -d --force-recreate", project) + case "d": + return fmt.Sprintf("docker compose -p %s down", project) + case "p": + return fmt.Sprintf("docker compose -p %s pull", project) + case "b": + return fmt.Sprintf("docker compose -p %s build", project) + default: + return fmt.Sprintf("docker compose -p %s up -d", project) + } + } + + name := row.Container.Names + switch m.lastActionKey { + case "s": + return fmt.Sprintf("docker start %s", name) + case "x": + return fmt.Sprintf("docker stop %s", name) + case "r": + return fmt.Sprintf("docker restart %s", name) + case "d": + return fmt.Sprintf("docker rm %s", name) + case "e": + return fmt.Sprintf("docker exec -it %s sh", name) + default: + return fmt.Sprintf("docker logs -f %s", name) + } +} + +// renderFooter builds the bottom bar with status, hints, and optional overlays. +func (m Model) renderFooter() string { + th := currentTheme + var parts []string + + // Command preview (always first, ContainersView only) + if preview := m.BuildCommandPreview(); preview != "" { + prefix := th.FooterKeyStyle.Render("$ ") + parts = append(parts, prefix+th.CommandPreviewStyle.Render(preview)) + } + + // Filter bar + if m.filterActive { + filterBar := th.FilterStyle.Render("/ " + m.filterInput.View()) + parts = append(parts, filterBar) + } + + // Progress bar + if m.progressVisible { + parts = append(parts, th.StatusMessageStyle.Render(m.progressLabel)) + parts = append(parts, " "+m.progressBar.ViewAs(m.progressBar.Percent())) + } + + // Status / spinner + if m.statusMessage != "" && !m.progressVisible { spinnerStr := "" if m.showSpinner { - spinnerStr = m.spinner.View() + " " + spinnerStr = th.SpinnerStyle.Render(m.spinner.View()) + " " } - footerContent = currentTheme.StatusMessageStyle.Render(spinnerStr+m.statusMessage) + "\n" + footerContent + parts = append(parts, th.StatusMessageStyle.Render(spinnerStr+m.statusMessage)) } - return currentTheme.AppStyle.Render( - lipgloss.JoinVertical( - lipgloss.Top, - header, - lipgloss.NewStyle().Height(m.height-lipgloss.Height(header)-currentTheme.FooterStyle.GetVerticalPadding()-currentTheme.HeaderStyle.GetVerticalPadding()-currentTheme.AppStyle.GetVerticalPadding()*2).Render(content), - currentTheme.FooterStyle.Render(footerContent), - ), - ) + // Key hints row + parts = append(parts, m.renderKeyHints()) + + return th.FooterStyle.Width(m.width).Render(strings.Join(parts, "\n")) +} + +// renderKeyHints builds a styled key-hint line for the current view. +func (m Model) renderKeyHints() string { + th := currentTheme + type hint struct{ k, d string } + + global := []hint{{"?", "help"}, {"q", "quit"}} + + var viewHints []hint + switch m.currentView { + case ContainersView: + idx := m.containerCursor + if idx >= 0 && idx < len(m.rows) && m.rows[idx].Type == RowTypeGroup { + viewHints = []hint{ + {"โ†‘/โ†“", "move"}, {"โ†’/โ†", "expand/collapse"}, + {"u", "up"}, {"U", "up+build"}, {"R", "recreate"}, + {"d", "down"}, {"p", "pull"}, {"b", "build"}, + {"/", "filter"}, + } + break + } + viewHints = []hint{ + {"space", "actions"}, {"โ†‘/โ†“", "move"}, {"enter", "details"}, {"l", "logs"}, + {"i", "inspect"}, {"s", "start"}, {"x", "stop"}, + {"r", "restart"}, {"d", "delete"}, {"e", "exec"}, {"/", "filter"}, + } + case ImagesView: + viewHints = []hint{{"d", "remove"}, {"P", "prune"}, {"/", "filter"}} + case VolumesView: + viewHints = []hint{{"d", "remove"}, {"/", "filter"}} + case NetworksView: + viewHints = []hint{{"i", "inspect"}} + case SystemView: + viewHints = []hint{{"b", "basic"}, {"a", "advanced"}, {"t", "total"}} + case LogsView: + viewHints = []hint{{"p", "pause"}, {"f", "follow"}, {"n", "line#"}, {"esc", "back"}} + global = nil + case InspectView, DetailsView: + viewHints = []hint{{"โ†‘/โ†“", "scroll"}, {"esc", "back"}} + global = nil + } + + var segments []string + for _, h := range viewHints { + segments = append(segments, FooterHint(h.k, h.d)) + } + if len(global) > 0 && len(viewHints) > 0 { + segments = append(segments, th.FooterDescStyle.Render(" โ€ข ")) + } + for _, h := range global { + segments = append(segments, FooterHint(h.k, h.d)) + } + + return strings.Join(segments, th.FooterDescStyle.Render(" ")) +} + +// renderHelp renders a full-screen help overlay using bubbles/help. +func (m Model) renderHelp() string { + th := currentTheme + + title := th.ModalTitleStyle.Render("Berth โ€” Keyboard Reference (" + m.getViewName() + ")") + + // Use full-help mode (show all bindings). + hm := m.helpModel + hm.ShowAll = true + helpContent := hm.View(m.currentKeyMap()) + + hint := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + MarginTop(1). + Render("Press any key to close.") + + inner := lipgloss.JoinVertical(lipgloss.Left, title, "", helpContent, hint) + + boxW := m.width - 8 + if boxW < 50 { + boxW = 50 + } + + box := th.ModalBoxStyle.Width(boxW).Render(inner) + + // Center horizontally. + boxW2 := lipgloss.Width(box) + leftPad := (m.width - boxW2) / 2 + if leftPad < 0 { + leftPad = 0 + } + return lipgloss.NewStyle().PaddingLeft(leftPad).Render(box) } 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)) - } -} diff --git a/internal/utils/time.go b/internal/utils/time.go new file mode 100644 index 0000000..2d2f78a --- /dev/null +++ b/internal/utils/time.go @@ -0,0 +1,40 @@ +package utils + +import ( + "fmt" + "time" +) + +// FormatAge converts a Unix timestamp to a human-readable age string (e.g. "2h", "3d"). +func FormatAge(unixTime int64) string { + d := time.Since(time.Unix(unixTime, 0)) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + +// FormatBytes formats a byte count as a human-readable string (e.g. "512KB", "1.2GB"). +func FormatBytes(b uint64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%dB", b) + } + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + val := float64(b) / float64(div) + uc := "KMGTPE"[exp] + if val == float64(uint64(val)) { + return fmt.Sprintf("%d%cB", uint64(val), uc) + } + return fmt.Sprintf("%.1f%cB", val, uc) +}