├── demo.gif ├── .gitignore ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── NOTICE ├── CONTRIBUTING.md ├── internal ├── helm │ ├── cache.go │ └── client.go ├── ui │ ├── yaml_highlighter.go │ └── yaml_utils.go └── artifacthub │ ├── types.go │ └── client.go ├── RELEASING.md ├── Makefile ├── .goreleaser.yaml ├── PUBLISHING.md ├── install.sh ├── go.mod ├── README.md ├── LICENSE ├── go.sum └── cmd └── lazyhelm └── main.go /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alessandropitocchi/lazyhelm/HEAD/demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries 2 | lazyhelm 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | 9 | # Test binary 10 | *.test 11 | 12 | # Output of the go coverage tool 13 | *.out 14 | 15 | # Go workspace file 16 | go.work 17 | 18 | # Distribution 19 | dist/ 20 | *.tar.gz 21 | *.zip 22 | 23 | # IDE 24 | .idea/ 25 | .vscode/ 26 | *.swp 27 | *.swo 28 | *~ 29 | 30 | # OS 31 | .DS_Store 32 | Thumbs.db 33 | 34 | # Temporary files 35 | tmp/ 36 | temp/ 37 | *.tmp 38 | 39 | # Build artifacts 40 | build/ 41 | bin/ 42 | 43 | # Goreleaser 44 | .goreleaser.yml.backup 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: '1.21' 21 | 22 | - name: Download dependencies 23 | run: go mod download 24 | 25 | - name: Run tests 26 | run: go test -v ./... 27 | 28 | - name: Build 29 | run: go build -v ./cmd/lazyhelm 30 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | LazyHelm 2 | Copyright 2025 Alessandro Pitocchi 3 | 4 | This product includes software developed by Alessandro Pitocchi. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: '1.21' 24 | 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v5 27 | with: 28 | distribution: goreleaser 29 | version: latest 30 | args: release --clean 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to LazyHelm 2 | 3 | Thanks for your interest in contributing! 4 | 5 | ## License Agreement 6 | 7 | By submitting a pull request, you represent that you have the right to license your contribution to Alessandro Pitocchi and the community, and agree that your contributions are licensed under the Apache 2.0 license. 8 | 9 | All contributions must be made under the Apache License 2.0. By contributing, you certify that: 10 | 11 | 1. The contribution was created in whole or in part by you 12 | 2. You have the right to submit it under the Apache 2.0 license 13 | 3. The contribution is submitted on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND 14 | 15 | ## Code Headers 16 | 17 | All new source files must include the Apache 2.0 license header: 18 | 19 | ```go 20 | // Copyright 2025 Alessandro Pitocchi 21 | // 22 | // Licensed under the Apache License, Version 2.0 (the "License"); 23 | // you may not use this file except in compliance with the License. 24 | // You may obtain a copy of the License at 25 | // 26 | // http://www.apache.org/licenses/LICENSE-2.0 27 | // 28 | // Unless required by applicable law or agreed to in writing, software 29 | // distributed under the License is distributed on an "AS IS" BASIS, 30 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 31 | // See the License for the specific language governing permissions and 32 | // limitations under the License. 33 | ``` 34 | 35 | ## Developer Certificate of Origin 36 | 37 | By making a contribution, you certify that you agree to the [Developer Certificate of Origin (DCO)](https://developercertificate.org/). 38 | 39 | ## How to Contribute 40 | 41 | 1. Fork the repo 42 | 2. Create a feature branch 43 | 3. Make your changes 44 | 4. Add tests if applicable 45 | 5. Run `go test ./...` 46 | 6. Submit a pull request 47 | 48 | ## Code Style 49 | 50 | - Follow standard Go conventions 51 | - Run `go fmt` before committing 52 | - Write clear commit messages 53 | -------------------------------------------------------------------------------- /internal/helm/cache.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Alessandro Pitocchi 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package helm 16 | 17 | import ( 18 | "fmt" 19 | "sync" 20 | "time" 21 | ) 22 | 23 | type CacheEntry struct { 24 | values string 25 | timestamp time.Time 26 | } 27 | 28 | type Cache struct { 29 | entries map[string]CacheEntry 30 | ttl time.Duration 31 | mu sync.RWMutex 32 | } 33 | 34 | func NewCache(ttl time.Duration) *Cache { 35 | return &Cache{ 36 | entries: make(map[string]CacheEntry), 37 | ttl: ttl, 38 | } 39 | } 40 | 41 | func (c *Cache) Get(chartName, version string) (string, bool) { 42 | c.mu.RLock() 43 | defer c.mu.RUnlock() 44 | 45 | key := c.buildKey(chartName, version) 46 | entry, exists := c.entries[key] 47 | 48 | if !exists { 49 | return "", false 50 | } 51 | 52 | if time.Since(entry.timestamp) > c.ttl { 53 | return "", false 54 | } 55 | 56 | return entry.values, true 57 | } 58 | 59 | func (c *Cache) Set(chartName, version, values string) { 60 | c.mu.Lock() 61 | defer c.mu.Unlock() 62 | 63 | key := c.buildKey(chartName, version) 64 | c.entries[key] = CacheEntry{ 65 | values: values, 66 | timestamp: time.Now(), 67 | } 68 | } 69 | 70 | func (c *Cache) Clear() { 71 | c.mu.Lock() 72 | defer c.mu.Unlock() 73 | 74 | c.entries = make(map[string]CacheEntry) 75 | } 76 | 77 | func (c *Cache) buildKey(chartName, version string) string { 78 | if version == "" { 79 | return chartName 80 | } 81 | return fmt.Sprintf("%s@%s", chartName, version) 82 | } 83 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | This document describes how to create a new release of LazyHelm. 4 | 5 | ## Prerequisites 6 | 7 | 1. You must have push access to the repository 8 | 2. All tests must pass 9 | 3. The `main` branch should be in a releasable state 10 | 11 | ## Creating a Release 12 | 13 | ### 1. Update Version 14 | 15 | Make sure your changes are committed and pushed to `main`. 16 | 17 | ### 2. Create and Push a Tag 18 | 19 | ```bash 20 | # Create a new tag (use semantic versioning) 21 | git tag -a v1.0.0 -m "Release v1.0.0" 22 | 23 | # Push the tag 24 | git push origin v1.0.0 25 | ``` 26 | 27 | ### 3. Automated Release 28 | 29 | Once the tag is pushed, GitHub Actions will automatically: 30 | - Build binaries for multiple platforms (Linux, macOS, Windows) 31 | - Create a GitHub release with the binaries 32 | - Generate a changelog 33 | - Update Homebrew tap (if configured) 34 | 35 | ### 4. Verify the Release 36 | 37 | 1. Go to https://github.com/alessandropitocchi/lazyhelm/releases 38 | 2. Verify the release was created 39 | 3. Check that all binaries are present 40 | 4. Test installation using the install script 41 | 42 | ## Version Numbering 43 | 44 | We follow [Semantic Versioning](https://semver.org/): 45 | 46 | - `MAJOR.MINOR.PATCH` 47 | - `MAJOR`: Breaking changes 48 | - `MINOR`: New features (backward compatible) 49 | - `PATCH`: Bug fixes (backward compatible) 50 | 51 | Examples: 52 | - `v1.0.0` - Initial stable release 53 | - `v1.1.0` - New features added 54 | - `v1.1.1` - Bug fixes 55 | - `v2.0.0` - Breaking changes 56 | 57 | ## Manual Release (if needed) 58 | 59 | If GitHub Actions fails, you can create a release manually: 60 | 61 | ```bash 62 | # Install goreleaser (if not already installed) 63 | brew install goreleaser 64 | 65 | # Create a snapshot release (test without publishing) 66 | goreleaser release --snapshot --clean 67 | 68 | # Create an actual release (requires tag) 69 | goreleaser release --clean 70 | ``` 71 | 72 | ## Post-Release 73 | 74 | 1. Announce the release (optional) 75 | 2. Update documentation if needed 76 | 3. Close related issues/PRs 77 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build install uninstall clean test run help 2 | 3 | BINARY_NAME=lazyhelm 4 | INSTALL_DIR=/usr/local/bin 5 | GO=go 6 | GOFLAGS=-v 7 | 8 | help: ## Show this help 9 | @echo "LazyHelm - Makefile commands:" 10 | @echo "" 11 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' 12 | 13 | build: ## Build the binary 14 | @echo "Building $(BINARY_NAME)..." 15 | $(GO) build $(GOFLAGS) -o $(BINARY_NAME) ./cmd/lazyhelm 16 | 17 | install: build ## Build and install to /usr/local/bin 18 | @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." 19 | @if [ -w "$(INSTALL_DIR)" ]; then \ 20 | mv $(BINARY_NAME) $(INSTALL_DIR)/; \ 21 | else \ 22 | sudo mv $(BINARY_NAME) $(INSTALL_DIR)/; \ 23 | fi 24 | @echo "✓ Installed successfully!" 25 | 26 | uninstall: ## Remove the binary from /usr/local/bin 27 | @echo "Uninstalling $(BINARY_NAME)..." 28 | @if [ -w "$(INSTALL_DIR)" ]; then \ 29 | rm -f $(INSTALL_DIR)/$(BINARY_NAME); \ 30 | else \ 31 | sudo rm -f $(INSTALL_DIR)/$(BINARY_NAME); \ 32 | fi 33 | @echo "✓ Uninstalled successfully!" 34 | 35 | clean: ## Remove build artifacts 36 | @echo "Cleaning..." 37 | @rm -f $(BINARY_NAME) 38 | @go clean 39 | 40 | test: ## Run tests 41 | @echo "Running tests..." 42 | $(GO) test ./... 43 | 44 | run: build ## Build and run 45 | @echo "Running $(BINARY_NAME)..." 46 | ./$(BINARY_NAME) 47 | 48 | dev: ## Run without building (go run) 49 | @echo "Running in development mode..." 50 | $(GO) run ./cmd/lazyhelm/main.go 51 | 52 | deps: ## Download dependencies 53 | @echo "Downloading dependencies..." 54 | $(GO) mod download 55 | $(GO) mod tidy 56 | 57 | release: ## Build for multiple platforms 58 | @echo "Building releases..." 59 | @mkdir -p dist 60 | GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY_NAME)_darwin_amd64 ./cmd/lazyhelm 61 | GOOS=darwin GOARCH=arm64 $(GO) build -o dist/$(BINARY_NAME)_darwin_arm64 ./cmd/lazyhelm 62 | GOOS=linux GOARCH=amd64 $(GO) build -o dist/$(BINARY_NAME)_linux_amd64 ./cmd/lazyhelm 63 | GOOS=linux GOARCH=arm64 $(GO) build -o dist/$(BINARY_NAME)_linux_arm64 ./cmd/lazyhelm 64 | @echo "✓ Release binaries created in dist/" 65 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: lazyhelm 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | - go mod download 7 | 8 | builds: 9 | - id: lazyhelm 10 | main: ./cmd/lazyhelm 11 | binary: lazyhelm 12 | env: 13 | - CGO_ENABLED=0 14 | goos: 15 | - linux 16 | - darwin 17 | - windows 18 | goarch: 19 | - amd64 20 | - arm64 21 | ignore: 22 | - goos: windows 23 | goarch: arm64 24 | ldflags: 25 | - -s -w 26 | - -X main.version={{.Version}} 27 | - -X main.commit={{.Commit}} 28 | - -X main.date={{.Date}} 29 | 30 | archives: 31 | - id: lazyhelm 32 | format: tar.gz 33 | name_template: >- 34 | {{ .ProjectName }}_ 35 | {{- title .Os }}_ 36 | {{- if eq .Arch "amd64" }}x86_64 37 | {{- else if eq .Arch "386" }}i386 38 | {{- else }}{{ .Arch }}{{ end }} 39 | format_overrides: 40 | - goos: windows 41 | format: zip 42 | files: 43 | - README.md 44 | - LICENSE 45 | 46 | checksum: 47 | name_template: 'checksums.txt' 48 | 49 | snapshot: 50 | name_template: "{{ incpatch .Version }}-next" 51 | 52 | changelog: 53 | sort: asc 54 | filters: 55 | exclude: 56 | - '^docs:' 57 | - '^test:' 58 | - '^chore:' 59 | - '^ci:' 60 | 61 | release: 62 | github: 63 | owner: alessandropitocchi 64 | name: lazyhelm 65 | draft: false 66 | prerelease: auto 67 | name_template: "{{.ProjectName}} v{{.Version}}" 68 | footer: | 69 | ## Installation 70 | 71 | ### Homebrew (macOS/Linux) 72 | ```bash 73 | brew tap alessandropitocchi/lazyhelm 74 | brew install lazyhelm 75 | ``` 76 | 77 | Or in one command: 78 | ```bash 79 | brew install alessandropitocchi/lazyhelm/lazyhelm 80 | ``` 81 | 82 | ### Using Go 83 | ```bash 84 | go install github.com/alessandropitocchi/lazyhelm/cmd/lazyhelm@{{.Tag}} 85 | ``` 86 | 87 | ### Using install script 88 | ```bash 89 | curl -sSL https://raw.githubusercontent.com/alessandropitocchi/lazyhelm/main/install.sh | bash 90 | ``` 91 | 92 | ### Manual 93 | Download the appropriate binary for your platform from the assets below. 94 | 95 | brews: 96 | - name: lazyhelm 97 | repository: 98 | owner: alessandropitocchi 99 | name: homebrew-lazyhelm 100 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 101 | commit_author: 102 | name: goreleaserbot 103 | email: bot@goreleaser.com 104 | folder: Formula 105 | homepage: "https://github.com/alessandropitocchi/lazyhelm" 106 | description: "A terminal UI for browsing and managing Helm charts" 107 | license: "Apache-2.0" 108 | skip_upload: auto 109 | test: | 110 | output = shell_output("#{bin}/lazyhelm --version") 111 | assert_match "lazyhelm version", output 112 | install: | 113 | bin.install "lazyhelm" 114 | -------------------------------------------------------------------------------- /PUBLISHING.md: -------------------------------------------------------------------------------- 1 | # Publishing LazyHelm 2 | 3 | Quick guide to publish your project and make it installable. 4 | 5 | ## 1. Push to GitHub 6 | 7 | ```bash 8 | # Add all files 9 | git add . 10 | 11 | # Commit 12 | git commit -m "feat: initial release with installation support" 13 | 14 | # Push to GitHub 15 | git push origin main 16 | ``` 17 | 18 | ## 2. Create Your First Release 19 | 20 | ```bash 21 | # Create and push a tag 22 | git tag -a v0.1.0 -m "Initial release v0.1.0" 23 | git push origin v0.1.0 24 | ``` 25 | 26 | GitHub Actions will automatically: 27 | - Build binaries for Linux, macOS, Windows 28 | - Create a GitHub release 29 | - Upload all binaries 30 | 31 | ## 3. Users Can Now Install 32 | 33 | Once the release is published, users can install via: 34 | 35 | ### Option 1: Go Install (Easiest) 36 | ```bash 37 | go install github.com/alessandropitocchi/lazyhelm/cmd/lazyhelm@latest 38 | ``` 39 | 40 | ### Option 2: Install Script 41 | ```bash 42 | curl -sSL https://raw.githubusercontent.com/alessandropitocchi/lazyhelm/main/install.sh | bash 43 | ``` 44 | 45 | ### Option 3: Manual Download 46 | - Go to GitHub Releases 47 | - Download the binary for their platform 48 | - Move to `/usr/local/bin/` 49 | 50 | ## 4. (Optional) Homebrew Tap 51 | 52 | For macOS users, you can create a Homebrew tap: 53 | 54 | ```bash 55 | # Create a new repo: homebrew-tap 56 | # The goreleaser will automatically update it on each release 57 | ``` 58 | 59 | Then users can install with: 60 | ```bash 61 | brew tap alessandropitocchi/tap 62 | brew install lazyhelm 63 | ``` 64 | 65 | ## Testing Before Publishing 66 | 67 | Test the release process locally: 68 | 69 | ```bash 70 | # Install goreleaser 71 | brew install goreleaser 72 | 73 | # Test release without publishing 74 | goreleaser release --snapshot --clean 75 | 76 | # Check binaries in dist/ 77 | ls -la dist/ 78 | ``` 79 | 80 | ## Updating the Release 81 | 82 | To create a new release: 83 | 84 | ```bash 85 | # Make your changes 86 | git add . 87 | git commit -m "feat: add new feature" 88 | git push 89 | 90 | # Create new tag (bump version) 91 | git tag -a v0.2.0 -m "Release v0.2.0 with new features" 92 | git push origin v0.2.0 93 | ``` 94 | 95 | ## Repository Settings 96 | 97 | Make sure your GitHub repository has: 98 | 1. **Actions enabled** (Settings → Actions → Allow all actions) 99 | 2. **Write permissions for GITHUB_TOKEN** (Settings → Actions → Workflow permissions → Read and write) 100 | 101 | ## First Time Setup Checklist 102 | 103 | - [x] README.md created 104 | - [x] LICENSE file present 105 | - [x] install.sh script created and executable 106 | - [x] Makefile for easy builds 107 | - [x] .goreleaser.yaml configured 108 | - [x] GitHub Actions workflows (.github/workflows/) 109 | - [x] .gitignore configured 110 | - [ ] Push to GitHub 111 | - [ ] Create first release tag 112 | - [ ] Verify release on GitHub 113 | - [ ] Test installation 114 | - [ ] Share with the community! 115 | 116 | ## Support 117 | 118 | After publishing: 119 | - Monitor GitHub Issues for bug reports 120 | - Update README with screenshots/GIFs 121 | - Add to awesome-go or similar lists 122 | - Share on Reddit, Twitter, etc. 123 | -------------------------------------------------------------------------------- /internal/ui/yaml_highlighter.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Alessandro Pitocchi 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ui 16 | 17 | import ( 18 | "regexp" 19 | "strings" 20 | 21 | "github.com/charmbracelet/lipgloss" 22 | ) 23 | 24 | var ( 25 | keyStyle = lipgloss.NewStyle(). 26 | Foreground(lipgloss.Color("117")). // Azzurro cielo chiaro 27 | Bold(true) 28 | 29 | stringStyle = lipgloss.NewStyle(). 30 | Foreground(lipgloss.Color("121")) // Verde menta delicato 31 | 32 | numberStyle = lipgloss.NewStyle(). 33 | Foreground(lipgloss.Color("229")) // Giallo pastello 34 | 35 | boolStyle = lipgloss.NewStyle(). 36 | Foreground(lipgloss.Color("219")) // Rosa pastello/lavanda 37 | 38 | commentStyle = lipgloss.NewStyle(). 39 | Foreground(lipgloss.Color("248")) // Grigio chiaro 40 | 41 | nullStyle = lipgloss.NewStyle(). 42 | Foreground(lipgloss.Color("252")) // Grigio molto chiaro 43 | ) 44 | 45 | var ( 46 | commentRegex = regexp.MustCompile(`^\s*#.*$`) 47 | keyRegex = regexp.MustCompile(`^(\s*)([a-zA-Z0-9_-]+):\s*(.*)$`) 48 | numberRegex = regexp.MustCompile(`^-?\d+(\.\d+)?$`) 49 | boolRegex = regexp.MustCompile(`^(true|false|yes|no|on|off)$`) 50 | nullRegex = regexp.MustCompile(`^(null|~)$`) 51 | ) 52 | 53 | func HighlightYAML(line string) string { 54 | if commentRegex.MatchString(line) { 55 | return commentStyle.Render(line) 56 | } 57 | 58 | matches := keyRegex.FindStringSubmatch(line) 59 | if len(matches) == 4 { 60 | indent := matches[1] 61 | key := matches[2] 62 | value := matches[3] 63 | 64 | result := indent + keyStyle.Render(key+":") + " " 65 | 66 | if value != "" { 67 | value = strings.TrimSpace(value) 68 | result += highlightValue(value) 69 | } 70 | 71 | return result 72 | } 73 | 74 | return line 75 | } 76 | 77 | func highlightValue(value string) string { 78 | trimmed := strings.Trim(value, `"'`) 79 | 80 | if numberRegex.MatchString(trimmed) { 81 | return numberStyle.Render(value) 82 | } 83 | 84 | if boolRegex.MatchString(strings.ToLower(trimmed)) { 85 | return boolStyle.Render(value) 86 | } 87 | 88 | if nullRegex.MatchString(trimmed) { 89 | return nullStyle.Render(value) 90 | } 91 | 92 | if strings.HasPrefix(value, `"`) || strings.HasPrefix(value, `'`) { 93 | return stringStyle.Render(value) 94 | } 95 | 96 | if value != "" && value != "-" && value != "|" && value != ">" { 97 | return stringStyle.Render(value) 98 | } 99 | 100 | return value 101 | } 102 | 103 | func HighlightYAMLContent(content string) string { 104 | lines := strings.Split(content, "\n") 105 | highlighted := make([]string, len(lines)) 106 | 107 | for i, line := range lines { 108 | highlighted[i] = HighlightYAML(line) 109 | } 110 | 111 | return strings.Join(highlighted, "\n") 112 | } 113 | 114 | // HighlightYAMLLine is an alias for HighlightYAML 115 | func HighlightYAMLLine(line string) string { 116 | return HighlightYAML(line) 117 | } 118 | -------------------------------------------------------------------------------- /internal/artifacthub/types.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Alessandro Pitocchi 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package artifacthub 16 | 17 | // SearchResponse represents the response from Artifact Hub search API 18 | type SearchResponse struct { 19 | Packages []Package `json:"packages"` 20 | } 21 | 22 | // Package represents a Helm package in Artifact Hub 23 | type Package struct { 24 | PackageID string `json:"package_id"` 25 | Name string `json:"name"` 26 | NormalizedName string `json:"normalized_name"` 27 | Description string `json:"description"` 28 | Version string `json:"version"` 29 | AppVersion string `json:"app_version"` 30 | Deprecated bool `json:"deprecated"` 31 | Stars int `json:"stars"` 32 | Signed bool `json:"signed"` 33 | Signatures []string `json:"signatures"` 34 | SecurityReport SecurityReport `json:"security_report_summary"` 35 | Repository Repository `json:"repository"` 36 | Keywords []string `json:"keywords"` 37 | HomeURL string `json:"home_url"` 38 | Readme string `json:"readme"` 39 | AvailableVersions []AvailableVersion `json:"available_versions"` 40 | ValuesSchema interface{} `json:"values_schema"` 41 | DefaultValues string `json:"default_values"` 42 | } 43 | 44 | // Repository represents a Helm repository in Artifact Hub 45 | type Repository struct { 46 | RepositoryID string `json:"repository_id"` 47 | Name string `json:"name"` 48 | DisplayName string `json:"display_name"` 49 | URL string `json:"url"` 50 | Kind int `json:"kind"` // 0 = Helm 51 | VerifiedPublisher bool `json:"verified_publisher"` 52 | Official bool `json:"official"` 53 | OrganizationName string `json:"organization_name"` 54 | OrganizationDisplay string `json:"organization_display_name"` 55 | } 56 | 57 | // SecurityReport represents security vulnerability summary 58 | type SecurityReport struct { 59 | Critical int `json:"critical"` 60 | High int `json:"high"` 61 | Medium int `json:"medium"` 62 | Low int `json:"low"` 63 | Unknown int `json:"unknown"` 64 | } 65 | 66 | // AvailableVersion represents an available version of a package 67 | type AvailableVersion struct { 68 | Version string `json:"version"` 69 | CreatedAt int64 `json:"ts"` 70 | ContainsSecurityUpdates bool `json:"contains_security_updates"` 71 | Prerelease bool `json:"prerelease"` 72 | } 73 | 74 | // GetSecurityBadge returns a colored badge based on severity 75 | func (s SecurityReport) GetSecurityBadge() string { 76 | if s.Critical > 0 { 77 | return "🔴 Critical" 78 | } 79 | if s.High > 0 { 80 | return "🟠 High" 81 | } 82 | if s.Medium > 0 { 83 | return "🟡 Medium" 84 | } 85 | if s.Low > 0 { 86 | return "🟢 Low" 87 | } 88 | return "✅ Secure" 89 | } 90 | 91 | // GetBadges returns a string with all applicable badges 92 | func (p Package) GetBadges() string { 93 | badges := "" 94 | if p.Repository.VerifiedPublisher { 95 | badges += "✓ " 96 | } 97 | if p.Signed { 98 | badges += "🔒 " 99 | } 100 | if p.Repository.Official { 101 | badges += "⭐ " 102 | } 103 | return badges 104 | } 105 | -------------------------------------------------------------------------------- /internal/artifacthub/client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Alessandro Pitocchi 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package artifacthub 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | "io" 21 | "net/http" 22 | "net/url" 23 | "time" 24 | ) 25 | 26 | const ( 27 | baseURL = "https://artifacthub.io/api/v1" 28 | // kind 0 = Helm charts 29 | helmKind = 0 30 | ) 31 | 32 | // Client is the Artifact Hub API client 33 | type Client struct { 34 | httpClient *http.Client 35 | baseURL string 36 | } 37 | 38 | // NewClient creates a new Artifact Hub API client 39 | func NewClient() *Client { 40 | return &Client{ 41 | httpClient: &http.Client{ 42 | Timeout: 30 * time.Second, 43 | }, 44 | baseURL: baseURL, 45 | } 46 | } 47 | 48 | // SearchPackages searches for Helm packages on Artifact Hub 49 | func (c *Client) SearchPackages(query string, limit int) ([]Package, error) { 50 | if limit == 0 { 51 | limit = 20 52 | } 53 | 54 | params := url.Values{} 55 | params.Add("ts_query_web", query) 56 | params.Add("facets", "false") 57 | params.Add("limit", fmt.Sprintf("%d", limit)) 58 | params.Add("kind", fmt.Sprintf("%d", helmKind)) 59 | 60 | searchURL := fmt.Sprintf("%s/packages/search?%s", c.baseURL, params.Encode()) 61 | 62 | resp, err := c.httpClient.Get(searchURL) 63 | if err != nil { 64 | return nil, fmt.Errorf("failed to search packages: %w", err) 65 | } 66 | defer resp.Body.Close() 67 | 68 | if resp.StatusCode != http.StatusOK { 69 | body, _ := io.ReadAll(resp.Body) 70 | return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) 71 | } 72 | 73 | var searchResp SearchResponse 74 | if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { 75 | return nil, fmt.Errorf("failed to decode response: %w", err) 76 | } 77 | 78 | return searchResp.Packages, nil 79 | } 80 | 81 | // GetPackageDetails gets detailed information about a specific package 82 | func (c *Client) GetPackageDetails(repoName, packageName string) (*Package, error) { 83 | detailURL := fmt.Sprintf("%s/packages/helm/%s/%s", c.baseURL, repoName, packageName) 84 | 85 | resp, err := c.httpClient.Get(detailURL) 86 | if err != nil { 87 | return nil, fmt.Errorf("failed to get package details: %w", err) 88 | } 89 | defer resp.Body.Close() 90 | 91 | if resp.StatusCode != http.StatusOK { 92 | body, _ := io.ReadAll(resp.Body) 93 | return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) 94 | } 95 | 96 | var pkg Package 97 | if err := json.NewDecoder(resp.Body).Decode(&pkg); err != nil { 98 | return nil, fmt.Errorf("failed to decode response: %w", err) 99 | } 100 | 101 | return &pkg, nil 102 | } 103 | 104 | // GetPackageVersion gets a specific version of a package 105 | func (c *Client) GetPackageVersion(repoName, packageName, version string) (*Package, error) { 106 | versionURL := fmt.Sprintf("%s/packages/helm/%s/%s/%s", c.baseURL, repoName, packageName, version) 107 | 108 | resp, err := c.httpClient.Get(versionURL) 109 | if err != nil { 110 | return nil, fmt.Errorf("failed to get package version: %w", err) 111 | } 112 | defer resp.Body.Close() 113 | 114 | if resp.StatusCode != http.StatusOK { 115 | body, _ := io.ReadAll(resp.Body) 116 | return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) 117 | } 118 | 119 | var pkg Package 120 | if err := json.NewDecoder(resp.Body).Decode(&pkg); err != nil { 121 | return nil, fmt.Errorf("failed to decode response: %w", err) 122 | } 123 | 124 | return &pkg, nil 125 | } 126 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # LazyHelm Installation Script 5 | 6 | REPO="alessandropitocchi/lazyhelm" 7 | BINARY_NAME="lazyhelm" 8 | INSTALL_DIR="/usr/local/bin" 9 | 10 | # Colors 11 | RED='\033[0;31m' 12 | GREEN='\033[0;32m' 13 | YELLOW='\033[1;33m' 14 | NC='\033[0m' # No Color 15 | 16 | echo -e "${GREEN}Installing LazyHelm...${NC}" 17 | 18 | # Detect OS and architecture 19 | OS=$(uname -s | tr '[:upper:]' '[:lower:]') 20 | ARCH=$(uname -m) 21 | 22 | case $ARCH in 23 | x86_64) 24 | ARCH="amd64" 25 | ;; 26 | arm64|aarch64) 27 | ARCH="arm64" 28 | ;; 29 | *) 30 | echo -e "${RED}Unsupported architecture: $ARCH${NC}" 31 | exit 1 32 | ;; 33 | esac 34 | 35 | echo "Detected: $OS/$ARCH" 36 | 37 | # Get latest release 38 | echo "Fetching latest release..." 39 | LATEST_RELEASE=$(curl -s "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') 40 | 41 | if [ -z "$LATEST_RELEASE" ]; then 42 | echo -e "${YELLOW}No releases found. Installing from source...${NC}" 43 | 44 | # Check if Go is installed 45 | if ! command -v go &> /dev/null; then 46 | echo -e "${RED}Go is not installed. Please install Go 1.21+ first.${NC}" 47 | exit 1 48 | fi 49 | 50 | # Clone and build 51 | TEMP_DIR=$(mktemp -d) 52 | cd "$TEMP_DIR" 53 | echo "Cloning repository..." 54 | git clone "https://github.com/$REPO.git" 55 | cd lazyhelm 56 | echo "Building..." 57 | go build -o "$BINARY_NAME" ./cmd/lazyhelm 58 | 59 | # Install binary 60 | if [ -w "$INSTALL_DIR" ]; then 61 | mv "$BINARY_NAME" "$INSTALL_DIR/" 62 | else 63 | echo "Installing to $INSTALL_DIR (requires sudo)..." 64 | sudo mv "$BINARY_NAME" "$INSTALL_DIR/" 65 | fi 66 | 67 | cd ~ 68 | rm -rf "$TEMP_DIR" 69 | else 70 | echo "Latest release: $LATEST_RELEASE" 71 | 72 | # Determine the correct OS name for the archive (GoReleaser uses title case) 73 | case $OS in 74 | darwin) 75 | OS_TITLE="Darwin" 76 | ;; 77 | linux) 78 | OS_TITLE="Linux" 79 | ;; 80 | *) 81 | OS_TITLE="$OS" 82 | ;; 83 | esac 84 | 85 | # Determine the correct architecture name for the archive 86 | case $ARCH in 87 | amd64) 88 | ARCH_TITLE="x86_64" 89 | ;; 90 | *) 91 | ARCH_TITLE="$ARCH" 92 | ;; 93 | esac 94 | 95 | # Download archive 96 | ARCHIVE_NAME="${BINARY_NAME}_${OS_TITLE}_${ARCH_TITLE}.tar.gz" 97 | DOWNLOAD_URL="https://github.com/$REPO/releases/download/$LATEST_RELEASE/$ARCHIVE_NAME" 98 | TEMP_DIR=$(mktemp -d) 99 | 100 | echo "Downloading $ARCHIVE_NAME..." 101 | if curl -fsSL "$DOWNLOAD_URL" -o "$TEMP_DIR/$ARCHIVE_NAME"; then 102 | echo "Extracting..." 103 | tar -xzf "$TEMP_DIR/$ARCHIVE_NAME" -C "$TEMP_DIR" 104 | 105 | # Install binary 106 | if [ -w "$INSTALL_DIR" ]; then 107 | mv "$TEMP_DIR/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME" 108 | else 109 | echo "Installing to $INSTALL_DIR (requires sudo)..." 110 | sudo mv "$TEMP_DIR/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME" 111 | fi 112 | 113 | # Cleanup 114 | rm -rf "$TEMP_DIR" 115 | else 116 | echo -e "${RED}Failed to download binary. Trying from source...${NC}" 117 | rm -rf "$TEMP_DIR" 118 | 119 | # Fallback to source installation 120 | if ! command -v go &> /dev/null; then 121 | echo -e "${RED}Go is not installed. Please install Go 1.21+ first.${NC}" 122 | exit 1 123 | fi 124 | 125 | go install "github.com/$REPO/cmd/lazyhelm@latest" 126 | echo -e "${GREEN}Installed via 'go install'${NC}" 127 | echo -e "${YELLOW}Note: Make sure $HOME/go/bin is in your PATH${NC}" 128 | echo -e "${YELLOW}Add this to your ~/.zshrc or ~/.bashrc:${NC}" 129 | echo -e "${YELLOW} export PATH=\$PATH:\$HOME/go/bin${NC}" 130 | exit 0 131 | fi 132 | fi 133 | 134 | # Verify installation 135 | if command -v $BINARY_NAME &> /dev/null; then 136 | echo -e "${GREEN}✓ LazyHelm installed successfully!${NC}" 137 | echo "" 138 | echo "Run 'lazyhelm' to get started" 139 | echo "" 140 | echo "Optional: Set your preferred editor" 141 | echo " export EDITOR=nvim" 142 | else 143 | echo -e "${RED}Installation failed${NC}" 144 | exit 1 145 | fi 146 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alessandropitocchi/lazyhelm 2 | 3 | go 1.25.3 4 | 5 | require ( 6 | github.com/atotto/clipboard v0.1.4 7 | github.com/charmbracelet/bubbles v0.21.0 8 | github.com/charmbracelet/bubbletea v1.3.10 9 | github.com/charmbracelet/lipgloss v1.1.0 10 | github.com/sahilm/fuzzy v0.1.1 11 | gopkg.in/yaml.v3 v3.0.1 12 | helm.sh/helm/v3 v3.19.0 13 | ) 14 | 15 | require ( 16 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect 17 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 18 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 19 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 20 | github.com/blang/semver/v4 v4.0.0 // indirect 21 | github.com/chai2010/gettext-go v1.0.2 // indirect 22 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 23 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 24 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 25 | github.com/charmbracelet/x/term v0.2.1 // indirect 26 | github.com/containerd/containerd v1.7.28 // indirect 27 | github.com/containerd/errdefs v0.3.0 // indirect 28 | github.com/containerd/log v0.1.0 // indirect 29 | github.com/containerd/platforms v0.2.1 // indirect 30 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 31 | github.com/emicklei/go-restful/v3 v3.12.2 // indirect 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 33 | github.com/evanphx/json-patch v5.9.11+incompatible // indirect 34 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect 35 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 36 | github.com/go-errors/errors v1.4.2 // indirect 37 | github.com/go-logr/logr v1.4.2 // indirect 38 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 39 | github.com/go-openapi/jsonreference v0.20.2 // indirect 40 | github.com/go-openapi/swag v0.23.0 // indirect 41 | github.com/gogo/protobuf v1.3.2 // indirect 42 | github.com/google/btree v1.1.3 // indirect 43 | github.com/google/gnostic-models v0.7.0 // indirect 44 | github.com/google/go-cmp v0.7.0 // indirect 45 | github.com/google/uuid v1.6.0 // indirect 46 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect 47 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 48 | github.com/hashicorp/errwrap v1.1.0 // indirect 49 | github.com/hashicorp/go-multierror v1.1.1 // indirect 50 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 51 | github.com/josharian/intern v1.0.0 // indirect 52 | github.com/json-iterator/go v1.1.12 // indirect 53 | github.com/klauspost/compress v1.18.0 // indirect 54 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 55 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 56 | github.com/mailru/easyjson v0.7.7 // indirect 57 | github.com/mattn/go-isatty v0.0.20 // indirect 58 | github.com/mattn/go-localereader v0.0.1 // indirect 59 | github.com/mattn/go-runewidth v0.0.16 // indirect 60 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 61 | github.com/moby/spdystream v0.5.0 // indirect 62 | github.com/moby/term v0.5.2 // indirect 63 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 64 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 65 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 66 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 67 | github.com/muesli/cancelreader v0.2.2 // indirect 68 | github.com/muesli/termenv v0.16.0 // indirect 69 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 70 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 71 | github.com/opencontainers/go-digest v1.0.0 // indirect 72 | github.com/opencontainers/image-spec v1.1.1 // indirect 73 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 74 | github.com/pkg/errors v0.9.1 // indirect 75 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 76 | github.com/rivo/uniseg v0.4.7 // indirect 77 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 78 | github.com/sirupsen/logrus v1.9.3 // indirect 79 | github.com/spf13/cobra v1.10.1 // indirect 80 | github.com/spf13/pflag v1.0.9 // indirect 81 | github.com/x448/float16 v0.8.4 // indirect 82 | github.com/xlab/treeprint v1.2.0 // indirect 83 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 84 | go.yaml.in/yaml/v2 v2.4.2 // indirect 85 | go.yaml.in/yaml/v3 v3.0.4 // indirect 86 | golang.org/x/crypto v0.41.0 // indirect 87 | golang.org/x/net v0.42.0 // indirect 88 | golang.org/x/oauth2 v0.30.0 // indirect 89 | golang.org/x/sync v0.16.0 // indirect 90 | golang.org/x/sys v0.36.0 // indirect 91 | golang.org/x/term v0.34.0 // indirect 92 | golang.org/x/text v0.28.0 // indirect 93 | golang.org/x/time v0.12.0 // indirect 94 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect 95 | google.golang.org/grpc v1.72.1 // indirect 96 | google.golang.org/protobuf v1.36.5 // indirect 97 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 98 | gopkg.in/inf.v0 v0.9.1 // indirect 99 | k8s.io/api v0.34.0 // indirect 100 | k8s.io/apiextensions-apiserver v0.34.0 // indirect 101 | k8s.io/apimachinery v0.34.0 // indirect 102 | k8s.io/cli-runtime v0.34.0 // indirect 103 | k8s.io/client-go v0.34.0 // indirect 104 | k8s.io/component-base v0.34.0 // indirect 105 | k8s.io/klog/v2 v2.130.1 // indirect 106 | k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect 107 | k8s.io/kubectl v0.34.0 // indirect 108 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect 109 | oras.land/oras-go/v2 v2.6.0 // indirect 110 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 111 | sigs.k8s.io/kustomize/api v0.20.1 // indirect 112 | sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect 113 | sigs.k8s.io/randfill v1.0.0 // indirect 114 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 115 | sigs.k8s.io/yaml v1.6.0 // indirect 116 | ) 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LazyHelm 2 | 3 | A terminal UI for browsing and managing Helm charts. Inspired by lazygit and lazydocker. 4 | 5 | ## Demo 6 | 7 | ![LazyHelm Demo](demo.gif) 8 | 9 | *Demo updated to version 0.2.2* 10 | 11 | ## What it does 12 | 13 | LazyHelm brings an intuitive, lazygit-style interface to Helm: 14 | 15 | - **Browse & Search** - Explore Helm repos, search Artifact Hub, discover charts 16 | - **View & Compare** - Syntax-highlighted values, diff between versions or revisions 17 | - **Edit & Export** - Modify values in your editor, export configurations 18 | - **Cluster Integration** - View deployed releases, explore revision history, compare any two revisions 19 | - **Search Everything** - Fuzzy search through repos, charts, values, and releases 20 | 21 | All in your terminal. No need to remember helm commands or manually fetch values. 22 | 23 | ## Features 24 | 25 | ### Chart Repository Management 26 | - **Intuitive menu system** - Organized navigation for repositories, charts, and cluster resources 27 | - **Interactive browsing** - Browse local Helm repositories and charts 28 | - **Artifact Hub integration** - Search and browse charts directly from Artifact Hub 29 | - **Repository operations** - Add, remove, and update repository indexes 30 | - **Add from Artifact Hub** - Install repos with package info and security reports 31 | 32 | ### Chart Analysis 33 | - **Syntax-highlighted YAML** - Beautiful YAML rendering with full syntax highlighting 34 | - **Version comparison** - Diff between any two chart versions side-by-side 35 | - **Values editing** - Edit values in your preferred editor (nvim/vim/vi) with validation 36 | - **Export values** - Save chart values to files for backup or customization 37 | - **Template preview** - Generate and preview Helm templates before deployment 38 | - **YAML path copy** - Copy any YAML path to clipboard for quick reference 39 | 40 | ### Cluster Releases (Read-Only) 41 | - **Browse releases** - View all deployed Helm releases across namespaces 42 | - **Namespace filtering** - Filter releases by specific namespace or view all 43 | - **Release details** - View status, chart version, app version, and deployment notes 44 | - **Revision history** - Interactive history showing all deployments with descriptions 45 | - **Historical values** - Inspect values from any revision (current or past) 46 | - **Revision diff** - Compare values between any two revisions with side-by-side view 47 | - **Export release values** - Save deployed configuration to files 48 | - **Kubectl context** - Always shows current cluster context for safety 49 | - **Search in values** - Fuzzy search through release configurations 50 | - **Horizontal scroll** - Full support for long configuration lines 51 | 52 | ### Search & Navigation 53 | - **Fuzzy search** - Fast search through repos, charts, versions, values, and releases 54 | - **Quick filter clear** - Instantly restore full lists 55 | - **Search in content** - Find text in YAML files with match highlighting 56 | - **Jump to matches** - Navigate between search results with visual feedback 57 | 58 | ## Installation 59 | 60 | ### Homebrew 61 | 62 | ```bash 63 | brew tap alessandropitocchi/lazyhelm 64 | brew install lazyhelm 65 | ``` 66 | 67 | Or in one command: 68 | ```bash 69 | brew install alessandropitocchi/lazyhelm/lazyhelm 70 | ``` 71 | 72 | ### Install script 73 | 74 | ```bash 75 | curl -sSL https://raw.githubusercontent.com/alessandropitocchi/lazyhelm/main/install.sh | bash 76 | ``` 77 | 78 | ### From source 79 | 80 | ```bash 81 | git clone https://github.com/alessandropitocchi/lazyhelm.git 82 | cd lazyhelm 83 | make install 84 | ``` 85 | 86 | ## Usage 87 | 88 | Just run: 89 | ```bash 90 | lazyhelm 91 | ``` 92 | 93 | Set your editor if you want (defaults to nvim → vim → vi): 94 | ```bash 95 | export EDITOR=nvim 96 | ``` 97 | 98 | ### Menu Structure 99 | 100 | LazyHelm uses an intuitive menu system to organize functionality: 101 | 102 | ``` 103 | Main Menu 104 | ├── Browse Repositories 105 | │ ├── Local Repositories - Browse your configured Helm repos 106 | │ └── Search Artifact Hub - Search charts on Artifact Hub 107 | ├── Cluster Releases - View and analyze deployed Helm releases 108 | │ ├── All Namespaces - View releases across all namespaces 109 | │ └── Select Namespace - Filter by specific namespace 110 | └── Settings (Coming Soon) - Configure LazyHelm 111 | ``` 112 | 113 | ## Keybindings 114 | 115 | ### Navigation 116 | - `↑/k`, `↓/j` - Move up/down 117 | - `←`, `→` - Scroll left/right (in values/detail views) 118 | - `enter` - Select item / Go deeper 119 | - `esc` - Go back to previous screen 120 | - `q` - Quit application 121 | - `?` - Toggle help screen 122 | 123 | ### Search & Filter 124 | - `/` - Search/filter in current view 125 | - `c` - Clear search filter 126 | - `n` - Next search result 127 | - `N` - Previous search result 128 | 129 | ### Repository Management 130 | - `a` - Add new repository 131 | - `r` - Remove selected repository 132 | - `u` - Update repository index (helm repo update) 133 | - `s` - Search Artifact Hub 134 | 135 | ### Chart & Version Actions 136 | - `v` - View all versions (in chart list) 137 | - `d` - Diff two versions (select first, then second) 138 | 139 | ### Cluster Releases 140 | - `v` - View current release values (in release detail) 141 | - `h` - View release history & revisions (in release detail) 142 | - `d` - Diff two revisions (in revision history: select first, then second) 143 | - `w` - Export release values to file (in values view) 144 | - `/` - Search in release list or values 145 | - `c` - Clear search filter 146 | 147 | ### Values View 148 | - `e` - Edit values in external editor ($EDITOR) 149 | - `w` - Write/export values to file 150 | - `t` - Generate Helm template 151 | - `y` - Copy YAML path to clipboard 152 | - `←`, `→` - Scroll horizontally for long lines 153 | 154 | ## How it works 155 | 156 | Uses the Helm SDK to interact with chart repos and the [Bubbletea](https://github.com/charmbracelet/bubbletea) framework for the TUI. 157 | 158 | Reads from your existing Helm config (`~/.config/helm/repositories.yaml`) and caches data locally for faster browsing. 159 | 160 | ## Requirements 161 | 162 | - Helm 3.x installed 163 | - kubectl configured with valid context (optional, for Cluster Releases feature) 164 | - Go 1.21+ (if building from source) 165 | - Terminal with ANSI color support 166 | - kubectl configured with valid context (optional, for Cluster Releases feature) 167 | 168 | ## Development 169 | 170 | ```bash 171 | git clone https://github.com/alessandropitocchi/lazyhelm.git 172 | cd lazyhelm 173 | go mod download 174 | go build -o lazyhelm ./cmd/lazyhelm 175 | ./lazyhelm 176 | ``` 177 | 178 | ## TODO 179 | 180 | - Helm operations (install/upgrade/uninstall/rollback) 181 | - View manifest for deployed releases 182 | - Switch kubectl context from UI 183 | - Config file 184 | - Bookmarks 185 | 186 | ## License 187 | 188 | Licensed under the Apache License, Version 2.0 - see the [LICENSE](LICENSE) file for details. 189 | 190 | Copyright 2025 Alessandro Pitocchi 191 | -------------------------------------------------------------------------------- /internal/ui/yaml_utils.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Alessandro Pitocchi 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ui 16 | 17 | import ( 18 | "strings" 19 | ) 20 | 21 | func GetYAMLPath(lines []string, lineNum int) string { 22 | if lineNum < 0 || lineNum >= len(lines) { 23 | return "" 24 | } 25 | 26 | currentLine := lines[lineNum] 27 | trimmedLine := strings.TrimSpace(currentLine) 28 | 29 | // Skip empty lines and comments 30 | if trimmedLine == "" || strings.HasPrefix(trimmedLine, "#") { 31 | return "" 32 | } 33 | 34 | currentIndent := getIndentLevel(currentLine) 35 | currentField := extractKey(currentLine) 36 | 37 | // If current line doesn't have a key, look backwards for the nearest parent key 38 | if currentField == "" { 39 | // Check if this is a list item (starts with -) 40 | isListItem := strings.HasPrefix(trimmedLine, "-") 41 | 42 | for i := lineNum - 1; i >= 0; i-- { 43 | line := lines[i] 44 | trimmed := strings.TrimSpace(line) 45 | if trimmed == "" || strings.HasPrefix(trimmed, "#") { 46 | continue 47 | } 48 | 49 | indent := getIndentLevel(line) 50 | 51 | // For list items, look for parent at lower indent 52 | // For other values, look at same or lower indent 53 | targetIndent := currentIndent 54 | if isListItem { 55 | targetIndent = currentIndent - 2 // List items are typically 2 spaces indented from parent 56 | if targetIndent < 0 { 57 | targetIndent = 0 58 | } 59 | } 60 | 61 | // Found a key at appropriate indent level 62 | if indent < currentIndent || (indent == targetIndent && extractKey(line) != "") { 63 | field := extractKey(line) 64 | if field != "" { 65 | // Use this as our starting point 66 | lineNum = i 67 | currentLine = line 68 | currentIndent = indent 69 | currentField = field 70 | break 71 | } 72 | } 73 | } 74 | 75 | // Still no field found 76 | if currentField == "" { 77 | return "" 78 | } 79 | } 80 | 81 | path := []string{} 82 | 83 | // Build the path by walking backwards through parent keys 84 | for i := lineNum - 1; i >= 0; i-- { 85 | line := lines[i] 86 | trimmed := strings.TrimSpace(line) 87 | if trimmed == "" || strings.HasPrefix(trimmed, "#") { 88 | continue 89 | } 90 | 91 | indent := getIndentLevel(line) 92 | 93 | if indent < currentIndent { 94 | field := extractKey(line) 95 | if field != "" { 96 | path = append([]string{field}, path...) 97 | currentIndent = indent 98 | } 99 | } 100 | 101 | if indent == 0 { 102 | break 103 | } 104 | } 105 | 106 | path = append(path, currentField) 107 | return strings.Join(path, ".") 108 | } 109 | 110 | func getIndentLevel(line string) int { 111 | count := 0 112 | for _, ch := range line { 113 | if ch == ' ' { 114 | count++ 115 | } else if ch == '\t' { 116 | count += 2 117 | } else { 118 | break 119 | } 120 | } 121 | return count 122 | } 123 | 124 | func extractKey(line string) string { 125 | trimmed := strings.TrimSpace(line) 126 | if idx := strings.Index(trimmed, ":"); idx > 0 { 127 | return trimmed[:idx] 128 | } 129 | return "" 130 | } 131 | 132 | type DiffLine struct { 133 | Type string // "added", "removed", "unchanged", "modified" 134 | Line string 135 | LineNum int 136 | } 137 | 138 | func DiffYAML(oldContent, newContent string) []DiffLine { 139 | oldLines := strings.Split(oldContent, "\n") 140 | newLines := strings.Split(newContent, "\n") 141 | 142 | oldMap := make(map[string]struct { 143 | line string 144 | lineNum int 145 | }) 146 | newMap := make(map[string]struct { 147 | line string 148 | lineNum int 149 | }) 150 | 151 | // Build maps with line numbers 152 | for i, line := range oldLines { 153 | key := extractKey(line) 154 | if key != "" { 155 | oldMap[key] = struct { 156 | line string 157 | lineNum int 158 | }{line, i} 159 | } 160 | } 161 | 162 | for i, line := range newLines { 163 | key := extractKey(line) 164 | if key != "" { 165 | newMap[key] = struct { 166 | line string 167 | lineNum int 168 | }{line, i} 169 | } 170 | } 171 | 172 | result := make([]DiffLine, 0) 173 | contextLines := 2 // Number of context lines to show around changes 174 | 175 | // Track which lines are changes or near changes 176 | isChange := make(map[int]bool) 177 | 178 | // Find all changes first 179 | for key, newData := range newMap { 180 | if oldData, exists := oldMap[key]; exists { 181 | if oldData.line != newData.line { 182 | // Modified line - mark it and add both old and new 183 | isChange[newData.lineNum] = true 184 | } 185 | } else { 186 | // Added line 187 | isChange[newData.lineNum] = true 188 | } 189 | } 190 | 191 | // Find removed lines 192 | removedKeys := make([]string, 0) 193 | for key := range oldMap { 194 | if _, exists := newMap[key]; !exists { 195 | removedKeys = append(removedKeys, key) 196 | } 197 | } 198 | 199 | // Build result with changes and context 200 | for i, newLine := range newLines { 201 | key := extractKey(newLine) 202 | 203 | // Check if this line or nearby lines are changes 204 | hasNearbyChange := false 205 | for j := i - contextLines; j <= i + contextLines; j++ { 206 | if isChange[j] { 207 | hasNearbyChange = true 208 | break 209 | } 210 | } 211 | 212 | if !hasNearbyChange { 213 | continue // Skip lines far from changes 214 | } 215 | 216 | if key != "" { 217 | if oldData, exists := oldMap[key]; exists { 218 | if oldData.line != newLine { 219 | // Show old line first, then new line 220 | result = append(result, DiffLine{Type: "removed", Line: oldData.line, LineNum: oldData.lineNum}) 221 | result = append(result, DiffLine{Type: "added", Line: newLine, LineNum: i}) 222 | } else { 223 | // Context line (unchanged) 224 | result = append(result, DiffLine{Type: "unchanged", Line: newLine, LineNum: i}) 225 | } 226 | } else { 227 | // Added line 228 | result = append(result, DiffLine{Type: "added", Line: newLine, LineNum: i}) 229 | } 230 | } else { 231 | // Context line (empty or comment) 232 | result = append(result, DiffLine{Type: "unchanged", Line: newLine, LineNum: i}) 233 | } 234 | } 235 | 236 | // Add removed lines at the end with context 237 | for _, key := range removedKeys { 238 | oldData := oldMap[key] 239 | result = append(result, DiffLine{Type: "removed", Line: oldData.line, LineNum: oldData.lineNum}) 240 | } 241 | 242 | return result 243 | } 244 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | Copyright 2025 Alessandro Pitocchi 204 | -------------------------------------------------------------------------------- /internal/helm/client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Alessandro Pitocchi 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package helm 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | "os" 21 | "os/exec" 22 | "sort" 23 | 24 | "helm.sh/helm/v3/pkg/cli" 25 | "helm.sh/helm/v3/pkg/repo" 26 | ) 27 | 28 | type Client struct { 29 | settings *cli.EnvSettings 30 | } 31 | 32 | func NewClient() *Client { 33 | return &Client{ 34 | settings: cli.New(), 35 | } 36 | } 37 | 38 | type Repository struct { 39 | Name string 40 | URL string 41 | } 42 | 43 | func (c *Client) ListRepositories() ([]Repository, error) { 44 | repoFile := c.settings.RepositoryConfig 45 | 46 | f, err := repo.LoadFile(repoFile) 47 | if err != nil { 48 | if os.IsNotExist(err) { 49 | return []Repository{}, nil 50 | } 51 | return nil, err 52 | } 53 | 54 | repos := make([]Repository, 0, len(f.Repositories)) 55 | for _, r := range f.Repositories { 56 | repos = append(repos, Repository{ 57 | Name: r.Name, 58 | URL: r.URL, 59 | }) 60 | } 61 | 62 | return repos, nil 63 | } 64 | 65 | type Chart struct { 66 | Name string 67 | Version string 68 | Description string 69 | } 70 | 71 | func (c *Client) SearchCharts(repoName string) ([]Chart, error) { 72 | // Add trailing slash to search only in this specific repository 73 | args := []string{"search", "repo", repoName + "/", "--output", "json"} 74 | 75 | cmd := exec.Command("helm", args...) 76 | output, err := cmd.CombinedOutput() 77 | if err != nil { 78 | return nil, fmt.Errorf("helm search failed: %w", err) 79 | } 80 | 81 | var results []struct { 82 | Name string `json:"name"` 83 | Version string `json:"version"` 84 | Description string `json:"description"` 85 | } 86 | 87 | if err := json.Unmarshal(output, &results); err != nil { 88 | return nil, err 89 | } 90 | 91 | // Filter to ensure we only get charts from this repository 92 | repoPrefix := repoName + "/" 93 | charts := make([]Chart, 0) 94 | for _, r := range results { 95 | // Only include charts that start with "repoName/" 96 | if len(r.Name) > len(repoPrefix) && r.Name[:len(repoPrefix)] == repoPrefix { 97 | charts = append(charts, Chart{ 98 | Name: r.Name, 99 | Version: r.Version, 100 | Description: r.Description, 101 | }) 102 | } 103 | } 104 | 105 | return charts, nil 106 | } 107 | 108 | type ChartVersion struct { 109 | Version string 110 | AppVersion string 111 | Description string 112 | } 113 | 114 | func (c *Client) GetChartVersions(chartName string) ([]ChartVersion, error) { 115 | cmd := exec.Command("helm", "search", "repo", chartName, "--versions", "--output", "json") 116 | output, err := cmd.CombinedOutput() 117 | if err != nil { 118 | return nil, fmt.Errorf("helm search versions failed: %w", err) 119 | } 120 | 121 | var results []struct { 122 | Name string `json:"name"` 123 | Version string `json:"version"` 124 | AppVersion string `json:"app_version"` 125 | Description string `json:"description"` 126 | } 127 | 128 | if err := json.Unmarshal(output, &results); err != nil { 129 | return nil, err 130 | } 131 | 132 | versions := make([]ChartVersion, len(results)) 133 | for i, r := range results { 134 | versions[i] = ChartVersion{ 135 | Version: r.Version, 136 | AppVersion: r.AppVersion, 137 | Description: r.Description, 138 | } 139 | } 140 | 141 | return versions, nil 142 | } 143 | 144 | func (c *Client) GetChartValues(chartName string) (string, error) { 145 | cmd := exec.Command("helm", "show", "values", chartName) 146 | output, err := cmd.CombinedOutput() 147 | if err != nil { 148 | return "", fmt.Errorf("helm show values failed: %w", err) 149 | } 150 | return string(output), nil 151 | } 152 | 153 | func (c *Client) GetChartValuesByVersion(chartName, version string) (string, error) { 154 | cmd := exec.Command("helm", "show", "values", chartName, "--version", version) 155 | output, err := cmd.CombinedOutput() 156 | if err != nil { 157 | return "", fmt.Errorf("helm show values failed: %w", err) 158 | } 159 | return string(output), nil 160 | } 161 | 162 | func (c *Client) ExportValues(chartName, outputFile string) error { 163 | values, err := c.GetChartValues(chartName) 164 | if err != nil { 165 | return err 166 | } 167 | 168 | return os.WriteFile(outputFile, []byte(values), 0644) 169 | } 170 | 171 | func (c *Client) GenerateTemplate(chartName, valuesFile, outputPath string) error { 172 | releaseName := "myrelease" 173 | 174 | args := []string{"template", releaseName, chartName, "--output-dir", outputPath} 175 | if valuesFile != "" { 176 | args = append(args, "-f", valuesFile) 177 | } 178 | 179 | cmd := exec.Command("helm", args...) 180 | output, err := cmd.CombinedOutput() 181 | if err != nil { 182 | return fmt.Errorf("helm template failed: %w\nOutput: %s", err, string(output)) 183 | } 184 | return nil 185 | } 186 | 187 | func (c *Client) AddRepository(name, url string) error { 188 | cmd := exec.Command("helm", "repo", "add", name, url) 189 | output, err := cmd.CombinedOutput() 190 | if err != nil { 191 | return fmt.Errorf("helm repo add failed: %w\nOutput: %s", err, string(output)) 192 | } 193 | 194 | // Update repo dopo l'aggiunta 195 | cmd = exec.Command("helm", "repo", "update", name) 196 | if err := cmd.Run(); err != nil { 197 | return fmt.Errorf("helm repo update failed: %w", err) 198 | } 199 | 200 | return nil 201 | } 202 | 203 | func (c *Client) RemoveRepository(name string) error { 204 | cmd := exec.Command("helm", "repo", "remove", name) 205 | output, err := cmd.CombinedOutput() 206 | if err != nil { 207 | return fmt.Errorf("helm repo remove failed: %w\nOutput: %s", err, string(output)) 208 | } 209 | return nil 210 | } 211 | 212 | func (c *Client) UpdateRepository(name string) error { 213 | args := []string{"repo", "update"} 214 | if name != "" { 215 | args = append(args, name) 216 | } 217 | 218 | cmd := exec.Command("helm", args...) 219 | output, err := cmd.CombinedOutput() 220 | if err != nil { 221 | return fmt.Errorf("helm repo update failed: %w\nOutput: %s", err, string(output)) 222 | } 223 | return nil 224 | } 225 | 226 | // Cluster Releases functionality 227 | 228 | type Release struct { 229 | Name string 230 | Namespace string 231 | Revision string 232 | Updated string 233 | Status string 234 | Chart string 235 | AppVersion string 236 | } 237 | 238 | type ReleaseRevision struct { 239 | Revision int 240 | Updated string 241 | Status string 242 | Chart string 243 | AppVersion string 244 | Description string 245 | } 246 | 247 | type ReleaseStatus struct { 248 | Name string 249 | Namespace string 250 | Status string 251 | Description string 252 | Notes string 253 | } 254 | 255 | // ListReleases lists all Helm releases in the specified namespace 256 | // If namespace is empty, lists releases from all namespaces 257 | func (c *Client) ListReleases(namespace string) ([]Release, error) { 258 | args := []string{"list", "--output", "json"} 259 | if namespace == "" { 260 | args = append(args, "-A") // All namespaces 261 | } else { 262 | args = append(args, "-n", namespace) 263 | } 264 | 265 | cmd := exec.Command("helm", args...) 266 | output, err := cmd.CombinedOutput() 267 | if err != nil { 268 | return nil, fmt.Errorf("helm list failed: %w\nOutput: %s", err, string(output)) 269 | } 270 | 271 | var results []struct { 272 | Name string `json:"name"` 273 | Namespace string `json:"namespace"` 274 | Revision string `json:"revision"` 275 | Updated string `json:"updated"` 276 | Status string `json:"status"` 277 | Chart string `json:"chart"` 278 | AppVersion string `json:"app_version"` 279 | } 280 | 281 | if err := json.Unmarshal(output, &results); err != nil { 282 | return nil, err 283 | } 284 | 285 | releases := make([]Release, len(results)) 286 | for i, r := range results { 287 | releases[i] = Release{ 288 | Name: r.Name, 289 | Namespace: r.Namespace, 290 | Revision: r.Revision, 291 | Updated: r.Updated, 292 | Status: r.Status, 293 | Chart: r.Chart, 294 | AppVersion: r.AppVersion, 295 | } 296 | } 297 | 298 | return releases, nil 299 | } 300 | 301 | // ListNamespaces returns a list of namespaces that have Helm releases 302 | func (c *Client) ListNamespaces() ([]string, error) { 303 | // Get all releases to extract unique namespaces 304 | releases, err := c.ListReleases("") 305 | if err != nil { 306 | return nil, err 307 | } 308 | 309 | namespaceMap := make(map[string]bool) 310 | for _, r := range releases { 311 | namespaceMap[r.Namespace] = true 312 | } 313 | 314 | namespaces := make([]string, 0, len(namespaceMap)) 315 | for ns := range namespaceMap { 316 | namespaces = append(namespaces, ns) 317 | } 318 | 319 | sort.Strings(namespaces) 320 | return namespaces, nil 321 | } 322 | 323 | // GetReleaseHistory returns the revision history of a release 324 | func (c *Client) GetReleaseHistory(releaseName, namespace string) ([]ReleaseRevision, error) { 325 | args := []string{"history", releaseName, "--output", "json"} 326 | if namespace != "" { 327 | args = append(args, "-n", namespace) 328 | } 329 | 330 | cmd := exec.Command("helm", args...) 331 | output, err := cmd.CombinedOutput() 332 | if err != nil { 333 | return nil, fmt.Errorf("helm history failed: %w\nOutput: %s", err, string(output)) 334 | } 335 | 336 | var results []struct { 337 | Revision int `json:"revision"` 338 | Updated string `json:"updated"` 339 | Status string `json:"status"` 340 | Chart string `json:"chart"` 341 | AppVersion string `json:"app_version"` 342 | Description string `json:"description"` 343 | } 344 | 345 | if err := json.Unmarshal(output, &results); err != nil { 346 | return nil, err 347 | } 348 | 349 | revisions := make([]ReleaseRevision, len(results)) 350 | for i, r := range results { 351 | revisions[i] = ReleaseRevision{ 352 | Revision: r.Revision, 353 | Updated: r.Updated, 354 | Status: r.Status, 355 | Chart: r.Chart, 356 | AppVersion: r.AppVersion, 357 | Description: r.Description, 358 | } 359 | } 360 | 361 | return revisions, nil 362 | } 363 | 364 | // GetReleaseValues returns the values used for a specific release 365 | func (c *Client) GetReleaseValues(releaseName, namespace string) (string, error) { 366 | args := []string{"get", "values", releaseName} 367 | if namespace != "" { 368 | args = append(args, "-n", namespace) 369 | } 370 | 371 | cmd := exec.Command("helm", args...) 372 | output, err := cmd.CombinedOutput() 373 | if err != nil { 374 | return "", fmt.Errorf("helm get values failed: %w\nOutput: %s", err, string(output)) 375 | } 376 | 377 | return string(output), nil 378 | } 379 | 380 | // GetReleaseValuesByRevision returns the values used for a specific release revision 381 | func (c *Client) GetReleaseValuesByRevision(releaseName, namespace string, revision int) (string, error) { 382 | args := []string{"get", "values", releaseName, "--revision", fmt.Sprintf("%d", revision)} 383 | if namespace != "" { 384 | args = append(args, "-n", namespace) 385 | } 386 | 387 | cmd := exec.Command("helm", args...) 388 | output, err := cmd.CombinedOutput() 389 | if err != nil { 390 | return "", fmt.Errorf("helm get values (revision %d) failed: %w\nOutput: %s", revision, err, string(output)) 391 | } 392 | 393 | return string(output), nil 394 | } 395 | 396 | // GetReleaseStatus returns the status of a release 397 | func (c *Client) GetReleaseStatus(releaseName, namespace string) (*ReleaseStatus, error) { 398 | args := []string{"status", releaseName, "--output", "json"} 399 | if namespace != "" { 400 | args = append(args, "-n", namespace) 401 | } 402 | 403 | cmd := exec.Command("helm", args...) 404 | output, err := cmd.CombinedOutput() 405 | if err != nil { 406 | return nil, fmt.Errorf("helm status failed: %w\nOutput: %s", err, string(output)) 407 | } 408 | 409 | var result struct { 410 | Name string `json:"name"` 411 | Namespace string `json:"namespace"` 412 | Info struct { 413 | Status string `json:"status"` 414 | Description string `json:"description"` 415 | Notes string `json:"notes"` 416 | } `json:"info"` 417 | } 418 | 419 | if err := json.Unmarshal(output, &result); err != nil { 420 | return nil, err 421 | } 422 | 423 | return &ReleaseStatus{ 424 | Name: result.Name, 425 | Namespace: result.Namespace, 426 | Status: result.Info.Status, 427 | Description: result.Info.Description, 428 | Notes: result.Info.Notes, 429 | }, nil 430 | } 431 | 432 | // GetCurrentContext returns the current kubectl context 433 | func (c *Client) GetCurrentContext() (string, error) { 434 | cmd := exec.Command("kubectl", "config", "current-context") 435 | output, err := cmd.CombinedOutput() 436 | if err != nil { 437 | return "", fmt.Errorf("failed to get kubectl context: %w\nOutput: %s", err, string(output)) 438 | } 439 | 440 | context := string(output) 441 | // Trim newline 442 | if len(context) > 0 && context[len(context)-1] == '\n' { 443 | context = context[:len(context)-1] 444 | } 445 | 446 | return context, nil 447 | } 448 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= 2 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 3 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= 4 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 5 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 6 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 7 | github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= 8 | github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 9 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 10 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 11 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 12 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 14 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 15 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 16 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 17 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 18 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 19 | github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= 20 | github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 21 | github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= 22 | github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= 23 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 24 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 25 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 26 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 27 | github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= 28 | github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= 29 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 30 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 31 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 32 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 33 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 34 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 35 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 36 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 37 | github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 38 | github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 39 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 40 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 41 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 42 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 43 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 44 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 45 | github.com/containerd/containerd v1.7.28 h1:Nsgm1AtcmEh4AHAJ4gGlNSaKgXiNccU270Dnf81FQ3c= 46 | github.com/containerd/containerd v1.7.28/go.mod h1:azUkWcOvHrWvaiUjSQH0fjzuHIwSPg1WL5PshGP4Szs= 47 | github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= 48 | github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 49 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 50 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 51 | github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= 52 | github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= 53 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 54 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 55 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 56 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 57 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= 58 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 59 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 60 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 61 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 62 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 63 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 64 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 65 | github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= 66 | github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= 67 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 68 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 69 | github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= 70 | github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= 71 | github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= 72 | github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= 73 | github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= 74 | github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= 75 | github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= 76 | github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 77 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 78 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 79 | github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= 80 | github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= 81 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= 82 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= 83 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 84 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 85 | github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= 86 | github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= 87 | github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 88 | github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 89 | github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= 90 | github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= 91 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 92 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 93 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 94 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 95 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 96 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 97 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 98 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 99 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 100 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 101 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 102 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 103 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 104 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 105 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 106 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 107 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 108 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 109 | github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= 110 | github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 111 | github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= 112 | github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= 113 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 114 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 115 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 116 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= 117 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 118 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 119 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 120 | github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= 121 | github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= 122 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 123 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 124 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 125 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 126 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= 127 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= 128 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 129 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 130 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 131 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 132 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 133 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 134 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 135 | github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= 136 | github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= 137 | github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= 138 | github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 139 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 140 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 141 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 142 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 143 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 144 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 145 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 146 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 147 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 148 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 149 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 150 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 151 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 152 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 153 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 154 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 155 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 156 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 157 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 158 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= 159 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= 160 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 161 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 162 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 163 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 164 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 165 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 166 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 167 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 168 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 169 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 170 | github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= 171 | github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= 172 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 173 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 174 | github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= 175 | github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= 176 | github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= 177 | github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 178 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 179 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 180 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 181 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 182 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= 183 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 184 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= 185 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= 186 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 187 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 188 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 189 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 190 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 191 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 192 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 193 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 194 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= 195 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= 196 | github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= 197 | github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= 198 | github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= 199 | github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= 200 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 201 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 202 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 203 | github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 204 | github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= 205 | github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= 206 | github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= 207 | github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= 208 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 209 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 210 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 211 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 212 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 213 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 214 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 215 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 216 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 217 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 218 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 219 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 220 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 221 | github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= 222 | github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= 223 | github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= 224 | github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= 225 | github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 226 | github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 227 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 228 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 229 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 230 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 231 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 232 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 233 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 234 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 235 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 236 | github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= 237 | github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 238 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 239 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 240 | github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= 241 | github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 242 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 243 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 244 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 245 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 246 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 247 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 248 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 249 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 250 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 251 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 252 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 253 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 254 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 255 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 256 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 257 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 258 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 259 | github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= 260 | github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= 261 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 262 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 263 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 264 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 265 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 266 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 267 | go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= 268 | go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= 269 | go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= 270 | go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= 271 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= 272 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= 273 | go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= 274 | go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 275 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= 276 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= 277 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= 278 | go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= 279 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= 280 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= 281 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= 282 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= 283 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= 284 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= 285 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= 286 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= 287 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= 288 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= 289 | go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= 290 | go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= 291 | go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= 292 | go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= 293 | go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= 294 | go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= 295 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= 296 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= 297 | go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= 298 | go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= 299 | go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= 300 | go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 301 | go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= 302 | go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= 303 | go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= 304 | go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= 305 | go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= 306 | go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= 307 | go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= 308 | go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 309 | go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= 310 | go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= 311 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 312 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 313 | go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 314 | go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= 315 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 316 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 317 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 318 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 319 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 320 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 321 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 322 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 323 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 324 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 325 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 326 | golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= 327 | golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= 328 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 329 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 330 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 331 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 332 | golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 333 | golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 334 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 335 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 336 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 337 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 338 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 339 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 340 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 341 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 342 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 343 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 344 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 345 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 346 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 347 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 348 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 349 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 350 | golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= 351 | golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 352 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 353 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 354 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 355 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 356 | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 357 | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 358 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 359 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 360 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 361 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 362 | golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= 363 | golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= 364 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 365 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 366 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 367 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 368 | google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg= 369 | google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= 370 | google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= 371 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= 372 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= 373 | google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= 374 | google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 375 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 376 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 377 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 378 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 379 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 380 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 381 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 382 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 383 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 384 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 385 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 386 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 387 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 388 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 389 | helm.sh/helm/v3 v3.19.0 h1:krVyCGa8fa/wzTZgqw0DUiXuRT5BPdeqE/sQXujQ22k= 390 | helm.sh/helm/v3 v3.19.0/go.mod h1:Lk/SfzN0w3a3C3o+TdAKrLwJ0wcZ//t1/SDXAvfgDdc= 391 | k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= 392 | k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= 393 | k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= 394 | k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= 395 | k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= 396 | k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= 397 | k8s.io/cli-runtime v0.34.0 h1:N2/rUlJg6TMEBgtQ3SDRJwa8XyKUizwjlOknT1mB2Cw= 398 | k8s.io/cli-runtime v0.34.0/go.mod h1:t/skRecS73Piv+J+FmWIQA2N2/rDjdYSQzEE67LUUs8= 399 | k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= 400 | k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= 401 | k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8= 402 | k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg= 403 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 404 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 405 | k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= 406 | k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= 407 | k8s.io/kubectl v0.34.0 h1:NcXz4TPTaUwhiX4LU+6r6udrlm0NsVnSkP3R9t0dmxs= 408 | k8s.io/kubectl v0.34.0/go.mod h1:bmd0W5i+HuG7/p5sqicr0Li0rR2iIhXL0oUyLF3OjR4= 409 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= 410 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 411 | oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= 412 | oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= 413 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= 414 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 415 | sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= 416 | sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= 417 | sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= 418 | sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= 419 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 420 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 421 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= 422 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= 423 | sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= 424 | sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= 425 | -------------------------------------------------------------------------------- /cmd/lazyhelm/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Alessandro Pitocchi 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | "os/exec" 21 | "path/filepath" 22 | "strings" 23 | "time" 24 | 25 | "github.com/alessandropitocchi/lazyhelm/internal/artifacthub" 26 | "github.com/alessandropitocchi/lazyhelm/internal/helm" 27 | "github.com/alessandropitocchi/lazyhelm/internal/ui" 28 | "github.com/atotto/clipboard" 29 | "github.com/charmbracelet/bubbles/help" 30 | "github.com/charmbracelet/bubbles/key" 31 | "github.com/charmbracelet/bubbles/list" 32 | "github.com/charmbracelet/bubbles/textinput" 33 | "github.com/charmbracelet/bubbles/viewport" 34 | tea "github.com/charmbracelet/bubbletea" 35 | "github.com/charmbracelet/lipgloss" 36 | "github.com/sahilm/fuzzy" 37 | "gopkg.in/yaml.v3" 38 | ) 39 | 40 | var ( 41 | // Set via ldflags during build 42 | version = "dev" 43 | commit = "unknown" 44 | date = "unknown" 45 | ) 46 | 47 | var ( 48 | // Stile fzf-like con sfondi per massima leggibilità 49 | titleStyle = lipgloss.NewStyle(). 50 | Foreground(lipgloss.Color("0")). // Nero/Bianco (adaptive) 51 | Background(lipgloss.Color("105")). // Purple medio 52 | Bold(true). 53 | Padding(0, 1) 54 | 55 | panelStyle = lipgloss.NewStyle(). 56 | Border(lipgloss.RoundedBorder()). 57 | BorderForeground(lipgloss.Color("240")). // Grigio medio 58 | Padding(1, 2) 59 | 60 | activePanelStyle = lipgloss.NewStyle(). 61 | Border(lipgloss.DoubleBorder()). 62 | BorderForeground(lipgloss.Color("141")). // Violet chiaro 63 | Padding(1, 2) 64 | 65 | breadcrumbStyle = lipgloss.NewStyle(). 66 | Foreground(lipgloss.Color("0")). // Nero/Bianco 67 | Background(lipgloss.Color("73")). // Cyan/Teal 68 | Bold(true). 69 | Padding(0, 1) 70 | 71 | successStyle = lipgloss.NewStyle(). 72 | Foreground(lipgloss.Color("0")). // Nero 73 | Background(lipgloss.Color("120")). // Verde chiaro 74 | Bold(true). 75 | Padding(0, 2) 76 | 77 | errorStyle = lipgloss.NewStyle(). 78 | Foreground(lipgloss.Color("231")). // Bianco 79 | Background(lipgloss.Color("196")). // Rosso brillante 80 | Bold(true). 81 | Padding(0, 2) 82 | 83 | helpStyle = lipgloss.NewStyle(). 84 | Foreground(lipgloss.Color("244")) // Grigio medio 85 | 86 | addedStyle = lipgloss.NewStyle(). 87 | Foreground(lipgloss.Color("0")). // Nero 88 | Background(lipgloss.Color("120")). // Verde chiaro 89 | Bold(true) 90 | 91 | removedStyle = lipgloss.NewStyle(). 92 | Foreground(lipgloss.Color("231")). // Bianco 93 | Background(lipgloss.Color("160")). // Rosso medio 94 | Bold(true) 95 | 96 | modifiedStyle = lipgloss.NewStyle(). 97 | Foreground(lipgloss.Color("0")). // Nero 98 | Background(lipgloss.Color("228")). // Giallo chiaro 99 | Bold(true) 100 | 101 | infoStyle = lipgloss.NewStyle(). 102 | Foreground(lipgloss.Color("0")). // Nero 103 | Background(lipgloss.Color("141")). // Violet 104 | Bold(true). 105 | Padding(0, 2) 106 | 107 | pathStyle = lipgloss.NewStyle(). 108 | Foreground(lipgloss.Color("0")). // Nero 109 | Background(lipgloss.Color("228")). // Giallo chiaro 110 | Bold(true). 111 | Padding(0, 2) 112 | 113 | highlightStyle = lipgloss.NewStyle(). 114 | Background(lipgloss.Color("228")). // Giallo chiaro 115 | Foreground(lipgloss.Color("0")). // Nero 116 | Bold(true) 117 | 118 | searchInputStyle = lipgloss.NewStyle(). 119 | Foreground(lipgloss.Color("0")). // Nero 120 | Background(lipgloss.Color("141")). // Violet 121 | Padding(0, 1). 122 | Bold(true) 123 | ) 124 | 125 | type navigationState int 126 | 127 | const ( 128 | stateMainMenu navigationState = iota 129 | stateBrowseMenu 130 | stateRepoList 131 | stateChartList 132 | stateChartDetail 133 | stateValueViewer 134 | stateDiffViewer 135 | stateHelp 136 | stateArtifactHubSearch 137 | stateArtifactHubPackageDetail 138 | stateArtifactHubVersions 139 | stateClusterReleasesMenu 140 | stateNamespaceList 141 | stateReleaseList 142 | stateReleaseDetail 143 | stateReleaseHistory 144 | stateReleaseValues 145 | ) 146 | 147 | type inputMode int 148 | 149 | const ( 150 | normalMode inputMode = iota 151 | searchMode 152 | addRepoMode 153 | templatePathMode 154 | templateValuesMode 155 | exportValuesMode 156 | saveEditMode 157 | confirmRemoveRepoMode 158 | ) 159 | 160 | type model struct { 161 | helmClient *helm.Client 162 | cache *helm.Cache 163 | chartCache map[string]chartCacheEntry 164 | versionCache map[string]versionCacheEntry 165 | state navigationState 166 | previousState navigationState 167 | mode inputMode 168 | 169 | repos []helm.Repository 170 | charts []helm.Chart 171 | versions []helm.ChartVersion 172 | values string 173 | valuesLines []string 174 | diffLines []string // Lines for diff viewer (for search) 175 | selectedRepo int 176 | selectedChart int 177 | selectedVersion int 178 | compareVersion int 179 | 180 | // Search in values and diff 181 | searchMatches []int // Line numbers of matches 182 | currentMatchIndex int // Current match being viewed 183 | lastSearchQuery string // Last search query 184 | 185 | // Horizontal scrolling in values 186 | horizontalOffset int // Horizontal scroll offset for long lines 187 | 188 | // Artifact Hub 189 | artifactHubClient *artifacthub.Client 190 | ahPackages []artifacthub.Package 191 | ahSelectedPackage *artifacthub.Package 192 | ahPackageList list.Model 193 | ahVersionList list.Model 194 | ahSelectedPkg int 195 | ahSelectedVersion int 196 | ahLoading bool 197 | 198 | // Cluster Releases 199 | releases []helm.Release 200 | namespaces []string 201 | selectedRelease int 202 | selectedRevision int 203 | compareRevision int 204 | selectedNamespace string 205 | releaseHistory []helm.ReleaseRevision 206 | releaseValues string 207 | releaseValuesLines []string 208 | releaseStatus *helm.ReleaseStatus 209 | kubeContext string 210 | 211 | mainMenu list.Model 212 | browseMenu list.Model 213 | clusterReleasesMenu list.Model 214 | namespaceList list.Model 215 | releaseList list.Model 216 | releaseHistoryList list.Model 217 | releaseDetailView viewport.Model 218 | releaseValuesView viewport.Model 219 | repoList list.Model 220 | chartList list.Model 221 | versionList list.Model 222 | valuesView viewport.Model 223 | diffView viewport.Model 224 | searchInput textinput.Model 225 | helpView help.Model 226 | keys keyMap 227 | 228 | loading bool 229 | loadingVals bool 230 | diffMode bool 231 | successMsg string 232 | err error 233 | termWidth int 234 | termHeight int 235 | 236 | templatePath string 237 | templateValues string 238 | exportPath string 239 | newRepoName string 240 | newRepoURL string 241 | addRepoStep int 242 | editedContent string // Content from external editor 243 | editTempFile string // Temp file path for editing 244 | } 245 | 246 | type chartCacheEntry struct { 247 | charts []helm.Chart 248 | timestamp time.Time 249 | } 250 | 251 | type versionCacheEntry struct { 252 | versions []helm.ChartVersion 253 | timestamp time.Time 254 | } 255 | 256 | type keyMap struct { 257 | Up key.Binding 258 | Down key.Binding 259 | Left key.Binding 260 | Right key.Binding 261 | Enter key.Binding 262 | Back key.Binding 263 | Quit key.Binding 264 | Search key.Binding 265 | NextMatch key.Binding 266 | PrevMatch key.Binding 267 | Help key.Binding 268 | AddRepo key.Binding 269 | Export key.Binding 270 | Template key.Binding 271 | Versions key.Binding 272 | Copy key.Binding 273 | Diff key.Binding 274 | Edit key.Binding 275 | ArtifactHub key.Binding 276 | RemoveRepo key.Binding 277 | UpdateRepo key.Binding 278 | ClearFilter key.Binding 279 | } 280 | 281 | func (k keyMap) ShortHelp() []key.Binding { 282 | return []key.Binding{k.Enter, k.Back, k.Search, k.Help, k.Quit} 283 | } 284 | 285 | func (k keyMap) FullHelp() [][]key.Binding { 286 | return [][]key.Binding{ 287 | {k.Up, k.Down, k.Enter, k.Back}, 288 | {k.Search, k.AddRepo, k.Export, k.Template}, 289 | {k.Versions, k.Copy, k.Diff, k.Edit}, 290 | {k.Help, k.Quit}, 291 | } 292 | } 293 | 294 | var defaultKeys = keyMap{ 295 | Up: key.NewBinding( 296 | key.WithKeys("up", "k"), 297 | key.WithHelp("↑/k", "up"), 298 | ), 299 | Down: key.NewBinding( 300 | key.WithKeys("down", "j"), 301 | key.WithHelp("↓/j", "down"), 302 | ), 303 | Left: key.NewBinding( 304 | key.WithKeys("left"), 305 | key.WithHelp("←", "scroll left"), 306 | ), 307 | Right: key.NewBinding( 308 | key.WithKeys("right"), 309 | key.WithHelp("→", "scroll right"), 310 | ), 311 | Enter: key.NewBinding( 312 | key.WithKeys("enter"), 313 | key.WithHelp("enter", "select"), 314 | ), 315 | Back: key.NewBinding( 316 | key.WithKeys("esc"), 317 | key.WithHelp("esc", "back"), 318 | ), 319 | Quit: key.NewBinding( 320 | key.WithKeys("q", "ctrl+c"), 321 | key.WithHelp("q", "quit"), 322 | ), 323 | Search: key.NewBinding( 324 | key.WithKeys("/"), 325 | key.WithHelp("/", "search"), 326 | ), 327 | NextMatch: key.NewBinding( 328 | key.WithKeys("n"), 329 | key.WithHelp("n", "next match"), 330 | ), 331 | PrevMatch: key.NewBinding( 332 | key.WithKeys("N"), 333 | key.WithHelp("N", "prev match"), 334 | ), 335 | Help: key.NewBinding( 336 | key.WithKeys("?"), 337 | key.WithHelp("?", "help"), 338 | ), 339 | AddRepo: key.NewBinding( 340 | key.WithKeys("a"), 341 | key.WithHelp("a", "add repo"), 342 | ), 343 | Export: key.NewBinding( 344 | key.WithKeys("w"), 345 | key.WithHelp("w", "write/export values"), 346 | ), 347 | Template: key.NewBinding( 348 | key.WithKeys("t"), 349 | key.WithHelp("t", "template"), 350 | ), 351 | Versions: key.NewBinding( 352 | key.WithKeys("v"), 353 | key.WithHelp("v", "view versions"), 354 | ), 355 | Copy: key.NewBinding( 356 | key.WithKeys("y"), 357 | key.WithHelp("y", "copy yaml path"), 358 | ), 359 | Diff: key.NewBinding( 360 | key.WithKeys("d"), 361 | key.WithHelp("d", "diff versions"), 362 | ), 363 | Edit: key.NewBinding( 364 | key.WithKeys("e"), 365 | key.WithHelp("e", "edit in $EDITOR"), 366 | ), 367 | ArtifactHub: key.NewBinding( 368 | key.WithKeys("s"), 369 | key.WithHelp("s", "search artifact hub"), 370 | ), 371 | RemoveRepo: key.NewBinding( 372 | key.WithKeys("r"), 373 | key.WithHelp("r", "remove repository"), 374 | ), 375 | UpdateRepo: key.NewBinding( 376 | key.WithKeys("u"), 377 | key.WithHelp("u", "update repository"), 378 | ), 379 | ClearFilter: key.NewBinding( 380 | key.WithKeys("c"), 381 | key.WithHelp("c", "clear filter"), 382 | ), 383 | } 384 | 385 | type chartsLoadedMsg struct { 386 | charts []helm.Chart 387 | err error 388 | } 389 | 390 | type valuesLoadedMsg struct { 391 | values string 392 | err error 393 | } 394 | 395 | type versionsLoadedMsg struct { 396 | versions []helm.ChartVersion 397 | err error 398 | } 399 | 400 | type operationDoneMsg struct { 401 | success string 402 | err error 403 | } 404 | 405 | type reposReloadedMsg struct { 406 | repos []helm.Repository 407 | err error 408 | } 409 | 410 | type repoRemovedMsg struct { 411 | repos []helm.Repository 412 | repoName string 413 | err error 414 | } 415 | 416 | type editorFinishedMsg struct { 417 | content string 418 | filePath string 419 | err error 420 | } 421 | 422 | type releasesLoadedMsg struct { 423 | releases []helm.Release 424 | err error 425 | } 426 | 427 | type namespacesLoadedMsg struct { 428 | namespaces []string 429 | err error 430 | } 431 | 432 | type releaseHistoryLoadedMsg struct { 433 | history []helm.ReleaseRevision 434 | err error 435 | } 436 | 437 | type releaseValuesLoadedMsg struct { 438 | values string 439 | err error 440 | } 441 | 442 | type releaseStatusLoadedMsg struct { 443 | status *helm.ReleaseStatus 444 | err error 445 | } 446 | 447 | type kubeContextLoadedMsg struct { 448 | context string 449 | err error 450 | } 451 | 452 | type artifactHubSearchMsg struct { 453 | packages []artifacthub.Package 454 | err error 455 | } 456 | 457 | type artifactHubPackageMsg struct { 458 | pkg *artifacthub.Package 459 | err error 460 | } 461 | 462 | type clearSuccessMsgMsg struct{} 463 | 464 | type listItem struct { 465 | title string 466 | description string 467 | } 468 | 469 | func (i listItem) Title() string { return i.title } 470 | func (i listItem) Description() string { return i.description } 471 | func (i listItem) FilterValue() string { return i.title } 472 | 473 | func loadCharts(client *helm.Client, chartCache map[string]chartCacheEntry, repoName string) tea.Cmd { 474 | return func() tea.Msg { 475 | // Check cache first (30 minute TTL) 476 | if entry, exists := chartCache[repoName]; exists { 477 | if time.Since(entry.timestamp) < 30*time.Minute { 478 | return chartsLoadedMsg{charts: entry.charts, err: nil} 479 | } 480 | } 481 | 482 | charts, err := client.SearchCharts(repoName) 483 | if err == nil && len(charts) > 0 { 484 | chartCache[repoName] = chartCacheEntry{ 485 | charts: charts, 486 | timestamp: time.Now(), 487 | } 488 | } 489 | return chartsLoadedMsg{charts: charts, err: err} 490 | } 491 | } 492 | 493 | func loadValues(client *helm.Client, cache *helm.Cache, chartName string) tea.Cmd { 494 | return func() tea.Msg { 495 | if cached, found := cache.Get(chartName, ""); found { 496 | return valuesLoadedMsg{values: cached, err: nil} 497 | } 498 | 499 | values, err := client.GetChartValues(chartName) 500 | if err == nil { 501 | cache.Set(chartName, "", values) 502 | } 503 | return valuesLoadedMsg{values: values, err: err} 504 | } 505 | } 506 | 507 | func loadValuesByVersion(client *helm.Client, cache *helm.Cache, chartName, version string) tea.Cmd { 508 | return func() tea.Msg { 509 | if cached, found := cache.Get(chartName, version); found { 510 | return valuesLoadedMsg{values: cached, err: nil} 511 | } 512 | 513 | values, err := client.GetChartValuesByVersion(chartName, version) 514 | if err == nil { 515 | cache.Set(chartName, version, values) 516 | } 517 | return valuesLoadedMsg{values: values, err: err} 518 | } 519 | } 520 | 521 | func loadVersions(client *helm.Client, versionCache map[string]versionCacheEntry, chartName string) tea.Cmd { 522 | return func() tea.Msg { 523 | // Check cache first (30 minute TTL) 524 | if entry, exists := versionCache[chartName]; exists { 525 | if time.Since(entry.timestamp) < 30*time.Minute { 526 | return versionsLoadedMsg{versions: entry.versions, err: nil} 527 | } 528 | } 529 | 530 | versions, err := client.GetChartVersions(chartName) 531 | if err == nil && len(versions) > 0 { 532 | versionCache[chartName] = versionCacheEntry{ 533 | versions: versions, 534 | timestamp: time.Now(), 535 | } 536 | } 537 | return versionsLoadedMsg{versions: versions, err: err} 538 | } 539 | } 540 | 541 | func loadReleases(client *helm.Client, namespace string) tea.Cmd { 542 | return func() tea.Msg { 543 | releases, err := client.ListReleases(namespace) 544 | return releasesLoadedMsg{releases: releases, err: err} 545 | } 546 | } 547 | 548 | func loadNamespaces(client *helm.Client) tea.Cmd { 549 | return func() tea.Msg { 550 | namespaces, err := client.ListNamespaces() 551 | return namespacesLoadedMsg{namespaces: namespaces, err: err} 552 | } 553 | } 554 | 555 | func loadReleaseHistory(client *helm.Client, releaseName, namespace string) tea.Cmd { 556 | return func() tea.Msg { 557 | history, err := client.GetReleaseHistory(releaseName, namespace) 558 | return releaseHistoryLoadedMsg{history: history, err: err} 559 | } 560 | } 561 | 562 | func loadReleaseValues(client *helm.Client, releaseName, namespace string) tea.Cmd { 563 | return func() tea.Msg { 564 | values, err := client.GetReleaseValues(releaseName, namespace) 565 | return releaseValuesLoadedMsg{values: values, err: err} 566 | } 567 | } 568 | 569 | func loadReleaseStatus(client *helm.Client, releaseName, namespace string) tea.Cmd { 570 | return func() tea.Msg { 571 | status, err := client.GetReleaseStatus(releaseName, namespace) 572 | return releaseStatusLoadedMsg{status: status, err: err} 573 | } 574 | } 575 | 576 | func addRepository(client *helm.Client, name, url string) tea.Cmd { 577 | return func() tea.Msg { 578 | err := client.AddRepository(name, url) 579 | if err != nil { 580 | return operationDoneMsg{err: err} 581 | } 582 | 583 | repos, repoErr := client.ListRepositories() 584 | if repoErr != nil { 585 | return operationDoneMsg{success: fmt.Sprintf("Repository '%s' added, but failed to reload list", name)} 586 | } 587 | 588 | return reposReloadedMsg{repos: repos} 589 | } 590 | } 591 | 592 | func exportValues(client *helm.Client, chartName, outputFile string) tea.Cmd { 593 | return func() tea.Msg { 594 | err := client.ExportValues(chartName, outputFile) 595 | if err != nil { 596 | return operationDoneMsg{err: err} 597 | } 598 | return operationDoneMsg{success: fmt.Sprintf("Values exported to %s", outputFile)} 599 | } 600 | } 601 | 602 | func generateTemplate(client *helm.Client, chartName, valuesFile, outputPath string) tea.Cmd { 603 | return func() tea.Msg { 604 | err := client.GenerateTemplate(chartName, valuesFile, outputPath) 605 | if err != nil { 606 | return operationDoneMsg{err: err} 607 | } 608 | return operationDoneMsg{success: fmt.Sprintf("Template generated in %s", outputPath)} 609 | } 610 | } 611 | 612 | func searchArtifactHub(client *artifacthub.Client, query string) tea.Cmd { 613 | return func() tea.Msg { 614 | packages, err := client.SearchPackages(query, 50) 615 | if err != nil { 616 | return artifactHubSearchMsg{err: err} 617 | } 618 | return artifactHubSearchMsg{packages: packages} 619 | } 620 | } 621 | 622 | func loadArtifactHubPackage(client *artifacthub.Client, repoName, packageName string) tea.Cmd { 623 | return func() tea.Msg { 624 | pkg, err := client.GetPackageDetails(repoName, packageName) 625 | if err != nil { 626 | return artifactHubPackageMsg{err: err} 627 | } 628 | return artifactHubPackageMsg{pkg: pkg} 629 | } 630 | } 631 | 632 | func clearSuccessMsgAfter(d time.Duration) tea.Cmd { 633 | return tea.Tick(d, func(t time.Time) tea.Msg { 634 | return clearSuccessMsgMsg{} 635 | }) 636 | } 637 | 638 | // Helper to set success message and auto-clear after 3 seconds 639 | func (m *model) setSuccessMsg(msg string) tea.Cmd { 640 | m.successMsg = msg 641 | return clearSuccessMsgAfter(3 * time.Second) 642 | } 643 | 644 | func initialModel() model { 645 | client := helm.NewClient() 646 | cache := helm.NewCache(30 * time.Minute) 647 | repos, err := client.ListRepositories() 648 | 649 | repoItems := make([]list.Item, len(repos)) 650 | for i, repo := range repos { 651 | repoItems[i] = listItem{ 652 | title: repo.Name, 653 | description: repo.URL, 654 | } 655 | } 656 | 657 | // Create custom delegate with fzf-like colors (background for selected items) 658 | delegate := list.NewDefaultDelegate() 659 | delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle. 660 | Foreground(lipgloss.Color("0")). // Nero/Bianco (adaptive) 661 | Background(lipgloss.Color("141")). // Violet - stile fzf 662 | Bold(true) 663 | delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc. 664 | Foreground(lipgloss.Color("0")). // Nero/Bianco 665 | Background(lipgloss.Color("141")) // Violet 666 | delegate.Styles.NormalTitle = delegate.Styles.NormalTitle. 667 | Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "255"}) // Grigio scuro su chiaro, bianco su scuro 668 | delegate.Styles.NormalDesc = delegate.Styles.NormalDesc. 669 | Foreground(lipgloss.AdaptiveColor{Light: "240", Dark: "250"}) // Grigio medio 670 | 671 | repoList := list.New(repoItems, delegate, 0, 0) 672 | repoList.Title = "Repositories" 673 | repoList.SetShowStatusBar(false) 674 | repoList.SetFilteringEnabled(true) 675 | repoList.Styles.Title = titleStyle 676 | repoList.Styles.FilterPrompt = searchInputStyle 677 | repoList.Styles.FilterCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) 678 | 679 | chartDelegate := list.NewDefaultDelegate() 680 | chartDelegate.Styles = delegate.Styles 681 | chartList := list.New([]list.Item{}, chartDelegate, 0, 0) 682 | chartList.Title = "Charts" 683 | chartList.SetShowStatusBar(false) 684 | chartList.SetFilteringEnabled(true) 685 | chartList.Styles.Title = titleStyle 686 | chartList.Styles.FilterPrompt = searchInputStyle 687 | chartList.Styles.FilterCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) 688 | 689 | versionDelegate := list.NewDefaultDelegate() 690 | versionDelegate.Styles = delegate.Styles 691 | versionList := list.New([]list.Item{}, versionDelegate, 0, 0) 692 | versionList.Title = "Versions" 693 | versionList.SetShowStatusBar(false) 694 | versionList.SetFilteringEnabled(true) 695 | versionList.Styles.Title = titleStyle 696 | versionList.Styles.FilterPrompt = searchInputStyle 697 | versionList.Styles.FilterCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) 698 | 699 | valuesView := viewport.New(0, 0) 700 | diffView := viewport.New(0, 0) 701 | 702 | searchInput := textinput.New() 703 | searchInput.Placeholder = "Search..." 704 | 705 | helpView := help.New() 706 | 707 | // Artifact Hub lists 708 | ahPackageDelegate := list.NewDefaultDelegate() 709 | ahPackageDelegate.Styles = delegate.Styles 710 | ahPackageList := list.New([]list.Item{}, ahPackageDelegate, 0, 0) 711 | ahPackageList.Title = "Artifact Hub" 712 | ahPackageList.SetShowStatusBar(false) 713 | ahPackageList.SetFilteringEnabled(true) 714 | ahPackageList.Styles.Title = titleStyle 715 | ahPackageList.Styles.FilterPrompt = searchInputStyle 716 | ahPackageList.Styles.FilterCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) 717 | 718 | ahVersionDelegate := list.NewDefaultDelegate() 719 | ahVersionDelegate.Styles = delegate.Styles 720 | ahVersionList := list.New([]list.Item{}, ahVersionDelegate, 0, 0) 721 | ahVersionList.Title = "Versions" 722 | ahVersionList.SetShowStatusBar(false) 723 | ahVersionList.SetFilteringEnabled(true) 724 | ahVersionList.Styles.Title = titleStyle 725 | ahVersionList.Styles.FilterPrompt = searchInputStyle 726 | ahVersionList.Styles.FilterCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) 727 | 728 | // Main Menu 729 | menuItems := []list.Item{ 730 | listItem{title: "Browse Repositories", description: "Browse Helm repositories and charts"}, 731 | listItem{title: "Cluster Releases", description: "View deployed Helm releases"}, 732 | listItem{title: "Settings", description: "Configure LazyHelm settings (Coming Soon)"}, 733 | } 734 | mainMenuDelegate := list.NewDefaultDelegate() 735 | mainMenuDelegate.Styles = delegate.Styles 736 | mainMenu := list.New(menuItems, mainMenuDelegate, 0, 0) 737 | mainMenu.Title = "LazyHelm" 738 | mainMenu.SetShowStatusBar(false) 739 | mainMenu.SetFilteringEnabled(false) 740 | mainMenu.Styles.Title = titleStyle 741 | 742 | // Browse Menu (submenu for Browse Repositories) 743 | browseMenuItems := []list.Item{ 744 | listItem{title: "Local Repositories", description: "Browse your configured Helm repositories"}, 745 | listItem{title: "Search Artifact Hub", description: "Search charts on Artifact Hub"}, 746 | } 747 | browseMenuDelegate := list.NewDefaultDelegate() 748 | browseMenuDelegate.Styles = delegate.Styles 749 | browseMenu := list.New(browseMenuItems, browseMenuDelegate, 0, 0) 750 | browseMenu.Title = "Browse Repositories" 751 | browseMenu.SetShowStatusBar(false) 752 | browseMenu.SetFilteringEnabled(false) 753 | browseMenu.Styles.Title = titleStyle 754 | 755 | // Cluster Releases Menu 756 | clusterReleasesMenuItems := []list.Item{ 757 | listItem{title: "All Namespaces", description: "View releases from all namespaces"}, 758 | listItem{title: "Select Namespace", description: "Choose a specific namespace"}, 759 | } 760 | clusterReleasesMenuDelegate := list.NewDefaultDelegate() 761 | clusterReleasesMenuDelegate.Styles = delegate.Styles 762 | clusterReleasesMenu := list.New(clusterReleasesMenuItems, clusterReleasesMenuDelegate, 0, 0) 763 | clusterReleasesMenu.Title = "Cluster Releases" 764 | clusterReleasesMenu.SetShowStatusBar(false) 765 | clusterReleasesMenu.SetFilteringEnabled(false) 766 | clusterReleasesMenu.Styles.Title = titleStyle 767 | 768 | // Namespace List 769 | namespaceDelegate := list.NewDefaultDelegate() 770 | namespaceDelegate.Styles = delegate.Styles 771 | namespaceList := list.New([]list.Item{}, namespaceDelegate, 0, 0) 772 | namespaceList.Title = "Namespaces" 773 | namespaceList.SetShowStatusBar(false) 774 | namespaceList.SetFilteringEnabled(true) 775 | namespaceList.Styles.Title = titleStyle 776 | namespaceList.Styles.FilterPrompt = searchInputStyle 777 | namespaceList.Styles.FilterCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) 778 | 779 | // Release List 780 | releaseDelegate := list.NewDefaultDelegate() 781 | releaseDelegate.Styles = delegate.Styles 782 | releaseList := list.New([]list.Item{}, releaseDelegate, 0, 0) 783 | releaseList.Title = "Releases" 784 | releaseList.SetShowStatusBar(false) 785 | releaseList.SetFilteringEnabled(true) 786 | releaseList.Styles.Title = titleStyle 787 | releaseList.Styles.FilterPrompt = searchInputStyle 788 | releaseList.Styles.FilterCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) 789 | 790 | // Release History List 791 | releaseHistoryDelegate := list.NewDefaultDelegate() 792 | releaseHistoryDelegate.Styles = delegate.Styles 793 | releaseHistoryList := list.New([]list.Item{}, releaseHistoryDelegate, 0, 0) 794 | releaseHistoryList.Title = "Release History" 795 | releaseHistoryList.SetShowStatusBar(false) 796 | releaseHistoryList.SetFilteringEnabled(false) 797 | releaseHistoryList.Styles.Title = titleStyle 798 | 799 | // Release Detail View 800 | releaseDetailView := viewport.New(0, 0) 801 | 802 | // Release Values View 803 | releaseValuesView := viewport.New(0, 0) 804 | 805 | return model{ 806 | helmClient: client, 807 | cache: cache, 808 | chartCache: make(map[string]chartCacheEntry), 809 | versionCache: make(map[string]versionCacheEntry), 810 | state: stateMainMenu, 811 | mode: normalMode, 812 | repos: repos, 813 | compareRevision: -1, 814 | artifactHubClient: artifacthub.NewClient(), 815 | ahPackageList: ahPackageList, 816 | ahVersionList: ahVersionList, 817 | mainMenu: mainMenu, 818 | browseMenu: browseMenu, 819 | clusterReleasesMenu: clusterReleasesMenu, 820 | namespaceList: namespaceList, 821 | releaseList: releaseList, 822 | releaseHistoryList: releaseHistoryList, 823 | releaseDetailView: releaseDetailView, 824 | releaseValuesView: releaseValuesView, 825 | repoList: repoList, 826 | chartList: chartList, 827 | versionList: versionList, 828 | valuesView: valuesView, 829 | diffView: diffView, 830 | searchInput: searchInput, 831 | helpView: helpView, 832 | keys: defaultKeys, 833 | err: err, 834 | } 835 | } 836 | 837 | func (m model) Init() tea.Cmd { 838 | return nil 839 | } 840 | 841 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 842 | var cmd tea.Cmd 843 | var cmds []tea.Cmd 844 | 845 | switch msg := msg.(type) { 846 | case tea.WindowSizeMsg: 847 | m.termWidth = msg.Width 848 | m.termHeight = msg.Height 849 | 850 | h := msg.Height - 10 851 | w := msg.Width - 4 852 | 853 | m.mainMenu.SetSize(w/2, h) 854 | m.browseMenu.SetSize(w/2, h) 855 | m.repoList.SetSize(w/3, h) 856 | m.chartList.SetSize(w/2, h) 857 | m.versionList.SetSize(w/3, h) 858 | 859 | // Artifact Hub lists 860 | m.ahPackageList.SetSize(w-4, h) 861 | m.ahVersionList.SetSize(w/3, h) 862 | 863 | // Cluster Releases lists 864 | m.clusterReleasesMenu.SetSize(w/2, h) 865 | m.namespaceList.SetSize(w/3, h) 866 | m.releaseList.SetSize(w-4, h) 867 | m.releaseHistoryList.SetSize(w/3, h) 868 | 869 | // Values view takes full screen 870 | m.valuesView.Width = msg.Width - 6 // Full width minus border padding 871 | m.valuesView.Height = msg.Height - 8 // Full height minus header/footer 872 | 873 | m.diffView.Width = msg.Width - 6 874 | m.diffView.Height = msg.Height - 8 875 | 876 | m.releaseDetailView.Width = msg.Width - 6 877 | m.releaseDetailView.Height = msg.Height - 8 878 | 879 | m.releaseValuesView.Width = msg.Width - 6 880 | m.releaseValuesView.Height = msg.Height - 8 881 | 882 | return m, nil 883 | 884 | case tea.KeyMsg: 885 | if m.state == stateHelp { 886 | if msg.String() == "?" || msg.String() == "esc" || msg.String() == "q" { 887 | m.state = m.previousState 888 | } 889 | return m, nil 890 | } 891 | 892 | if m.mode != normalMode { 893 | return m.handleInputMode(msg) 894 | } 895 | 896 | switch { 897 | case key.Matches(msg, m.keys.Quit): 898 | return m, tea.Quit 899 | 900 | case key.Matches(msg, m.keys.Help): 901 | m.previousState = m.state 902 | m.state = stateHelp 903 | return m, nil 904 | 905 | case key.Matches(msg, m.keys.Back): 906 | return m.handleBack() 907 | 908 | case key.Matches(msg, m.keys.Enter): 909 | return m.handleEnter() 910 | 911 | case key.Matches(msg, m.keys.Search): 912 | return m.handleSearch() 913 | 914 | case key.Matches(msg, m.keys.AddRepo): 915 | if m.state == stateRepoList { 916 | m.mode = addRepoMode 917 | m.addRepoStep = 0 918 | m.searchInput.Reset() 919 | m.searchInput.Placeholder = "Repository name..." 920 | m.searchInput.Focus() 921 | } 922 | if (m.state == stateArtifactHubPackageDetail || m.state == stateArtifactHubVersions) && m.ahSelectedPackage != nil { 923 | // Add repo from Artifact Hub - only ask for name, URL is auto-filled 924 | m.mode = addRepoMode 925 | m.addRepoStep = 0 926 | m.newRepoURL = m.ahSelectedPackage.Repository.URL // Pre-fill URL 927 | m.searchInput.Reset() 928 | m.searchInput.Placeholder = fmt.Sprintf("Repository name (default: %s)...", m.ahSelectedPackage.Repository.Name) 929 | m.searchInput.Focus() 930 | } 931 | return m, nil 932 | 933 | case key.Matches(msg, m.keys.RemoveRepo): 934 | if m.state == stateRepoList && len(m.repos) > 0 { 935 | // Enter confirmation mode - use selected item to handle filtered lists 936 | selectedItem := m.repoList.SelectedItem() 937 | if selectedItem != nil { 938 | item := selectedItem.(listItem) 939 | m.mode = confirmRemoveRepoMode 940 | m.searchInput.Reset() 941 | m.searchInput.Placeholder = fmt.Sprintf("Remove '%s'? (y/n)", item.title) 942 | m.searchInput.Focus() 943 | } 944 | } 945 | return m, nil 946 | 947 | case key.Matches(msg, m.keys.UpdateRepo): 948 | if m.state == stateRepoList && len(m.repos) > 0 { 949 | selectedItem := m.repoList.SelectedItem() 950 | if selectedItem != nil { 951 | item := selectedItem.(listItem) 952 | repoName := item.title 953 | return m, func() tea.Msg { 954 | err := m.helmClient.UpdateRepository(repoName) 955 | if err != nil { 956 | return operationDoneMsg{err: err} 957 | } 958 | return operationDoneMsg{success: fmt.Sprintf("Repository '%s' updated successfully", repoName)} 959 | } 960 | } 961 | } 962 | return m, nil 963 | 964 | case key.Matches(msg, m.keys.Export): 965 | if m.state == stateChartDetail || m.state == stateValueViewer || m.state == stateReleaseValues { 966 | m.mode = exportValuesMode 967 | m.searchInput.Reset() 968 | m.searchInput.Placeholder = "./values.yaml" 969 | m.searchInput.Focus() 970 | } 971 | return m, nil 972 | 973 | case key.Matches(msg, m.keys.Template): 974 | if m.state == stateChartDetail || m.state == stateValueViewer { 975 | m.mode = templatePathMode 976 | m.searchInput.Reset() 977 | m.searchInput.Placeholder = "./output/" 978 | m.searchInput.Focus() 979 | } 980 | return m, nil 981 | 982 | case key.Matches(msg, m.keys.ArtifactHub): 983 | if m.state == stateRepoList { 984 | m.mode = searchMode 985 | m.searchInput.Reset() 986 | m.searchInput.Placeholder = "Search Artifact Hub..." 987 | m.searchInput.Focus() 988 | m.state = stateArtifactHubSearch 989 | } 990 | return m, nil 991 | 992 | case key.Matches(msg, m.keys.ClearFilter): 993 | // Clear filters and restore full lists 994 | var clearCmd tea.Cmd 995 | switch m.state { 996 | case stateRepoList: 997 | items := make([]list.Item, len(m.repos)) 998 | for i, repo := range m.repos { 999 | items[i] = listItem{ 1000 | title: repo.Name, 1001 | description: repo.URL, 1002 | } 1003 | } 1004 | m.repoList.SetItems(items) 1005 | clearCmd = m.setSuccessMsg("Filter cleared") 1006 | 1007 | case stateChartList: 1008 | items := make([]list.Item, len(m.charts)) 1009 | for i, chart := range m.charts { 1010 | name := chart.Name 1011 | if m.selectedRepo < len(m.repos) { 1012 | name = strings.TrimPrefix(name, m.repos[m.selectedRepo].Name+"/") 1013 | } 1014 | items[i] = listItem{ 1015 | title: name, 1016 | description: chart.Description, 1017 | } 1018 | } 1019 | m.chartList.SetItems(items) 1020 | clearCmd = m.setSuccessMsg("Filter cleared") 1021 | 1022 | case stateChartDetail: 1023 | items := make([]list.Item, len(m.versions)) 1024 | for i, ver := range m.versions { 1025 | desc := "" 1026 | if ver.AppVersion != "" { 1027 | desc = "App: " + ver.AppVersion 1028 | } 1029 | items[i] = listItem{ 1030 | title: "v" + ver.Version, 1031 | description: desc, 1032 | } 1033 | } 1034 | m.versionList.SetItems(items) 1035 | clearCmd = m.setSuccessMsg("Filter cleared") 1036 | 1037 | case stateArtifactHubSearch: 1038 | items := make([]list.Item, len(m.ahPackages)) 1039 | for i, pkg := range m.ahPackages { 1040 | badges := pkg.GetBadges() 1041 | stars := fmt.Sprintf("⭐%d", pkg.Stars) 1042 | security := pkg.SecurityReport.GetSecurityBadge() 1043 | 1044 | desc := fmt.Sprintf("%s | %s %s | %s", pkg.Repository.DisplayName, stars, badges, security) 1045 | items[i] = listItem{ 1046 | title: pkg.Name, 1047 | description: desc, 1048 | } 1049 | } 1050 | m.ahPackageList.SetItems(items) 1051 | clearCmd = m.setSuccessMsg("Filter cleared") 1052 | 1053 | case stateReleaseList: 1054 | items := make([]list.Item, len(m.releases)) 1055 | for i, release := range m.releases { 1056 | desc := fmt.Sprintf("%s | %s | %s", release.Namespace, release.Chart, release.Status) 1057 | items[i] = listItem{ 1058 | title: release.Name, 1059 | description: desc, 1060 | } 1061 | } 1062 | m.releaseList.SetItems(items) 1063 | clearCmd = m.setSuccessMsg("Filter cleared") 1064 | } 1065 | return m, clearCmd 1066 | 1067 | case key.Matches(msg, m.keys.Versions): 1068 | if m.state == stateChartList && len(m.charts) > 0 { 1069 | m.state = stateChartDetail 1070 | m.loading = true 1071 | idx := m.chartList.Index() 1072 | if idx < len(m.charts) { 1073 | return m, loadVersions(m.helmClient, m.versionCache, m.charts[idx].Name) 1074 | } 1075 | } 1076 | if m.state == stateArtifactHubPackageDetail && m.ahSelectedPackage != nil { 1077 | m.state = stateArtifactHubVersions 1078 | } 1079 | if m.state == stateReleaseDetail && m.selectedRelease < len(m.releases) { 1080 | release := m.releases[m.selectedRelease] 1081 | m.state = stateReleaseValues 1082 | m.loadingVals = true 1083 | return m, loadReleaseValues(m.helmClient, release.Name, release.Namespace) 1084 | } 1085 | return m, nil 1086 | 1087 | case key.Matches(msg, m.keys.Copy): 1088 | if m.state == stateValueViewer && len(m.valuesLines) > 0 { 1089 | var lineNum int 1090 | // If we have search matches, use the current match line 1091 | if len(m.searchMatches) > 0 && m.currentMatchIndex < len(m.searchMatches) { 1092 | lineNum = m.searchMatches[m.currentMatchIndex] 1093 | } else { 1094 | // Otherwise use the current viewport position (center of visible area) 1095 | lineNum = m.valuesView.YOffset + m.valuesView.Height/2 1096 | if lineNum >= len(m.valuesLines) { 1097 | lineNum = len(m.valuesLines) - 1 1098 | } 1099 | } 1100 | 1101 | yamlPath := ui.GetYAMLPath(m.valuesLines, lineNum) 1102 | var copyCmd tea.Cmd 1103 | if yamlPath != "" { 1104 | err := clipboard.WriteAll(yamlPath) 1105 | if err != nil { 1106 | copyCmd = m.setSuccessMsg("Failed to copy to clipboard") 1107 | } else { 1108 | copyCmd = m.setSuccessMsg("Copied: " + yamlPath) 1109 | } 1110 | } else { 1111 | copyCmd = m.setSuccessMsg("No YAML path found for current line") 1112 | } 1113 | return m, copyCmd 1114 | } 1115 | if m.state == stateReleaseValues && len(m.releaseValuesLines) > 0 { 1116 | var lineNum int 1117 | // If we have search matches, use the current match line 1118 | if len(m.searchMatches) > 0 && m.currentMatchIndex < len(m.searchMatches) { 1119 | lineNum = m.searchMatches[m.currentMatchIndex] 1120 | } else { 1121 | // Otherwise use the current viewport position (center of visible area) 1122 | lineNum = m.releaseValuesView.YOffset + m.releaseValuesView.Height/2 1123 | if lineNum >= len(m.releaseValuesLines) { 1124 | lineNum = len(m.releaseValuesLines) - 1 1125 | } 1126 | } 1127 | 1128 | yamlPath := ui.GetYAMLPath(m.releaseValuesLines, lineNum) 1129 | var copyCmd tea.Cmd 1130 | if yamlPath != "" { 1131 | err := clipboard.WriteAll(yamlPath) 1132 | if err != nil { 1133 | copyCmd = m.setSuccessMsg("Failed to copy to clipboard") 1134 | } else { 1135 | copyCmd = m.setSuccessMsg("Copied: " + yamlPath) 1136 | } 1137 | } else { 1138 | copyCmd = m.setSuccessMsg("No YAML path found for current line") 1139 | } 1140 | return m, copyCmd 1141 | } 1142 | return m, nil 1143 | 1144 | case key.Matches(msg, m.keys.Diff): 1145 | if m.state == stateChartDetail && len(m.versions) > 1 { 1146 | m.diffMode = true 1147 | m.compareVersion = m.versionList.Index() 1148 | } else if m.state == stateReleaseHistory && len(m.releaseHistory) > 1 { 1149 | m.diffMode = true 1150 | m.compareRevision = m.releaseHistoryList.Index() 1151 | } 1152 | return m, nil 1153 | 1154 | case key.Matches(msg, m.keys.Edit): 1155 | if m.state == stateValueViewer { 1156 | if m.values == "" { 1157 | return m, m.setSuccessMsg("No values to edit") 1158 | } 1159 | // Show which editor will be used 1160 | editor := os.Getenv("EDITOR") 1161 | if editor == "" { 1162 | editor = os.Getenv("VISUAL") 1163 | } 1164 | if editor == "" { 1165 | // Check which editor will be found 1166 | for _, cmd := range []string{"nvim", "vim", "vi"} { 1167 | if _, err := exec.LookPath(cmd); err == nil { 1168 | editor = cmd 1169 | break 1170 | } 1171 | } 1172 | } 1173 | editorCmd := m.setSuccessMsg(fmt.Sprintf("Opening %s...", editor)) 1174 | return m, tea.Batch(editorCmd, openEditorCmd(m.values)) 1175 | } 1176 | return m, nil 1177 | 1178 | case key.Matches(msg, m.keys.NextMatch): 1179 | if (m.state == stateValueViewer || m.state == stateDiffViewer) && len(m.searchMatches) > 0 { 1180 | m.currentMatchIndex = (m.currentMatchIndex + 1) % len(m.searchMatches) 1181 | if m.state == stateValueViewer { 1182 | m.updateValuesViewWithSearch() 1183 | } else if m.state == stateDiffViewer { 1184 | m.updateDiffViewWithSearch() 1185 | } 1186 | return m.jumpToMatch(), nil 1187 | } 1188 | return m, nil 1189 | 1190 | case key.Matches(msg, m.keys.PrevMatch): 1191 | if (m.state == stateValueViewer || m.state == stateDiffViewer) && len(m.searchMatches) > 0 { 1192 | m.currentMatchIndex = (m.currentMatchIndex - 1 + len(m.searchMatches)) % len(m.searchMatches) 1193 | if m.state == stateValueViewer { 1194 | m.updateValuesViewWithSearch() 1195 | } else if m.state == stateDiffViewer { 1196 | m.updateDiffViewWithSearch() 1197 | } 1198 | return m.jumpToMatch(), nil 1199 | } 1200 | return m, nil 1201 | 1202 | case msg.String() == "h": 1203 | // 'h' for history in release detail view 1204 | if m.state == stateReleaseDetail && m.selectedRelease < len(m.releases) { 1205 | m.state = stateReleaseHistory 1206 | return m, nil 1207 | } 1208 | return m, nil 1209 | 1210 | case key.Matches(msg, m.keys.Left): 1211 | // Horizontal scroll left (arrow key only) 1212 | if m.state == stateValueViewer { 1213 | if m.horizontalOffset > 0 { 1214 | m.horizontalOffset -= 5 1215 | if m.horizontalOffset < 0 { 1216 | m.horizontalOffset = 0 1217 | } 1218 | m.updateValuesViewWithSearch() 1219 | } 1220 | } else if m.state == stateReleaseValues { 1221 | if m.horizontalOffset > 0 { 1222 | m.horizontalOffset -= 5 1223 | if m.horizontalOffset < 0 { 1224 | m.horizontalOffset = 0 1225 | } 1226 | m.updateReleaseValuesViewWithSearch() 1227 | } 1228 | } else if m.state == stateReleaseDetail { 1229 | if m.horizontalOffset > 0 { 1230 | m.horizontalOffset -= 5 1231 | if m.horizontalOffset < 0 { 1232 | m.horizontalOffset = 0 1233 | } 1234 | m.updateReleaseDetailView() 1235 | } 1236 | } 1237 | return m, nil 1238 | 1239 | case key.Matches(msg, m.keys.Right): 1240 | // Horizontal scroll right (arrow key only) 1241 | if m.state == stateValueViewer { 1242 | m.horizontalOffset += 5 1243 | m.updateValuesViewWithSearch() 1244 | } else if m.state == stateReleaseValues { 1245 | m.horizontalOffset += 5 1246 | m.updateReleaseValuesViewWithSearch() 1247 | } else if m.state == stateReleaseDetail { 1248 | m.horizontalOffset += 5 1249 | m.updateReleaseDetailView() 1250 | } 1251 | return m, nil 1252 | } 1253 | 1254 | case chartsLoadedMsg: 1255 | m.loading = false 1256 | if msg.err != nil { 1257 | m.err = msg.err 1258 | return m, nil 1259 | } 1260 | 1261 | m.charts = msg.charts 1262 | items := make([]list.Item, len(msg.charts)) 1263 | for i, chart := range msg.charts { 1264 | name := chart.Name 1265 | if m.selectedRepo < len(m.repos) { 1266 | name = strings.TrimPrefix(name, m.repos[m.selectedRepo].Name+"/") 1267 | } 1268 | items[i] = listItem{ 1269 | title: name, 1270 | description: chart.Description, 1271 | } 1272 | } 1273 | m.chartList.SetItems(items) 1274 | return m, nil 1275 | 1276 | case versionsLoadedMsg: 1277 | m.loading = false 1278 | if msg.err != nil { 1279 | m.err = msg.err 1280 | return m, nil 1281 | } 1282 | 1283 | m.versions = msg.versions 1284 | items := make([]list.Item, len(msg.versions)) 1285 | for i, ver := range msg.versions { 1286 | desc := "" 1287 | if ver.AppVersion != "" { 1288 | desc = "App: " + ver.AppVersion 1289 | } 1290 | items[i] = listItem{ 1291 | title: "v" + ver.Version, 1292 | description: desc, 1293 | } 1294 | } 1295 | m.versionList.SetItems(items) 1296 | return m, nil 1297 | 1298 | case valuesLoadedMsg: 1299 | m.loadingVals = false 1300 | if msg.err != nil { 1301 | m.err = msg.err 1302 | return m, nil 1303 | } 1304 | 1305 | m.values = msg.values 1306 | m.valuesLines = strings.Split(msg.values, "\n") 1307 | highlighted := ui.HighlightYAMLContent(msg.values) 1308 | m.valuesView.SetContent(highlighted) 1309 | m.updateValuesViewWithSearch() 1310 | return m, nil 1311 | 1312 | case operationDoneMsg: 1313 | if msg.err != nil { 1314 | m.err = msg.err 1315 | return m, nil 1316 | } else { 1317 | return m, m.setSuccessMsg(msg.success) 1318 | } 1319 | 1320 | 1321 | case reposReloadedMsg: 1322 | if msg.err == nil { 1323 | m.repos = msg.repos 1324 | items := make([]list.Item, len(msg.repos)) 1325 | for i, repo := range msg.repos { 1326 | items[i] = listItem{ 1327 | title: repo.Name, 1328 | description: repo.URL, 1329 | } 1330 | } 1331 | m.repoList.SetItems(items) 1332 | m.mode = normalMode 1333 | return m, m.setSuccessMsg(fmt.Sprintf("Repository '%s' added successfully", m.newRepoName)) 1334 | } 1335 | return m, nil 1336 | 1337 | case repoRemovedMsg: 1338 | if msg.err == nil { 1339 | m.repos = msg.repos 1340 | items := make([]list.Item, len(msg.repos)) 1341 | for i, repo := range msg.repos { 1342 | items[i] = listItem{ 1343 | title: repo.Name, 1344 | description: repo.URL, 1345 | } 1346 | } 1347 | m.repoList.SetItems(items) 1348 | m.mode = normalMode 1349 | return m, m.setSuccessMsg(fmt.Sprintf("Repository '%s' removed successfully", msg.repoName)) 1350 | } 1351 | return m, nil 1352 | 1353 | case editorFinishedMsg: 1354 | if msg.err != nil { 1355 | return m, m.setSuccessMsg(fmt.Sprintf("Editor error: %v", msg.err)) 1356 | } 1357 | 1358 | // Validate YAML 1359 | var yamlData interface{} 1360 | if err := yaml.Unmarshal([]byte(msg.content), &yamlData); err != nil { 1361 | // Clean up temp file 1362 | if msg.filePath != "" { 1363 | os.Remove(msg.filePath) 1364 | } 1365 | return m, m.setSuccessMsg(fmt.Sprintf("Invalid YAML: %v", err)) 1366 | } 1367 | 1368 | // Save edited content and temp file path, then ask where to save 1369 | m.editedContent = msg.content 1370 | m.editTempFile = msg.filePath 1371 | m.mode = saveEditMode 1372 | m.searchInput.Reset() 1373 | m.searchInput.Placeholder = "./custom-values.yaml" 1374 | m.searchInput.Focus() 1375 | return m, nil 1376 | 1377 | case artifactHubSearchMsg: 1378 | m.ahLoading = false 1379 | if msg.err != nil { 1380 | m.err = msg.err 1381 | return m, nil 1382 | } 1383 | 1384 | m.ahPackages = msg.packages 1385 | items := make([]list.Item, len(msg.packages)) 1386 | for i, pkg := range msg.packages { 1387 | badges := pkg.GetBadges() 1388 | stars := fmt.Sprintf("⭐%d", pkg.Stars) 1389 | security := pkg.SecurityReport.GetSecurityBadge() 1390 | 1391 | desc := fmt.Sprintf("%s | %s %s | %s", pkg.Repository.DisplayName, stars, badges, security) 1392 | items[i] = listItem{ 1393 | title: pkg.Name, 1394 | description: desc, 1395 | } 1396 | } 1397 | m.ahPackageList.SetItems(items) 1398 | return m, nil 1399 | 1400 | case artifactHubPackageMsg: 1401 | m.ahLoading = false 1402 | if msg.err != nil { 1403 | m.err = msg.err 1404 | return m, nil 1405 | } 1406 | 1407 | m.ahSelectedPackage = msg.pkg 1408 | 1409 | // Populate version list 1410 | if len(msg.pkg.AvailableVersions) > 0 { 1411 | items := make([]list.Item, len(msg.pkg.AvailableVersions)) 1412 | for i, ver := range msg.pkg.AvailableVersions { 1413 | desc := "" 1414 | if ver.ContainsSecurityUpdates { 1415 | desc = "🛡️ Security update" 1416 | } 1417 | if ver.Prerelease { 1418 | desc += " [Pre-release]" 1419 | } 1420 | items[i] = listItem{ 1421 | title: "v" + ver.Version, 1422 | description: desc, 1423 | } 1424 | } 1425 | m.ahVersionList.SetItems(items) 1426 | } 1427 | return m, nil 1428 | 1429 | case clearSuccessMsgMsg: 1430 | m.successMsg = "" 1431 | return m, nil 1432 | 1433 | case releasesLoadedMsg: 1434 | m.loading = false 1435 | if msg.err != nil { 1436 | m.err = msg.err 1437 | return m, nil 1438 | } 1439 | 1440 | m.releases = msg.releases 1441 | items := make([]list.Item, len(msg.releases)) 1442 | for i, release := range msg.releases { 1443 | desc := fmt.Sprintf("%s | %s | %s", release.Namespace, release.Chart, release.Status) 1444 | items[i] = listItem{ 1445 | title: release.Name, 1446 | description: desc, 1447 | } 1448 | } 1449 | m.releaseList.SetItems(items) 1450 | return m, nil 1451 | 1452 | case namespacesLoadedMsg: 1453 | m.loading = false 1454 | if msg.err != nil { 1455 | m.err = msg.err 1456 | return m, nil 1457 | } 1458 | 1459 | m.namespaces = msg.namespaces 1460 | items := make([]list.Item, len(msg.namespaces)) 1461 | for i, ns := range msg.namespaces { 1462 | items[i] = listItem{ 1463 | title: ns, 1464 | description: "Kubernetes namespace", 1465 | } 1466 | } 1467 | m.namespaceList.SetItems(items) 1468 | return m, nil 1469 | 1470 | case releaseHistoryLoadedMsg: 1471 | m.loading = false 1472 | if msg.err != nil { 1473 | m.err = msg.err 1474 | return m, nil 1475 | } 1476 | 1477 | m.releaseHistory = msg.history 1478 | items := make([]list.Item, len(msg.history)) 1479 | for i, rev := range msg.history { 1480 | desc := fmt.Sprintf("%s | %s | %s", rev.Status, rev.Chart, rev.Updated) 1481 | items[i] = listItem{ 1482 | title: fmt.Sprintf("Revision %d", rev.Revision), 1483 | description: desc, 1484 | } 1485 | } 1486 | m.releaseHistoryList.SetItems(items) 1487 | 1488 | // Update detail view if we're showing it 1489 | if m.state == stateReleaseDetail { 1490 | m.updateReleaseDetailView() 1491 | } 1492 | return m, nil 1493 | 1494 | case releaseValuesLoadedMsg: 1495 | m.loadingVals = false 1496 | if msg.err != nil { 1497 | m.err = msg.err 1498 | return m, nil 1499 | } 1500 | 1501 | m.releaseValues = msg.values 1502 | m.releaseValuesLines = strings.Split(msg.values, "\n") 1503 | highlighted := ui.HighlightYAMLContent(msg.values) 1504 | m.releaseValuesView.SetContent(highlighted) 1505 | return m, nil 1506 | 1507 | case releaseStatusLoadedMsg: 1508 | m.loading = false 1509 | if msg.err != nil { 1510 | m.err = msg.err 1511 | return m, nil 1512 | } 1513 | 1514 | m.releaseStatus = msg.status 1515 | 1516 | // Update detail view if we're showing it 1517 | if m.state == stateReleaseDetail { 1518 | m.updateReleaseDetailView() 1519 | } 1520 | return m, nil 1521 | 1522 | case kubeContextLoadedMsg: 1523 | if msg.err != nil { 1524 | // Context error is not fatal, just don't show it 1525 | m.kubeContext = "unknown" 1526 | } else { 1527 | m.kubeContext = msg.context 1528 | } 1529 | return m, nil 1530 | } 1531 | 1532 | switch m.state { 1533 | case stateMainMenu: 1534 | m.mainMenu, cmd = m.mainMenu.Update(msg) 1535 | cmds = append(cmds, cmd) 1536 | case stateBrowseMenu: 1537 | m.browseMenu, cmd = m.browseMenu.Update(msg) 1538 | cmds = append(cmds, cmd) 1539 | case stateRepoList: 1540 | m.repoList, cmd = m.repoList.Update(msg) 1541 | cmds = append(cmds, cmd) 1542 | case stateChartList: 1543 | m.chartList, cmd = m.chartList.Update(msg) 1544 | cmds = append(cmds, cmd) 1545 | case stateChartDetail: 1546 | m.versionList, cmd = m.versionList.Update(msg) 1547 | cmds = append(cmds, cmd) 1548 | case stateValueViewer: 1549 | m.valuesView, cmd = m.valuesView.Update(msg) 1550 | cmds = append(cmds, cmd) 1551 | case stateDiffViewer: 1552 | m.diffView, cmd = m.diffView.Update(msg) 1553 | cmds = append(cmds, cmd) 1554 | case stateArtifactHubSearch: 1555 | m.ahPackageList, cmd = m.ahPackageList.Update(msg) 1556 | cmds = append(cmds, cmd) 1557 | case stateArtifactHubPackageDetail: 1558 | m.ahVersionList, cmd = m.ahVersionList.Update(msg) 1559 | cmds = append(cmds, cmd) 1560 | case stateArtifactHubVersions: 1561 | m.ahVersionList, cmd = m.ahVersionList.Update(msg) 1562 | cmds = append(cmds, cmd) 1563 | case stateClusterReleasesMenu: 1564 | m.clusterReleasesMenu, cmd = m.clusterReleasesMenu.Update(msg) 1565 | cmds = append(cmds, cmd) 1566 | case stateNamespaceList: 1567 | m.namespaceList, cmd = m.namespaceList.Update(msg) 1568 | cmds = append(cmds, cmd) 1569 | case stateReleaseList: 1570 | m.releaseList, cmd = m.releaseList.Update(msg) 1571 | cmds = append(cmds, cmd) 1572 | case stateReleaseDetail: 1573 | m.releaseDetailView, cmd = m.releaseDetailView.Update(msg) 1574 | cmds = append(cmds, cmd) 1575 | case stateReleaseHistory: 1576 | m.releaseHistoryList, cmd = m.releaseHistoryList.Update(msg) 1577 | cmds = append(cmds, cmd) 1578 | case stateReleaseValues: 1579 | m.releaseValuesView, cmd = m.releaseValuesView.Update(msg) 1580 | cmds = append(cmds, cmd) 1581 | } 1582 | 1583 | return m, tea.Batch(cmds...) 1584 | } 1585 | 1586 | func (m model) handleBack() (tea.Model, tea.Cmd) { 1587 | // Clear success message and search results 1588 | m.successMsg = "" 1589 | m.searchMatches = []int{} 1590 | m.lastSearchQuery = "" 1591 | m.horizontalOffset = 0 1592 | 1593 | if m.diffMode { 1594 | m.diffMode = false 1595 | return m, nil 1596 | } 1597 | 1598 | switch m.state { 1599 | case stateBrowseMenu: 1600 | m.state = stateMainMenu 1601 | case stateRepoList: 1602 | m.state = stateBrowseMenu 1603 | case stateChartList: 1604 | m.state = stateRepoList 1605 | m.charts = nil 1606 | m.chartList.SetItems([]list.Item{}) 1607 | case stateChartDetail: 1608 | m.state = stateChartList 1609 | m.versions = nil 1610 | m.versionList.SetItems([]list.Item{}) 1611 | case stateValueViewer: 1612 | m.state = stateChartDetail 1613 | m.values = "" 1614 | m.valuesLines = nil 1615 | case stateDiffViewer: 1616 | // Return to release history if we were comparing revisions, otherwise chart detail 1617 | if m.compareRevision >= 0 { 1618 | m.state = stateReleaseHistory 1619 | m.compareRevision = -1 1620 | } else { 1621 | m.state = stateChartDetail 1622 | } 1623 | case stateArtifactHubSearch: 1624 | m.state = stateBrowseMenu 1625 | m.ahPackages = nil 1626 | m.ahPackageList.SetItems([]list.Item{}) 1627 | case stateArtifactHubPackageDetail: 1628 | m.state = stateArtifactHubSearch 1629 | m.ahSelectedPackage = nil 1630 | m.ahVersionList.SetItems([]list.Item{}) 1631 | case stateArtifactHubVersions: 1632 | m.state = stateArtifactHubPackageDetail 1633 | case stateClusterReleasesMenu: 1634 | m.state = stateMainMenu 1635 | case stateNamespaceList: 1636 | m.state = stateClusterReleasesMenu 1637 | m.namespaces = nil 1638 | m.namespaceList.SetItems([]list.Item{}) 1639 | case stateReleaseList: 1640 | if m.selectedNamespace == "" { 1641 | // Came from "All Namespaces" 1642 | m.state = stateClusterReleasesMenu 1643 | } else { 1644 | // Came from "Select Namespace" 1645 | m.state = stateNamespaceList 1646 | } 1647 | m.releases = nil 1648 | m.releaseList.SetItems([]list.Item{}) 1649 | case stateReleaseDetail: 1650 | m.state = stateReleaseList 1651 | case stateReleaseHistory: 1652 | m.state = stateReleaseDetail 1653 | m.updateReleaseDetailView() 1654 | case stateReleaseValues: 1655 | m.state = stateReleaseHistory 1656 | m.releaseValues = "" 1657 | m.releaseValuesLines = nil 1658 | m.selectedRevision = 0 1659 | m.horizontalOffset = 0 1660 | } 1661 | return m, nil 1662 | } 1663 | 1664 | func (m model) handleEnter() (tea.Model, tea.Cmd) { 1665 | // Clear success message 1666 | m.successMsg = "" 1667 | 1668 | switch m.state { 1669 | case stateMainMenu: 1670 | selectedItem := m.mainMenu.SelectedItem() 1671 | if selectedItem != nil { 1672 | item := selectedItem.(listItem) 1673 | switch item.title { 1674 | case "Browse Repositories": 1675 | m.state = stateBrowseMenu 1676 | return m, nil 1677 | case "Cluster Releases": 1678 | m.state = stateClusterReleasesMenu 1679 | // Load kubectl context 1680 | return m, func() tea.Msg { 1681 | ctx, err := m.helmClient.GetCurrentContext() 1682 | if err != nil { 1683 | return kubeContextLoadedMsg{err: err} 1684 | } 1685 | return kubeContextLoadedMsg{context: ctx} 1686 | } 1687 | case "Settings": 1688 | return m, m.setSuccessMsg("Feature coming soon!") 1689 | } 1690 | } 1691 | 1692 | case stateBrowseMenu: 1693 | selectedItem := m.browseMenu.SelectedItem() 1694 | if selectedItem != nil { 1695 | item := selectedItem.(listItem) 1696 | switch item.title { 1697 | case "Local Repositories": 1698 | m.state = stateRepoList 1699 | return m, nil 1700 | case "Search Artifact Hub": 1701 | m.mode = searchMode 1702 | m.searchInput.Reset() 1703 | m.searchInput.Placeholder = "Search Artifact Hub..." 1704 | m.searchInput.Focus() 1705 | m.state = stateArtifactHubSearch 1706 | return m, nil 1707 | } 1708 | } 1709 | 1710 | case stateClusterReleasesMenu: 1711 | selectedItem := m.clusterReleasesMenu.SelectedItem() 1712 | if selectedItem != nil { 1713 | item := selectedItem.(listItem) 1714 | switch item.title { 1715 | case "All Namespaces": 1716 | m.state = stateReleaseList 1717 | m.selectedNamespace = "" // Empty means all namespaces 1718 | m.loading = true 1719 | return m, loadReleases(m.helmClient, "") 1720 | case "Select Namespace": 1721 | m.state = stateNamespaceList 1722 | m.loading = true 1723 | return m, loadNamespaces(m.helmClient) 1724 | } 1725 | } 1726 | 1727 | case stateNamespaceList: 1728 | selectedItem := m.namespaceList.SelectedItem() 1729 | if selectedItem != nil { 1730 | item := selectedItem.(listItem) 1731 | m.selectedNamespace = item.title 1732 | m.state = stateReleaseList 1733 | m.loading = true 1734 | return m, loadReleases(m.helmClient, item.title) 1735 | } 1736 | 1737 | case stateReleaseList: 1738 | selectedItem := m.releaseList.SelectedItem() 1739 | if selectedItem != nil { 1740 | item := selectedItem.(listItem) 1741 | // Find the release by name 1742 | for i, release := range m.releases { 1743 | if release.Name == item.title { 1744 | m.selectedRelease = i 1745 | m.state = stateReleaseDetail 1746 | m.loading = true 1747 | // Load both history and status for the detail view 1748 | return m, tea.Batch( 1749 | loadReleaseHistory(m.helmClient, release.Name, release.Namespace), 1750 | loadReleaseStatus(m.helmClient, release.Name, release.Namespace), 1751 | ) 1752 | } 1753 | } 1754 | } 1755 | 1756 | case stateReleaseHistory: 1757 | selectedItem := m.releaseHistoryList.SelectedItem() 1758 | if selectedItem != nil && m.selectedRelease < len(m.releases) { 1759 | item := selectedItem.(listItem) 1760 | release := m.releases[m.selectedRelease] 1761 | 1762 | // Find the selected revision index 1763 | var selectedIdx int = -1 1764 | for i, rev := range m.releaseHistory { 1765 | revTitle := fmt.Sprintf("Revision %d", rev.Revision) 1766 | if revTitle == item.title { 1767 | selectedIdx = i 1768 | break 1769 | } 1770 | } 1771 | 1772 | if selectedIdx >= 0 { 1773 | if m.diffMode { 1774 | if selectedIdx == m.compareRevision { 1775 | return m, m.setSuccessMsg("Please select a different revision to compare") 1776 | } 1777 | 1778 | revision1 := m.releaseHistory[m.compareRevision].Revision 1779 | revision2 := m.releaseHistory[selectedIdx].Revision 1780 | 1781 | // Get values for both revisions 1782 | values1, err := m.helmClient.GetReleaseValuesByRevision(release.Name, release.Namespace, revision1) 1783 | if err != nil { 1784 | m.err = err 1785 | m.diffMode = false 1786 | return m, nil 1787 | } 1788 | 1789 | values2, err := m.helmClient.GetReleaseValuesByRevision(release.Name, release.Namespace, revision2) 1790 | if err != nil { 1791 | m.err = err 1792 | m.diffMode = false 1793 | return m, nil 1794 | } 1795 | 1796 | diffLines := ui.DiffYAML(values1, values2) 1797 | diffContent := m.renderDiffContent(diffLines, fmt.Sprintf("rev%d", revision1), fmt.Sprintf("rev%d", revision2)) 1798 | 1799 | // Save diff lines for search functionality 1800 | m.diffLines = strings.Split(diffContent, "\n") 1801 | 1802 | m.diffView.SetContent(diffContent) 1803 | m.state = stateDiffViewer 1804 | m.diffMode = false 1805 | return m, nil 1806 | } 1807 | 1808 | // Normal flow: view values for selected revision 1809 | rev := m.releaseHistory[selectedIdx] 1810 | m.selectedRevision = rev.Revision 1811 | m.state = stateReleaseValues 1812 | m.loadingVals = true 1813 | return m, func() tea.Msg { 1814 | values, err := m.helmClient.GetReleaseValuesByRevision(release.Name, release.Namespace, rev.Revision) 1815 | if err != nil { 1816 | return releaseValuesLoadedMsg{err: err} 1817 | } 1818 | return releaseValuesLoadedMsg{values: values} 1819 | } 1820 | } 1821 | } 1822 | 1823 | case stateRepoList: 1824 | selectedItem := m.repoList.SelectedItem() 1825 | if selectedItem != nil { 1826 | item := selectedItem.(listItem) 1827 | // Find the correct index in the full list by matching the title 1828 | for i, repo := range m.repos { 1829 | if repo.Name == item.title { 1830 | m.selectedRepo = i 1831 | m.state = stateChartList 1832 | m.loading = true 1833 | return m, loadCharts(m.helmClient, m.chartCache, repo.Name) 1834 | } 1835 | } 1836 | } 1837 | 1838 | case stateChartList: 1839 | selectedItem := m.chartList.SelectedItem() 1840 | if selectedItem != nil { 1841 | item := selectedItem.(listItem) 1842 | // Find the correct index in the full list by matching the title 1843 | for i, chart := range m.charts { 1844 | chartName := chart.Name 1845 | if m.selectedRepo < len(m.repos) { 1846 | chartName = strings.TrimPrefix(chartName, m.repos[m.selectedRepo].Name+"/") 1847 | } 1848 | if chartName == item.title { 1849 | m.selectedChart = i 1850 | m.state = stateChartDetail 1851 | m.loading = true 1852 | return m, loadVersions(m.helmClient, m.versionCache, m.charts[i].Name) 1853 | } 1854 | } 1855 | } 1856 | 1857 | case stateChartDetail: 1858 | selectedItem := m.versionList.SelectedItem() 1859 | if selectedItem != nil { 1860 | item := selectedItem.(listItem) 1861 | // Find the correct index in the full list by matching the title 1862 | var selectedIdx int = -1 1863 | for i, ver := range m.versions { 1864 | if "v"+ver.Version == item.title { 1865 | selectedIdx = i 1866 | break 1867 | } 1868 | } 1869 | 1870 | if selectedIdx >= 0 { 1871 | if m.diffMode { 1872 | if selectedIdx == m.compareVersion { 1873 | return m, m.setSuccessMsg("Please select a different version to compare") 1874 | } 1875 | 1876 | chartName := m.charts[m.selectedChart].Name 1877 | version1 := m.versions[m.compareVersion].Version 1878 | version2 := m.versions[selectedIdx].Version 1879 | 1880 | values1, found1 := m.cache.Get(chartName, version1) 1881 | if !found1 { 1882 | v, err := m.helmClient.GetChartValuesByVersion(chartName, version1) 1883 | if err != nil { 1884 | m.err = err 1885 | m.diffMode = false 1886 | return m, nil 1887 | } 1888 | values1 = v 1889 | m.cache.Set(chartName, version1, values1) 1890 | } 1891 | 1892 | values2, found2 := m.cache.Get(chartName, version2) 1893 | if !found2 { 1894 | v, err := m.helmClient.GetChartValuesByVersion(chartName, version2) 1895 | if err != nil { 1896 | m.err = err 1897 | m.diffMode = false 1898 | return m, nil 1899 | } 1900 | values2 = v 1901 | m.cache.Set(chartName, version2, values2) 1902 | } 1903 | 1904 | diffLines := ui.DiffYAML(values1, values2) 1905 | diffContent := m.renderDiffContent(diffLines, version1, version2) 1906 | 1907 | // Save diff lines for search functionality 1908 | m.diffLines = strings.Split(diffContent, "\n") 1909 | 1910 | m.diffView.SetContent(diffContent) 1911 | m.state = stateDiffViewer 1912 | m.diffMode = false 1913 | return m, nil 1914 | } 1915 | 1916 | m.selectedVersion = selectedIdx 1917 | m.state = stateValueViewer 1918 | m.loadingVals = true 1919 | chartName := m.charts[m.selectedChart].Name 1920 | version := m.versions[selectedIdx].Version 1921 | return m, loadValuesByVersion(m.helmClient, m.cache, chartName, version) 1922 | } 1923 | } 1924 | 1925 | case stateArtifactHubSearch: 1926 | selectedItem := m.ahPackageList.SelectedItem() 1927 | if selectedItem != nil { 1928 | item := selectedItem.(listItem) 1929 | // Find the correct package in the full list by matching the name 1930 | for i, pkg := range m.ahPackages { 1931 | if pkg.Name == item.title { 1932 | m.ahSelectedPkg = i 1933 | m.state = stateArtifactHubPackageDetail 1934 | m.ahLoading = true 1935 | return m, loadArtifactHubPackage(m.artifactHubClient, pkg.Repository.Name, pkg.Name) 1936 | } 1937 | } 1938 | } 1939 | 1940 | case stateArtifactHubVersions: 1941 | // Can't view values from Artifact Hub - need to add repo first 1942 | return m, m.setSuccessMsg("Add the repository first (press 'a'), then browse it from the main menu to view values") 1943 | } 1944 | 1945 | return m, nil 1946 | } 1947 | 1948 | func (m model) handleSearch() (tea.Model, tea.Cmd) { 1949 | if m.state == stateRepoList || m.state == stateChartList || m.state == stateChartDetail || m.state == stateValueViewer || m.state == stateDiffViewer || m.state == stateReleaseValues || m.state == stateReleaseList { 1950 | m.successMsg = "" // Clear success message 1951 | m.mode = searchMode 1952 | m.searchInput.Reset() 1953 | m.searchInput.Placeholder = "Search..." 1954 | m.searchInput.Focus() 1955 | } 1956 | if m.state == stateArtifactHubSearch { 1957 | // Allow searching again in Artifact Hub 1958 | m.successMsg = "" 1959 | m.mode = searchMode 1960 | m.searchInput.Reset() 1961 | m.searchInput.Placeholder = "Search Artifact Hub..." 1962 | m.searchInput.Focus() 1963 | } 1964 | return m, nil 1965 | } 1966 | 1967 | func (m model) handleInputMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 1968 | var cmd tea.Cmd 1969 | 1970 | switch msg.String() { 1971 | case "esc": 1972 | // Clean up temp file if canceling save edit mode 1973 | if m.mode == saveEditMode && m.editTempFile != "" { 1974 | os.Remove(m.editTempFile) 1975 | m.editTempFile = "" 1976 | m.editedContent = "" 1977 | } 1978 | 1979 | // Restore original lists if we were in search mode 1980 | if m.mode == searchMode { 1981 | switch m.state { 1982 | case stateRepoList: 1983 | items := make([]list.Item, len(m.repos)) 1984 | for i, repo := range m.repos { 1985 | items[i] = listItem{ 1986 | title: repo.Name, 1987 | description: repo.URL, 1988 | } 1989 | } 1990 | m.repoList.SetItems(items) 1991 | 1992 | case stateChartList: 1993 | items := make([]list.Item, len(m.charts)) 1994 | for i, chart := range m.charts { 1995 | name := chart.Name 1996 | if m.selectedRepo < len(m.repos) { 1997 | name = strings.TrimPrefix(name, m.repos[m.selectedRepo].Name+"/") 1998 | } 1999 | items[i] = listItem{ 2000 | title: name, 2001 | description: chart.Description, 2002 | } 2003 | } 2004 | m.chartList.SetItems(items) 2005 | 2006 | case stateChartDetail: 2007 | items := make([]list.Item, len(m.versions)) 2008 | for i, ver := range m.versions { 2009 | desc := "" 2010 | if ver.AppVersion != "" { 2011 | desc = "App: " + ver.AppVersion 2012 | } 2013 | items[i] = listItem{ 2014 | title: "v" + ver.Version, 2015 | description: desc, 2016 | } 2017 | } 2018 | m.versionList.SetItems(items) 2019 | 2020 | case stateValueViewer: 2021 | // Clear search results 2022 | m.searchMatches = []int{} 2023 | m.lastSearchQuery = "" 2024 | 2025 | case stateReleaseValues: 2026 | // Clear search results 2027 | m.searchMatches = []int{} 2028 | m.lastSearchQuery = "" 2029 | 2030 | case stateReleaseList: 2031 | // Restore full release list 2032 | items := make([]list.Item, len(m.releases)) 2033 | for i, release := range m.releases { 2034 | desc := fmt.Sprintf("%s | %s | %s", release.Namespace, release.Chart, release.Status) 2035 | items[i] = listItem{ 2036 | title: release.Name, 2037 | description: desc, 2038 | } 2039 | } 2040 | m.releaseList.SetItems(items) 2041 | 2042 | case stateDiffViewer: 2043 | // Clear search results and restore original content 2044 | m.searchMatches = []int{} 2045 | m.lastSearchQuery = "" 2046 | m.updateDiffViewWithSearch() // Restore original without highlights 2047 | 2048 | case stateArtifactHubSearch: 2049 | // Return to repo list 2050 | m.state = stateRepoList 2051 | m.ahPackages = nil 2052 | m.ahPackageList.SetItems([]list.Item{}) 2053 | } 2054 | } 2055 | 2056 | m.mode = normalMode 2057 | m.searchInput.Blur() 2058 | m.addRepoStep = 0 2059 | m.newRepoURL = "" // Reset pre-filled URL 2060 | return m, nil 2061 | 2062 | case "enter": 2063 | switch m.mode { 2064 | case searchMode: 2065 | // Check if we're in Artifact Hub search state 2066 | if m.state == stateArtifactHubSearch { 2067 | query := m.searchInput.Value() 2068 | if query != "" { 2069 | m.mode = normalMode 2070 | m.searchInput.Blur() 2071 | m.ahLoading = true 2072 | return m, searchArtifactHub(m.artifactHubClient, query) 2073 | } 2074 | } 2075 | m.mode = normalMode 2076 | m.searchInput.Blur() 2077 | 2078 | case addRepoMode: 2079 | if m.addRepoStep == 0 { 2080 | inputName := m.searchInput.Value() 2081 | // If coming from Artifact Hub and no name provided, use default 2082 | if inputName == "" && m.newRepoURL != "" && m.ahSelectedPackage != nil { 2083 | inputName = m.ahSelectedPackage.Repository.Name 2084 | } 2085 | m.newRepoName = inputName 2086 | 2087 | // If URL is already set (from Artifact Hub), skip asking for URL 2088 | if m.newRepoURL != "" { 2089 | m.mode = normalMode 2090 | m.searchInput.Blur() 2091 | return m, addRepository(m.helmClient, m.newRepoName, m.newRepoURL) 2092 | } 2093 | 2094 | // Otherwise ask for URL (normal flow) 2095 | m.addRepoStep = 1 2096 | m.searchInput.Reset() 2097 | m.searchInput.Placeholder = "Repository URL..." 2098 | } else { 2099 | m.newRepoURL = m.searchInput.Value() 2100 | m.mode = normalMode 2101 | m.searchInput.Blur() 2102 | return m, addRepository(m.helmClient, m.newRepoName, m.newRepoURL) 2103 | } 2104 | 2105 | case exportValuesMode: 2106 | path := m.searchInput.Value() 2107 | if path == "" { 2108 | path = "./values.yaml" 2109 | } 2110 | m.mode = normalMode 2111 | m.searchInput.Blur() 2112 | 2113 | if m.state == stateReleaseValues { 2114 | return m, func() tea.Msg { 2115 | err := os.WriteFile(path, []byte(m.releaseValues), 0644) 2116 | if err != nil { 2117 | return operationDoneMsg{err: err} 2118 | } 2119 | if m.selectedRevision > 0 { 2120 | return operationDoneMsg{success: fmt.Sprintf("Values (revision %d) exported to %s", m.selectedRevision, path)} 2121 | } 2122 | return operationDoneMsg{success: fmt.Sprintf("Values exported to %s", path)} 2123 | } 2124 | } 2125 | 2126 | chartName := m.charts[m.selectedChart].Name 2127 | if m.state == stateValueViewer && m.selectedVersion < len(m.versions) { 2128 | version := m.versions[m.selectedVersion].Version 2129 | return m, tea.Batch(func() tea.Msg { 2130 | values, err := m.helmClient.GetChartValuesByVersion(chartName, version) 2131 | if err != nil { 2132 | return operationDoneMsg{err: err} 2133 | } 2134 | err = os.WriteFile(path, []byte(values), 0644) 2135 | if err != nil { 2136 | return operationDoneMsg{err: err} 2137 | } 2138 | return operationDoneMsg{success: fmt.Sprintf("Values (v%s) exported to %s", version, path)} 2139 | }) 2140 | } 2141 | return m, exportValues(m.helmClient, chartName, path) 2142 | 2143 | case templatePathMode: 2144 | m.templatePath = m.searchInput.Value() 2145 | if m.templatePath == "" { 2146 | m.templatePath = "./output/" 2147 | } 2148 | m.mode = templateValuesMode 2149 | m.searchInput.Reset() 2150 | m.searchInput.Placeholder = "Values file (optional)..." 2151 | 2152 | case templateValuesMode: 2153 | m.templateValues = m.searchInput.Value() 2154 | m.mode = normalMode 2155 | m.searchInput.Blur() 2156 | 2157 | chartName := m.charts[m.selectedChart].Name 2158 | if m.state == stateValueViewer && m.selectedVersion < len(m.versions) { 2159 | version := m.versions[m.selectedVersion].Version 2160 | chartName = fmt.Sprintf("%s --version %s", chartName, version) 2161 | } 2162 | return m, generateTemplate(m.helmClient, chartName, m.templateValues, m.templatePath) 2163 | 2164 | case saveEditMode: 2165 | path := m.searchInput.Value() 2166 | if path == "" { 2167 | path = "./custom-values.yaml" 2168 | } 2169 | m.mode = normalMode 2170 | m.searchInput.Blur() 2171 | 2172 | // Expand home directory if needed 2173 | if strings.HasPrefix(path, "~/") { 2174 | home, err := os.UserHomeDir() 2175 | if err == nil { 2176 | path = filepath.Join(home, path[2:]) 2177 | } 2178 | } 2179 | 2180 | // Save the edited values 2181 | err := os.WriteFile(path, []byte(m.editedContent), 0644) 2182 | 2183 | // Clean up temp file 2184 | if m.editTempFile != "" { 2185 | os.Remove(m.editTempFile) 2186 | m.editTempFile = "" 2187 | } 2188 | 2189 | m.editedContent = "" // Clear edited content 2190 | if err != nil { 2191 | return m, m.setSuccessMsg(fmt.Sprintf("Error saving: %v", err)) 2192 | } else { 2193 | return m, m.setSuccessMsg(fmt.Sprintf("✓ Values saved to %s", path)) 2194 | } 2195 | 2196 | case confirmRemoveRepoMode: 2197 | response := strings.ToLower(m.searchInput.Value()) 2198 | m.mode = normalMode 2199 | m.searchInput.Blur() 2200 | 2201 | if response == "y" || response == "yes" { 2202 | // Remove the repository - use selected item to handle filtered lists 2203 | selectedItem := m.repoList.SelectedItem() 2204 | if selectedItem != nil { 2205 | item := selectedItem.(listItem) 2206 | repoName := item.title 2207 | return m, func() tea.Msg { 2208 | err := m.helmClient.RemoveRepository(repoName) 2209 | if err != nil { 2210 | return operationDoneMsg{err: err} 2211 | } 2212 | 2213 | // Reload repositories 2214 | repos, repoErr := m.helmClient.ListRepositories() 2215 | if repoErr != nil { 2216 | return operationDoneMsg{success: fmt.Sprintf("Repository '%s' removed, but failed to reload list", repoName)} 2217 | } 2218 | 2219 | return repoRemovedMsg{repos: repos, repoName: repoName} 2220 | } 2221 | } 2222 | } 2223 | return m, nil 2224 | } 2225 | return m, nil 2226 | } 2227 | 2228 | m.searchInput, cmd = m.searchInput.Update(msg) 2229 | 2230 | if m.mode == searchMode && m.searchInput.Value() != "" { 2231 | query := strings.ToLower(m.searchInput.Value()) 2232 | 2233 | switch m.state { 2234 | case stateRepoList: 2235 | matches := fuzzy.Find(query, reposToStrings(m.repos)) 2236 | items := make([]list.Item, len(matches)) 2237 | for i, match := range matches { 2238 | repo := m.repos[match.Index] 2239 | items[i] = listItem{ 2240 | title: repo.Name, 2241 | description: repo.URL, 2242 | } 2243 | } 2244 | m.repoList.SetItems(items) 2245 | 2246 | case stateChartList: 2247 | matches := fuzzy.Find(query, chartsToStrings(m.charts)) 2248 | items := make([]list.Item, len(matches)) 2249 | for i, match := range matches { 2250 | chart := m.charts[match.Index] 2251 | name := chart.Name 2252 | if m.selectedRepo < len(m.repos) { 2253 | name = strings.TrimPrefix(name, m.repos[m.selectedRepo].Name+"/") 2254 | } 2255 | items[i] = listItem{ 2256 | title: name, 2257 | description: chart.Description, 2258 | } 2259 | } 2260 | m.chartList.SetItems(items) 2261 | 2262 | case stateChartDetail: 2263 | matches := fuzzy.Find(query, versionsToStrings(m.versions)) 2264 | items := make([]list.Item, len(matches)) 2265 | for i, match := range matches { 2266 | ver := m.versions[match.Index] 2267 | desc := "" 2268 | if ver.AppVersion != "" { 2269 | desc = "App: " + ver.AppVersion 2270 | } 2271 | items[i] = listItem{ 2272 | title: "v" + ver.Version, 2273 | description: desc, 2274 | } 2275 | } 2276 | m.versionList.SetItems(items) 2277 | 2278 | case stateReleaseList: 2279 | matches := fuzzy.Find(query, releasesToStrings(m.releases)) 2280 | items := make([]list.Item, len(matches)) 2281 | for i, match := range matches { 2282 | release := m.releases[match.Index] 2283 | desc := fmt.Sprintf("%s | %s | %s", release.Namespace, release.Chart, release.Status) 2284 | items[i] = listItem{ 2285 | title: release.Name, 2286 | description: desc, 2287 | } 2288 | } 2289 | m.releaseList.SetItems(items) 2290 | 2291 | case stateValueViewer: 2292 | // Find all matches in values 2293 | m.searchMatches = []int{} 2294 | m.lastSearchQuery = query 2295 | for i, line := range m.valuesLines { 2296 | if strings.Contains(strings.ToLower(line), query) { 2297 | m.searchMatches = append(m.searchMatches, i) 2298 | } 2299 | } 2300 | 2301 | // Update the view with highlighted search terms 2302 | m.updateValuesViewWithSearch() 2303 | 2304 | // Jump to first match 2305 | if len(m.searchMatches) > 0 { 2306 | m.currentMatchIndex = 0 2307 | targetLine := m.searchMatches[0] 2308 | if targetLine > m.valuesView.Height/2 { 2309 | targetLine = targetLine - m.valuesView.Height/2 2310 | } else { 2311 | targetLine = 0 2312 | } 2313 | m.valuesView.YOffset = targetLine 2314 | } 2315 | 2316 | case stateReleaseValues: 2317 | // Find all matches in release values 2318 | m.searchMatches = []int{} 2319 | m.lastSearchQuery = query 2320 | for i, line := range m.releaseValuesLines { 2321 | if strings.Contains(strings.ToLower(line), query) { 2322 | m.searchMatches = append(m.searchMatches, i) 2323 | } 2324 | } 2325 | 2326 | // Update the view with highlighted search terms 2327 | m.updateReleaseValuesViewWithSearch() 2328 | 2329 | // Jump to first match 2330 | if len(m.searchMatches) > 0 { 2331 | m.currentMatchIndex = 0 2332 | targetLine := m.searchMatches[0] 2333 | if targetLine > m.releaseValuesView.Height/2 { 2334 | targetLine = targetLine - m.releaseValuesView.Height/2 2335 | } else { 2336 | targetLine = 0 2337 | } 2338 | m.releaseValuesView.YOffset = targetLine 2339 | } 2340 | 2341 | case stateDiffViewer: 2342 | // Find all matches in diff 2343 | m.searchMatches = []int{} 2344 | m.lastSearchQuery = query 2345 | for i, line := range m.diffLines { 2346 | if strings.Contains(strings.ToLower(line), query) { 2347 | m.searchMatches = append(m.searchMatches, i) 2348 | } 2349 | } 2350 | 2351 | // Update the view with highlighted search terms 2352 | m.updateDiffViewWithSearch() 2353 | 2354 | // Jump to first match 2355 | if len(m.searchMatches) > 0 { 2356 | m.currentMatchIndex = 0 2357 | targetLine := m.searchMatches[0] 2358 | if targetLine > m.diffView.Height/2 { 2359 | targetLine = targetLine - m.diffView.Height/2 2360 | } else { 2361 | targetLine = 0 2362 | } 2363 | m.diffView.YOffset = targetLine 2364 | } 2365 | } 2366 | } 2367 | 2368 | return m, cmd 2369 | } 2370 | 2371 | func reposToStrings(repos []helm.Repository) []string { 2372 | result := make([]string, len(repos)) 2373 | for i, r := range repos { 2374 | result[i] = r.Name 2375 | } 2376 | return result 2377 | } 2378 | 2379 | func chartsToStrings(charts []helm.Chart) []string { 2380 | result := make([]string, len(charts)) 2381 | for i, c := range charts { 2382 | result[i] = c.Name 2383 | } 2384 | return result 2385 | } 2386 | 2387 | func versionsToStrings(versions []helm.ChartVersion) []string { 2388 | result := make([]string, len(versions)) 2389 | for i, v := range versions { 2390 | result[i] = v.Version 2391 | } 2392 | return result 2393 | } 2394 | 2395 | func releasesToStrings(releases []helm.Release) []string { 2396 | result := make([]string, len(releases)) 2397 | for i, r := range releases { 2398 | result[i] = r.Name 2399 | } 2400 | return result 2401 | } 2402 | 2403 | func (m model) jumpToMatch() model { 2404 | if len(m.searchMatches) == 0 { 2405 | return m 2406 | } 2407 | 2408 | targetLine := m.searchMatches[m.currentMatchIndex] 2409 | 2410 | // Center the match on screen based on current state 2411 | if m.state == stateValueViewer { 2412 | if targetLine > m.valuesView.Height/2 { 2413 | m.valuesView.YOffset = targetLine - m.valuesView.Height/2 2414 | } else { 2415 | m.valuesView.YOffset = 0 2416 | } 2417 | } else if m.state == stateReleaseValues { 2418 | if targetLine > m.releaseValuesView.Height/2 { 2419 | m.releaseValuesView.YOffset = targetLine - m.releaseValuesView.Height/2 2420 | } else { 2421 | m.releaseValuesView.YOffset = 0 2422 | } 2423 | } else if m.state == stateDiffViewer { 2424 | if targetLine > m.diffView.Height/2 { 2425 | m.diffView.YOffset = targetLine - m.diffView.Height/2 2426 | } else { 2427 | m.diffView.YOffset = 0 2428 | } 2429 | } 2430 | 2431 | return m 2432 | } 2433 | 2434 | func (m *model) updateValuesViewWithSearch() { 2435 | lines := strings.Split(m.values, "\n") 2436 | viewportWidth := m.valuesView.Width 2437 | if viewportWidth <= 0 { 2438 | viewportWidth = m.termWidth - 6 // Default to full screen minus borders/padding 2439 | } 2440 | 2441 | // Get the current match line (only this one should be highlighted) 2442 | var currentMatchLine int = -1 2443 | if len(m.searchMatches) > 0 && m.currentMatchIndex < len(m.searchMatches) { 2444 | currentMatchLine = m.searchMatches[m.currentMatchIndex] 2445 | } 2446 | 2447 | query := strings.ToLower(m.lastSearchQuery) 2448 | highlightedLines := make([]string, len(lines)) 2449 | 2450 | for i, line := range lines { 2451 | // Apply horizontal scrolling 2452 | visibleLine := line 2453 | hasMore := false 2454 | 2455 | // Calculate actual display width considering the line content 2456 | if len(line) > m.horizontalOffset { 2457 | visibleLine = line[m.horizontalOffset:] 2458 | 2459 | // Truncate if longer than viewport width 2460 | if len(visibleLine) > viewportWidth-3 { // -3 for indicator 2461 | visibleLine = visibleLine[:viewportWidth-3] 2462 | hasMore = true 2463 | } 2464 | } else { 2465 | visibleLine = "" 2466 | } 2467 | 2468 | // Apply syntax highlighting 2469 | var highlighted string 2470 | // Only highlight if this is THE CURRENT match (not all matches) 2471 | if i == currentMatchLine && query != "" { 2472 | // This line is the CURRENT match - find and highlight it 2473 | lowerLine := strings.ToLower(visibleLine) 2474 | idx := strings.Index(lowerLine, query) 2475 | if idx >= 0 && idx+len(query) <= len(visibleLine) { 2476 | // Split the line into 3 parts 2477 | before := visibleLine[:idx] 2478 | match := visibleLine[idx : idx+len(query)] 2479 | after := visibleLine[idx+len(query):] 2480 | 2481 | // Apply YAML highlighting to before and after, but not to match 2482 | beforeHighlighted := ui.HighlightYAMLLine(before) 2483 | afterHighlighted := ui.HighlightYAMLLine(after) 2484 | matchHighlighted := highlightStyle.Render(match) 2485 | 2486 | highlighted = beforeHighlighted + matchHighlighted + afterHighlighted 2487 | } else { 2488 | // Fallback to normal highlighting if match not found in visible portion 2489 | highlighted = ui.HighlightYAMLLine(visibleLine) 2490 | } 2491 | } else { 2492 | // Normal line - just apply YAML highlighting 2493 | highlighted = ui.HighlightYAMLLine(visibleLine) 2494 | } 2495 | 2496 | // Add continuation indicator if line continues 2497 | if hasMore { 2498 | arrowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true) 2499 | highlighted += arrowStyle.Render(" →") 2500 | } 2501 | 2502 | highlightedLines[i] = highlighted 2503 | } 2504 | 2505 | m.valuesView.SetContent(strings.Join(highlightedLines, "\n")) 2506 | } 2507 | 2508 | func (m *model) updateReleaseValuesViewWithSearch() { 2509 | lines := strings.Split(m.releaseValues, "\n") 2510 | viewportWidth := m.releaseValuesView.Width 2511 | if viewportWidth <= 0 { 2512 | viewportWidth = m.termWidth - 6 // Default to full screen minus borders/padding 2513 | } 2514 | 2515 | // Get the current match line (only this one should be highlighted) 2516 | var currentMatchLine int = -1 2517 | if len(m.searchMatches) > 0 && m.currentMatchIndex < len(m.searchMatches) { 2518 | currentMatchLine = m.searchMatches[m.currentMatchIndex] 2519 | } 2520 | 2521 | query := strings.ToLower(m.lastSearchQuery) 2522 | highlightedLines := make([]string, len(lines)) 2523 | 2524 | for i, line := range lines { 2525 | // Apply horizontal scrolling 2526 | visibleLine := line 2527 | hasMore := false 2528 | 2529 | // Calculate actual display width considering the line content 2530 | if len(line) > m.horizontalOffset { 2531 | visibleLine = line[m.horizontalOffset:] 2532 | 2533 | // Truncate if longer than viewport width 2534 | if len(visibleLine) > viewportWidth-3 { // -3 for indicator 2535 | visibleLine = visibleLine[:viewportWidth-3] 2536 | hasMore = true 2537 | } 2538 | } else { 2539 | visibleLine = "" 2540 | } 2541 | 2542 | // Apply syntax highlighting 2543 | var highlighted string 2544 | // Only highlight if this is THE CURRENT match (not all matches) 2545 | if i == currentMatchLine && query != "" { 2546 | // This line is the CURRENT match - find and highlight it 2547 | lowerLine := strings.ToLower(visibleLine) 2548 | idx := strings.Index(lowerLine, query) 2549 | if idx >= 0 && idx+len(query) <= len(visibleLine) { 2550 | // Split the line into 3 parts 2551 | before := visibleLine[:idx] 2552 | match := visibleLine[idx : idx+len(query)] 2553 | after := visibleLine[idx+len(query):] 2554 | 2555 | // Apply YAML highlighting to before and after, but not to match 2556 | beforeHighlighted := ui.HighlightYAMLLine(before) 2557 | afterHighlighted := ui.HighlightYAMLLine(after) 2558 | matchHighlighted := highlightStyle.Render(match) 2559 | 2560 | highlighted = beforeHighlighted + matchHighlighted + afterHighlighted 2561 | } else { 2562 | // Fallback to normal highlighting if match not found in visible portion 2563 | highlighted = ui.HighlightYAMLLine(visibleLine) 2564 | } 2565 | } else { 2566 | // Normal line - just apply YAML highlighting 2567 | highlighted = ui.HighlightYAMLLine(visibleLine) 2568 | } 2569 | 2570 | // Add continuation indicator if line continues 2571 | if hasMore { 2572 | arrowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true) 2573 | highlighted += arrowStyle.Render(" →") 2574 | } 2575 | 2576 | highlightedLines[i] = highlighted 2577 | } 2578 | 2579 | m.releaseValuesView.SetContent(strings.Join(highlightedLines, "\n")) 2580 | } 2581 | 2582 | func (m *model) updateDiffViewWithSearch() { 2583 | if len(m.diffLines) == 0 { 2584 | return 2585 | } 2586 | 2587 | // Get the current match line (only this one should be highlighted) 2588 | var currentMatchLine int = -1 2589 | if len(m.searchMatches) > 0 && m.currentMatchIndex < len(m.searchMatches) { 2590 | currentMatchLine = m.searchMatches[m.currentMatchIndex] 2591 | } 2592 | 2593 | query := strings.ToLower(m.lastSearchQuery) 2594 | highlightedLines := make([]string, len(m.diffLines)) 2595 | 2596 | for i, line := range m.diffLines { 2597 | // Only highlight if this is THE CURRENT match 2598 | if i == currentMatchLine && query != "" { 2599 | // This line is the CURRENT match - find and highlight it 2600 | lowerLine := strings.ToLower(line) 2601 | idx := strings.Index(lowerLine, query) 2602 | if idx >= 0 && idx+len(query) <= len(line) { 2603 | // Split the line into 3 parts 2604 | before := line[:idx] 2605 | match := line[idx : idx+len(query)] 2606 | after := line[idx+len(query):] 2607 | 2608 | // Highlight the match in yellow background 2609 | matchHighlighted := highlightStyle.Render(match) 2610 | 2611 | highlightedLines[i] = before + matchHighlighted + after 2612 | } else { 2613 | // Fallback to normal line if match not found 2614 | highlightedLines[i] = line 2615 | } 2616 | } else { 2617 | // Normal line - no highlighting 2618 | highlightedLines[i] = line 2619 | } 2620 | } 2621 | 2622 | m.diffView.SetContent(strings.Join(highlightedLines, "\n")) 2623 | } 2624 | 2625 | func (m model) View() string { 2626 | if m.err != nil { 2627 | return errorStyle.Render(fmt.Sprintf(" Error: %v ", m.err)) + "\n\n" + 2628 | helpStyle.Render("Press 'q' to quit") 2629 | } 2630 | 2631 | if m.state == stateHelp { 2632 | return m.renderHelp() 2633 | } 2634 | 2635 | var content string 2636 | 2637 | breadcrumb := m.getBreadcrumb() 2638 | if breadcrumb != "" { 2639 | breadcrumbLine := breadcrumbStyle.Render(" " + breadcrumb + " ") 2640 | 2641 | // Add kubectl context on the right if in cluster releases section 2642 | if ((m.state >= stateClusterReleasesMenu && m.state <= stateReleaseValues) || 2643 | (m.state == stateDiffViewer && m.compareRevision >= 0)) && m.kubeContext != "" { 2644 | contextInfo := infoStyle.Render(fmt.Sprintf(" kubectl: %s ", m.kubeContext)) 2645 | // Calculate spacing to push context to the right 2646 | breadcrumbWidth := len(breadcrumb) + 2 2647 | contextWidth := len(m.kubeContext) + 10 2648 | spacer := strings.Repeat(" ", max(1, m.termWidth-breadcrumbWidth-contextWidth-4)) 2649 | breadcrumbLine = breadcrumbLine + spacer + contextInfo 2650 | } 2651 | 2652 | content += breadcrumbLine + "\n\n" 2653 | } 2654 | 2655 | // Show search info AFTER breadcrumb for better visibility 2656 | if (m.state == stateValueViewer || m.state == stateReleaseValues || m.state == stateDiffViewer) && len(m.searchMatches) > 0 { 2657 | content += m.renderSearchHeader() + "\n" 2658 | } 2659 | 2660 | switch m.state { 2661 | case stateMainMenu: 2662 | content += m.renderMainMenu() 2663 | case stateBrowseMenu: 2664 | content += m.renderBrowseMenu() 2665 | case stateRepoList: 2666 | content += m.renderRepoList() 2667 | case stateChartList: 2668 | content += m.renderChartList() 2669 | case stateChartDetail: 2670 | content += m.renderChartDetail() 2671 | case stateValueViewer: 2672 | content += m.renderValueViewer() 2673 | case stateDiffViewer: 2674 | content += m.renderDiffViewer() 2675 | case stateArtifactHubSearch: 2676 | content += m.renderArtifactHubSearch() 2677 | case stateArtifactHubPackageDetail: 2678 | content += m.renderArtifactHubPackageDetail() 2679 | case stateArtifactHubVersions: 2680 | content += m.renderArtifactHubVersions() 2681 | case stateClusterReleasesMenu: 2682 | content += m.renderClusterReleasesMenu() 2683 | case stateNamespaceList: 2684 | content += m.renderNamespaceList() 2685 | case stateReleaseList: 2686 | content += m.renderReleaseList() 2687 | case stateReleaseDetail: 2688 | content += m.renderReleaseDetail() 2689 | case stateReleaseHistory: 2690 | content += m.renderReleaseHistory() 2691 | case stateReleaseValues: 2692 | content += m.renderReleaseValues() 2693 | } 2694 | 2695 | footer := "\n" 2696 | if m.successMsg != "" { 2697 | footer += successStyle.Render(" " + m.successMsg + " ") + "\n" 2698 | } 2699 | 2700 | if m.mode != normalMode { 2701 | footer += m.renderInputPrompt() + "\n" 2702 | } 2703 | 2704 | footer += "\n" + helpStyle.Render(" "+m.helpView.ShortHelpView(m.keys.ShortHelp())+" ") 2705 | 2706 | return content + footer 2707 | } 2708 | 2709 | func (m model) renderSearchHeader() string { 2710 | if len(m.searchMatches) == 0 { 2711 | return "" 2712 | } 2713 | 2714 | var header string 2715 | 2716 | // Match counter - always visible 2717 | matchInfo := fmt.Sprintf(" Match %d/%d ", m.currentMatchIndex+1, len(m.searchMatches)) 2718 | header += infoStyle.Render(matchInfo) + " " 2719 | 2720 | // Show YAML path or line content based on state 2721 | if m.state == stateValueViewer { 2722 | matchLine := m.searchMatches[m.currentMatchIndex] 2723 | yamlPath := ui.GetYAMLPath(m.valuesLines, matchLine) 2724 | 2725 | if yamlPath != "" { 2726 | header += pathStyle.Render(" " + yamlPath + " ") 2727 | } else if matchLine < len(m.valuesLines) { 2728 | lineContent := strings.TrimSpace(m.valuesLines[matchLine]) 2729 | if len(lineContent) > 60 { 2730 | lineContent = lineContent[:60] + "..." 2731 | } 2732 | header += pathStyle.Render(fmt.Sprintf(" Line %d: %s ", matchLine+1, lineContent)) 2733 | } 2734 | header += " " + helpStyle.Render("n=next N=prev y=copy") 2735 | } else if m.state == stateReleaseValues { 2736 | matchLine := m.searchMatches[m.currentMatchIndex] 2737 | yamlPath := ui.GetYAMLPath(m.releaseValuesLines, matchLine) 2738 | 2739 | if yamlPath != "" { 2740 | header += pathStyle.Render(" " + yamlPath + " ") 2741 | } else if matchLine < len(m.releaseValuesLines) { 2742 | lineContent := strings.TrimSpace(m.releaseValuesLines[matchLine]) 2743 | if len(lineContent) > 60 { 2744 | lineContent = lineContent[:60] + "..." 2745 | } 2746 | header += pathStyle.Render(fmt.Sprintf(" Line %d: %s ", matchLine+1, lineContent)) 2747 | } 2748 | header += " " + helpStyle.Render("n=next N=prev y=copy") 2749 | } else if m.state == stateDiffViewer { 2750 | matchLine := m.searchMatches[m.currentMatchIndex] 2751 | if matchLine < len(m.diffLines) { 2752 | lineContent := strings.TrimSpace(m.diffLines[matchLine]) 2753 | if len(lineContent) > 80 { 2754 | lineContent = lineContent[:80] + "..." 2755 | } 2756 | header += pathStyle.Render(fmt.Sprintf(" %s ", lineContent)) 2757 | } 2758 | header += " " + helpStyle.Render("n=next N=prev") 2759 | } 2760 | 2761 | return header 2762 | } 2763 | 2764 | func (m model) getBreadcrumb() string { 2765 | parts := []string{"LazyHelm"} 2766 | 2767 | // Cluster Releases navigation 2768 | if m.state >= stateClusterReleasesMenu && m.state <= stateReleaseValues { 2769 | parts = append(parts, "Cluster Releases") 2770 | 2771 | if m.state == stateNamespaceList { 2772 | parts = append(parts, "Select Namespace") 2773 | } 2774 | 2775 | if m.state >= stateReleaseList && m.selectedNamespace != "" { 2776 | parts = append(parts, m.selectedNamespace) 2777 | } 2778 | 2779 | if m.state >= stateReleaseList && m.selectedRelease < len(m.releases) { 2780 | parts = append(parts, m.releases[m.selectedRelease].Name) 2781 | } 2782 | 2783 | if m.state == stateReleaseHistory { 2784 | parts = append(parts, "history") 2785 | } 2786 | 2787 | if m.state == stateReleaseValues { 2788 | if m.selectedRevision > 0 { 2789 | parts = append(parts, fmt.Sprintf("revision %d", m.selectedRevision)) 2790 | } 2791 | parts = append(parts, "values") 2792 | } 2793 | 2794 | return strings.Join(parts, " > ") 2795 | } 2796 | 2797 | // Artifact Hub navigation 2798 | if m.state == stateArtifactHubSearch { 2799 | parts = append(parts, "Artifact Hub") 2800 | return strings.Join(parts, " > ") 2801 | } 2802 | 2803 | if m.state == stateArtifactHubPackageDetail && m.ahSelectedPackage != nil { 2804 | parts = append(parts, "Artifact Hub", m.ahSelectedPackage.Name) 2805 | return strings.Join(parts, " > ") 2806 | } 2807 | 2808 | if m.state == stateArtifactHubVersions && m.ahSelectedPackage != nil { 2809 | parts = append(parts, "Artifact Hub", m.ahSelectedPackage.Name, "Versions") 2810 | return strings.Join(parts, " > ") 2811 | } 2812 | 2813 | // Regular Helm navigation 2814 | if m.selectedRepo < len(m.repos) { 2815 | parts = append(parts, m.repos[m.selectedRepo].Name) 2816 | } 2817 | 2818 | if m.state >= stateChartList && m.selectedChart < len(m.charts) { 2819 | name := m.charts[m.selectedChart].Name 2820 | if m.selectedRepo < len(m.repos) { 2821 | name = strings.TrimPrefix(name, m.repos[m.selectedRepo].Name+"/") 2822 | } 2823 | parts = append(parts, name) 2824 | } 2825 | 2826 | if m.state >= stateChartDetail && m.selectedVersion < len(m.versions) { 2827 | parts = append(parts, "v"+m.versions[m.selectedVersion].Version) 2828 | } 2829 | 2830 | if m.state == stateValueViewer { 2831 | parts = append(parts, "values") 2832 | } 2833 | 2834 | if m.state == stateDiffViewer { 2835 | parts = append(parts, "diff") 2836 | } 2837 | 2838 | return strings.Join(parts, " > ") 2839 | } 2840 | 2841 | func (m model) renderMainMenu() string { 2842 | return activePanelStyle.Render(m.mainMenu.View()) 2843 | } 2844 | 2845 | func (m model) renderBrowseMenu() string { 2846 | return activePanelStyle.Render(m.browseMenu.View()) 2847 | } 2848 | 2849 | func (m model) renderRepoList() string { 2850 | if len(m.repos) == 0 { 2851 | return "No repositories found.\nPress 'a' to add a repository.\n\nPress 'q' to quit\n" 2852 | } 2853 | return activePanelStyle.Render(m.repoList.View()) 2854 | } 2855 | 2856 | func (m model) renderChartList() string { 2857 | if m.loading { 2858 | return "Loading charts..." 2859 | } 2860 | if len(m.charts) == 0 { 2861 | return "No charts found." 2862 | } 2863 | return activePanelStyle.Render(m.chartList.View()) 2864 | } 2865 | 2866 | func (m model) renderChartDetail() string { 2867 | if m.loading { 2868 | return activePanelStyle.Render("Loading versions...") 2869 | } 2870 | if len(m.versions) == 0 { 2871 | return activePanelStyle.Render("No versions found.") 2872 | } 2873 | 2874 | if m.diffMode { 2875 | selectedVersion := "unknown" 2876 | if m.compareVersion < len(m.versions) { 2877 | selectedVersion = "v" + m.versions[m.compareVersion].Version 2878 | } 2879 | diffMsg := fmt.Sprintf(" Diff mode: First version = %s | Select second version to compare ", selectedVersion) 2880 | return infoStyle.Render(diffMsg) + "\n\n" + activePanelStyle.Render(m.versionList.View()) 2881 | } 2882 | 2883 | return activePanelStyle.Render(m.versionList.View()) 2884 | } 2885 | 2886 | func (m model) renderValueViewer() string { 2887 | if m.loadingVals { 2888 | return activePanelStyle.Render("Loading values...") 2889 | } 2890 | if m.values == "" { 2891 | return activePanelStyle.Render("No values available.") 2892 | } 2893 | 2894 | var header string 2895 | 2896 | // Show horizontal scroll indicator if scrolled 2897 | if m.horizontalOffset > 0 { 2898 | scrollInfo := fmt.Sprintf(" ← Scrolled %d chars | use ←/→ or h/l to scroll ", m.horizontalOffset) 2899 | header = helpStyle.Render(scrollInfo) + "\n\n" 2900 | } 2901 | 2902 | if header != "" { 2903 | return header + activePanelStyle.Render(m.valuesView.View()) 2904 | } 2905 | 2906 | return activePanelStyle.Render(m.valuesView.View()) 2907 | } 2908 | 2909 | func (m model) renderDiffViewer() string { 2910 | return activePanelStyle.Render(m.diffView.View()) 2911 | } 2912 | 2913 | func (m model) renderDiffContent(diffLines []ui.DiffLine, version1, version2 string) string { 2914 | // Detect if we're comparing revisions (starts with "rev") or versions 2915 | var label1, label2 string 2916 | if len(version1) > 3 && version1[:3] == "rev" { 2917 | // Release revision 2918 | label1 = "Revision " + version1[3:] 2919 | label2 = "Revision " + version2[3:] 2920 | } else { 2921 | // Chart version 2922 | label1 = "v" + version1 2923 | label2 = "v" + version2 2924 | } 2925 | 2926 | header := fmt.Sprintf("Comparing %s (old) → %s (new)\n", label1, label2) 2927 | header += fmt.Sprintf("Showing only changes (%d lines)\n\n", len(diffLines)) 2928 | 2929 | var content strings.Builder 2930 | content.WriteString(header) 2931 | 2932 | for _, line := range diffLines { 2933 | switch line.Type { 2934 | case "added": 2935 | content.WriteString(addedStyle.Render("+ " + line.Line)) 2936 | case "removed": 2937 | content.WriteString(removedStyle.Render("- " + line.Line)) 2938 | case "unchanged": 2939 | content.WriteString(" " + line.Line) 2940 | } 2941 | content.WriteString("\n") 2942 | } 2943 | 2944 | return content.String() 2945 | } 2946 | 2947 | func (m model) renderHelp() string { 2948 | help := "\n LazyHelm - Help\n\n" 2949 | 2950 | help += " Navigation:\n" 2951 | help += " ↑/k, ↓/j Move up/down\n" 2952 | help += " ←, → Scroll left/right (in values view)\n" 2953 | help += " enter Select item / Go deeper\n" 2954 | help += " esc Go back to previous screen\n" 2955 | help += " q Quit application\n" 2956 | help += " ? Toggle this help screen\n\n" 2957 | 2958 | help += " Search & Filter:\n" 2959 | help += " / Search/filter in current view\n" 2960 | help += " c Clear search filter\n" 2961 | help += " n Next search result\n" 2962 | help += " N Previous search result\n\n" 2963 | 2964 | help += " Repository Management:\n" 2965 | help += " a Add new repository\n" 2966 | help += " r Remove selected repository\n" 2967 | help += " u Update repository index (helm repo update)\n" 2968 | help += " s Search Artifact Hub\n\n" 2969 | 2970 | help += " Chart & Version Actions:\n" 2971 | help += " v View all versions (in chart list)\n" 2972 | help += " d Diff two versions (select first, then second)\n\n" 2973 | 2974 | help += " Cluster Releases:\n" 2975 | help += " v View release values (in release list)\n" 2976 | help += " h View release history & revisions\n" 2977 | help += " d Diff two revisions (select first, then second)\n" 2978 | help += " w Export release values to file\n\n" 2979 | 2980 | help += " Values View:\n" 2981 | help += " e Edit values in external editor ($EDITOR)\n" 2982 | help += " w Write/export values to file\n" 2983 | help += " t Generate Helm template\n" 2984 | help += " y Copy YAML path to clipboard\n" 2985 | help += " ←/→ Scroll horizontally for long lines\n\n" 2986 | 2987 | help += " Tips:\n" 2988 | help += " • Horizontal scroll: Lines ending with → continue beyond screen\n" 2989 | help += " • Search shows match count and current YAML path\n" 2990 | help += " • Editor: Uses $EDITOR/$VISUAL, falls back to nvim→vim→vi\n" 2991 | help += " • Diff: Press d on first version, enter on second to compare\n" 2992 | help += " • YAML validation happens automatically when editing\n\n" 2993 | 2994 | help += " Press ? or esc to close this help\n" 2995 | return help 2996 | } 2997 | 2998 | func (m model) renderInputPrompt() string { 2999 | var prompt string 3000 | switch m.mode { 3001 | case searchMode: 3002 | prompt = "Search: " + m.searchInput.View() 3003 | case addRepoMode: 3004 | if m.addRepoStep == 0 { 3005 | prompt = "Repository name: " + m.searchInput.View() 3006 | } else { 3007 | prompt = "Repository URL: " + m.searchInput.View() 3008 | } 3009 | case exportValuesMode: 3010 | prompt = "Export to: " + m.searchInput.View() 3011 | case templatePathMode: 3012 | prompt = "Output directory: " + m.searchInput.View() 3013 | case templateValuesMode: 3014 | prompt = "Values file (optional): " + m.searchInput.View() 3015 | case saveEditMode: 3016 | prompt = "Save to: " + m.searchInput.View() 3017 | case confirmRemoveRepoMode: 3018 | prompt = m.searchInput.Placeholder + " " + m.searchInput.View() 3019 | default: 3020 | return "" 3021 | } 3022 | return searchInputStyle.Render(" " + prompt + " ") 3023 | } 3024 | 3025 | func openEditorCmd(content string) tea.Cmd { 3026 | // Get editor from environment, fallback to nvim/vim/vi 3027 | editor := os.Getenv("EDITOR") 3028 | if editor == "" { 3029 | editor = os.Getenv("VISUAL") 3030 | } 3031 | if editor == "" { 3032 | // Try to find nvim, vim, then vi in that order 3033 | for _, cmd := range []string{"nvim", "vim", "vi"} { 3034 | if path, err := exec.LookPath(cmd); err == nil { 3035 | editor = path 3036 | break 3037 | } 3038 | } 3039 | } 3040 | if editor == "" { 3041 | return func() tea.Msg { 3042 | return editorFinishedMsg{err: fmt.Errorf("no editor found (tried nvim, vim, vi)")} 3043 | } 3044 | } 3045 | 3046 | // Parse editor command (might have flags like "code --wait") 3047 | editorParts := strings.Fields(editor) 3048 | if len(editorParts) == 0 { 3049 | return func() tea.Msg { 3050 | return editorFinishedMsg{err: fmt.Errorf("invalid editor command")} 3051 | } 3052 | } 3053 | 3054 | // Create temp file with .yaml extension for proper syntax highlighting 3055 | tmpfile, err := os.CreateTemp("", "lazyhelm-values-*.yaml") 3056 | if err != nil { 3057 | return func() tea.Msg { 3058 | return editorFinishedMsg{err: fmt.Errorf("failed to create temp file: %w", err)} 3059 | } 3060 | } 3061 | tmpPath := tmpfile.Name() 3062 | 3063 | // Write content to temp file 3064 | if _, err := tmpfile.Write([]byte(content)); err != nil { 3065 | tmpfile.Close() 3066 | os.Remove(tmpPath) 3067 | return func() tea.Msg { 3068 | return editorFinishedMsg{err: fmt.Errorf("failed to write temp file: %w", err)} 3069 | } 3070 | } 3071 | tmpfile.Close() 3072 | 3073 | // Build command with editor and its args plus the temp file 3074 | args := append(editorParts[1:], tmpPath) 3075 | c := exec.Command(editorParts[0], args...) 3076 | 3077 | // Return tea.ExecProcess directly to properly handle terminal control 3078 | return tea.ExecProcess(c, func(err error) tea.Msg { 3079 | // This callback runs after the editor exits 3080 | if err != nil { 3081 | os.Remove(tmpPath) 3082 | return editorFinishedMsg{err: fmt.Errorf("editor failed: %w", err), filePath: tmpPath} 3083 | } 3084 | 3085 | // Read edited content 3086 | editedContent, readErr := os.ReadFile(tmpPath) 3087 | if readErr != nil { 3088 | os.Remove(tmpPath) 3089 | return editorFinishedMsg{err: fmt.Errorf("failed to read edited file: %w", readErr), filePath: tmpPath} 3090 | } 3091 | 3092 | // Don't remove the file yet - we'll do it after saving 3093 | return editorFinishedMsg{content: string(editedContent), filePath: tmpPath, err: nil} 3094 | }) 3095 | } 3096 | 3097 | func (m model) renderArtifactHubSearch() string { 3098 | if m.ahLoading { 3099 | return activePanelStyle.Render("Searching Artifact Hub...") 3100 | } 3101 | if len(m.ahPackages) == 0 { 3102 | return activePanelStyle.Render("No packages found.\nTry a different search query.\n\nPress 'esc' to go back") 3103 | } 3104 | 3105 | hint := "\n" + helpStyle.Render(" enter: view details | a: add repository | esc: back ") 3106 | return activePanelStyle.Render(m.ahPackageList.View()) + hint 3107 | } 3108 | 3109 | func (m model) renderArtifactHubPackageDetail() string { 3110 | if m.ahLoading { 3111 | return activePanelStyle.Render("Loading package details...") 3112 | } 3113 | 3114 | if m.ahSelectedPackage == nil { 3115 | return activePanelStyle.Render("No package selected") 3116 | } 3117 | 3118 | pkg := m.ahSelectedPackage 3119 | 3120 | // Build info panel - full screen 3121 | info := lipgloss.NewStyle(). 3122 | Padding(1, 2). 3123 | Border(lipgloss.RoundedBorder()). 3124 | BorderForeground(lipgloss.Color("141")). 3125 | Width(m.termWidth - 8). 3126 | Render(fmt.Sprintf( 3127 | "%s %s\n\n"+ 3128 | "Repository: %s\n"+ 3129 | "URL: %s\n"+ 3130 | "Latest Version: %s\n"+ 3131 | "App Version: %s\n"+ 3132 | "Stars: ⭐%d\n"+ 3133 | "Security: %s\n"+ 3134 | "Signed: %s\n\n"+ 3135 | "%s\n\n"+ 3136 | "Available versions: %d", 3137 | pkg.Name, 3138 | pkg.GetBadges(), 3139 | pkg.Repository.DisplayName, 3140 | pkg.Repository.URL, 3141 | pkg.Version, 3142 | pkg.AppVersion, 3143 | pkg.Stars, 3144 | pkg.SecurityReport.GetSecurityBadge(), 3145 | func() string { 3146 | if pkg.Signed { 3147 | return "🔒 Yes" 3148 | } 3149 | return "No" 3150 | }(), 3151 | pkg.Description, 3152 | len(pkg.AvailableVersions), 3153 | )) 3154 | 3155 | hint := "\n" + helpStyle.Render(" a: add repository | v: view versions | esc: back ") 3156 | 3157 | return info + hint 3158 | } 3159 | 3160 | func (m model) renderArtifactHubVersions() string { 3161 | if len(m.ahSelectedPackage.AvailableVersions) == 0 { 3162 | return activePanelStyle.Render("No versions available") 3163 | } 3164 | 3165 | hint := "\n" + helpStyle.Render(" a: add repository to view values | esc: back ") 3166 | return activePanelStyle.Render(m.ahVersionList.View()) + hint 3167 | } 3168 | 3169 | func (m model) renderClusterReleasesMenu() string { 3170 | return activePanelStyle.Render(m.clusterReleasesMenu.View()) 3171 | } 3172 | 3173 | func (m model) renderNamespaceList() string { 3174 | if m.loading { 3175 | return "Loading namespaces..." 3176 | } 3177 | if len(m.namespaces) == 0 { 3178 | return "No namespaces with Helm releases found." 3179 | } 3180 | return activePanelStyle.Render(m.namespaceList.View()) 3181 | } 3182 | 3183 | func (m model) renderReleaseList() string { 3184 | if m.loading { 3185 | return "Loading releases..." 3186 | } 3187 | if len(m.releases) == 0 { 3188 | return "No releases found." 3189 | } 3190 | 3191 | var header string 3192 | if m.selectedNamespace == "" { 3193 | header = infoStyle.Render(" Showing releases from all namespaces ") + "\n\n" 3194 | } else { 3195 | header = infoStyle.Render(fmt.Sprintf(" Namespace: %s ", m.selectedNamespace)) + "\n\n" 3196 | } 3197 | 3198 | return header + activePanelStyle.Render(m.releaseList.View()) 3199 | } 3200 | 3201 | func (m *model) updateReleaseDetailView() { 3202 | if m.selectedRelease >= len(m.releases) { 3203 | return 3204 | } 3205 | 3206 | release := m.releases[m.selectedRelease] 3207 | var content strings.Builder 3208 | 3209 | // Release header 3210 | content.WriteString(infoStyle.Render(fmt.Sprintf(" Release: %s ", release.Name)) + "\n\n") 3211 | 3212 | // Status section 3213 | if m.releaseStatus != nil { 3214 | content.WriteString("Status: " + m.releaseStatus.Status + "\n") 3215 | if m.releaseStatus.Description != "" { 3216 | content.WriteString("Description: " + m.releaseStatus.Description + "\n") 3217 | } 3218 | content.WriteString("\n") 3219 | } 3220 | 3221 | // Release info 3222 | content.WriteString(fmt.Sprintf("Namespace: %s\n", release.Namespace)) 3223 | content.WriteString(fmt.Sprintf("Chart: %s\n", release.Chart)) 3224 | content.WriteString(fmt.Sprintf("App Version: %s\n", release.AppVersion)) 3225 | content.WriteString(fmt.Sprintf("Updated: %s\n", release.Updated)) 3226 | content.WriteString("\n") 3227 | 3228 | // History section 3229 | content.WriteString("Revision History:\n") 3230 | if len(m.releaseHistory) > 0 { 3231 | for _, rev := range m.releaseHistory { 3232 | revStr := fmt.Sprintf(" Revision %d - %s (%s) - %s\n", 3233 | rev.Revision, rev.Status, rev.Chart, rev.Updated) 3234 | content.WriteString(revStr) 3235 | } 3236 | } else { 3237 | content.WriteString(" Loading...\n") 3238 | } 3239 | content.WriteString("\n") 3240 | 3241 | // Notes section 3242 | if m.releaseStatus != nil && m.releaseStatus.Notes != "" { 3243 | content.WriteString("Notes:\n") 3244 | // Indent each line of notes 3245 | noteLines := strings.Split(m.releaseStatus.Notes, "\n") 3246 | for _, line := range noteLines { 3247 | content.WriteString(" " + line + "\n") 3248 | } 3249 | content.WriteString("\n") 3250 | } 3251 | 3252 | content.WriteString(helpStyle.Render(" v: view current values | h: interactive history | esc: back ")) 3253 | 3254 | // Apply horizontal scrolling 3255 | lines := strings.Split(content.String(), "\n") 3256 | viewportWidth := m.releaseDetailView.Width 3257 | if viewportWidth <= 0 { 3258 | viewportWidth = m.termWidth - 6 3259 | } 3260 | 3261 | scrolledLines := make([]string, len(lines)) 3262 | for i, line := range lines { 3263 | visibleLine := line 3264 | hasMore := false 3265 | 3266 | if len(line) > m.horizontalOffset { 3267 | visibleLine = line[m.horizontalOffset:] 3268 | 3269 | if len(visibleLine) > viewportWidth-3 { 3270 | visibleLine = visibleLine[:viewportWidth-3] 3271 | hasMore = true 3272 | } 3273 | } else { 3274 | visibleLine = "" 3275 | } 3276 | 3277 | if hasMore { 3278 | scrolledLines[i] = visibleLine + " →" 3279 | } else { 3280 | scrolledLines[i] = visibleLine 3281 | } 3282 | } 3283 | 3284 | m.releaseDetailView.SetContent(strings.Join(scrolledLines, "\n")) 3285 | } 3286 | 3287 | func (m model) renderReleaseDetail() string { 3288 | if m.loading { 3289 | return activePanelStyle.Render("Loading release details...") 3290 | } 3291 | 3292 | if m.selectedRelease >= len(m.releases) { 3293 | return activePanelStyle.Render("No release selected.") 3294 | } 3295 | 3296 | var header string 3297 | if m.horizontalOffset > 0 { 3298 | scrollInfo := fmt.Sprintf(" ← Scrolled %d chars | use ←/→ to scroll ", m.horizontalOffset) 3299 | header = helpStyle.Render(scrollInfo) + "\n\n" 3300 | } 3301 | 3302 | if header != "" { 3303 | return header + activePanelStyle.Render(m.releaseDetailView.View()) 3304 | } 3305 | 3306 | return activePanelStyle.Render(m.releaseDetailView.View()) 3307 | } 3308 | 3309 | func (m model) renderReleaseHistory() string { 3310 | if m.loading { 3311 | return activePanelStyle.Render("Loading revision history...") 3312 | } 3313 | if len(m.releaseHistory) == 0 { 3314 | return activePanelStyle.Render("No revision history found.") 3315 | } 3316 | 3317 | if m.diffMode { 3318 | selectedRevision := "unknown" 3319 | if m.compareRevision < len(m.releaseHistory) { 3320 | selectedRevision = fmt.Sprintf("Revision %d", m.releaseHistory[m.compareRevision].Revision) 3321 | } 3322 | diffMsg := fmt.Sprintf(" Diff mode: First revision = %s | Select second revision to compare ", selectedRevision) 3323 | return infoStyle.Render(diffMsg) + "\n\n" + activePanelStyle.Render(m.releaseHistoryList.View()) 3324 | } 3325 | 3326 | hint := "\n" + helpStyle.Render(" Select a revision to view its values | esc: back ") 3327 | return activePanelStyle.Render(m.releaseHistoryList.View()) + hint 3328 | } 3329 | 3330 | func (m model) renderReleaseValues() string { 3331 | if m.loadingVals { 3332 | return activePanelStyle.Render("Loading values...") 3333 | } 3334 | if m.releaseValues == "" { 3335 | return activePanelStyle.Render("No values available.") 3336 | } 3337 | 3338 | var header string 3339 | // Show which revision we're viewing 3340 | if m.selectedRevision > 0 { 3341 | header = infoStyle.Render(fmt.Sprintf(" Revision %d Values ", m.selectedRevision)) + "\n\n" 3342 | } 3343 | 3344 | // Show horizontal scroll indicator if scrolled 3345 | if m.horizontalOffset > 0 { 3346 | scrollInfo := fmt.Sprintf(" ← Scrolled %d chars | use ←/→ or h/l to scroll ", m.horizontalOffset) 3347 | header += helpStyle.Render(scrollInfo) + "\n\n" 3348 | } 3349 | 3350 | if header != "" { 3351 | return header + activePanelStyle.Render(m.releaseValuesView.View()) 3352 | } 3353 | 3354 | return activePanelStyle.Render(m.releaseValuesView.View()) 3355 | } 3356 | 3357 | func main() { 3358 | // Check for version flag 3359 | if len(os.Args) > 1 { 3360 | arg := os.Args[1] 3361 | if arg == "--version" || arg == "-v" || arg == "version" { 3362 | fmt.Printf("lazyhelm version %s\n", version) 3363 | fmt.Printf("commit: %s\n", commit) 3364 | fmt.Printf("built: %s\n", date) 3365 | os.Exit(0) 3366 | } 3367 | if arg == "--help" || arg == "-h" || arg == "help" { 3368 | fmt.Println("LazyHelm - A fast, intuitive Terminal User Interface (TUI) for managing Helm charts") 3369 | fmt.Println() 3370 | fmt.Println("Usage:") 3371 | fmt.Println(" lazyhelm Start the TUI") 3372 | fmt.Println(" lazyhelm --version Show version information") 3373 | fmt.Println(" lazyhelm --help Show this help message") 3374 | fmt.Println() 3375 | fmt.Println("For more information, visit: https://github.com/alessandropitocchi/lazyhelm") 3376 | os.Exit(0) 3377 | } 3378 | } 3379 | 3380 | p := tea.NewProgram( 3381 | initialModel(), 3382 | tea.WithAltScreen(), 3383 | tea.WithMouseCellMotion(), 3384 | ) 3385 | 3386 | if _, err := p.Run(); err != nil { 3387 | fmt.Printf("Error: %v", err) 3388 | os.Exit(1) 3389 | } 3390 | } 3391 | --------------------------------------------------------------------------------