├── .github ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── documentation_update.yml │ └── feature_request.yml └── workflows │ └── run-tests.yaml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── code_of_conduct.md ├── components └── table.go ├── demos ├── demo.tape ├── hub.gif ├── hub.tape ├── overview.gif ├── repositories.gif └── repositories.tape ├── go.mod ├── go.sum ├── helpers ├── constants.go ├── editor.go ├── keymaps.go ├── keymaps_test.go └── logging.go ├── hub ├── hub.go ├── hub_commands.go ├── hub_keymap.go ├── hub_test.go └── hub_view.go ├── install-binary.sh ├── main.go ├── plugin.yaml ├── plugins ├── overview.go ├── overview_commands.go ├── overview_keymap.go ├── overview_test.go └── overview_view.go ├── releases ├── install.go ├── install_commands.go ├── install_keymap.go ├── install_test.go ├── install_view.go ├── overview.go ├── overview_commands.go ├── overview_keymap.go ├── overview_test.go ├── overview_view.go ├── upgrade.go ├── upgrade_commands.go ├── upgrade_keymap.go ├── upgrade_test.go └── upgrade_view.go ├── repositories ├── add.go ├── add_commands.go ├── add_keymap.go ├── add_test.go ├── add_view.go ├── install.go ├── install_commands.go ├── install_keymap.go ├── install_test.go ├── install_view.go ├── overview.go ├── overview_commands.go ├── overview_keymap.go ├── overview_test.go └── overview_view.go ├── styles ├── helpers.go └── styles.go ├── types ├── helm.go └── messages.go └── ui.go /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug or unexpected behavior in the project. 3 | title: "[Bug]: " 4 | labels: ["bug", "triage"] 5 | projects: ["octo-org/1", "octo-org/44"] 6 | assignees: 7 | - octocat 8 | body: 9 | - type: markdown 10 | attributes: 11 | value: | 12 | Thanks for taking the time to fill out this bug report! 13 | - type: input 14 | id: contact 15 | attributes: 16 | label: Contact Details 17 | description: How can we get in touch with you if we need more info? 18 | placeholder: ex. email@example.com, github handle, etc. 19 | validations: 20 | required: false 21 | - type: textarea 22 | id: what-happened 23 | attributes: 24 | label: what went wrong? 25 | description: Also tell us, what did you expect to happen? 26 | placeholder: Tell us what you see! 27 | value: "A bug was encountered!" 28 | validations: 29 | required: true 30 | - type: textarea 31 | id: how-to-reproduce 32 | attributes: 33 | label: Detail steps on how to reproduce 34 | placeholder: | 35 | Step 1: ..., Step 2: ... 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: Version 40 | attributes: 41 | label: App Version 42 | description: What version of our software are you running. 43 | placeholder: e.g., v1.0.0 44 | validations: 45 | required: true 46 | - type: textarea 47 | id: hardware-config 48 | attributes: 49 | label: Hardware Configuration 50 | description: Describe your hardware-config, including the operating system, CPU, RAM, etc. 51 | placeholder: e.g., Windows 10, Intel i5, 8GB RAM 52 | validations: 53 | required: true 54 | - type: textarea 55 | id: terminal-shell 56 | attributes: 57 | label: Terminal and Shell Type 58 | description: Specify the terminal and shell you are using to run the project. 59 | placeholder: e.g., Windows Command Prompt, PowerShell, Bash 60 | validations: 61 | required: true 62 | - type: textarea 63 | id: logs 64 | attributes: 65 | label: Relevant log output 66 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 67 | render: shell 68 | - type: checkboxes 69 | id: terms 70 | attributes: 71 | label: Code of Conduct 72 | description: By submitting this issue, you agree to follow our [Code of Conduct](./code_of_conduct.md). 73 | options: 74 | - label: I agree to follow this project's Code of Conduct 75 | required: true 76 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation_update.yml: -------------------------------------------------------------------------------- 1 | name: Documentation Update 2 | description: Propose an update or fix to the project's documentation. 3 | labels: [documentation] 4 | projects: ["octo-org/1", "octo-org/44"] 5 | assignees: 6 | - octocat 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this report! 12 | - type: input 13 | id: contact 14 | attributes: 15 | label: Contact Details 16 | description: How can we get in touch with you if we need more info? 17 | placeholder: ex. email@example.com, github handle, etc. 18 | validations: 19 | required: false 20 | - type: textarea 21 | id: documentation-section 22 | attributes: 23 | label: Documentation section 24 | placeholder: Which section of the documentation needs updating? 25 | - type: textarea 26 | id: suggested-improvement 27 | attributes: 28 | label: Suggested improvement/update 29 | placeholder: Details of proposed update(s). 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature or enhancement for the project. 3 | labels: ["enhancement", triage"] 4 | projects: ["octo-org/1", "octo-org/44"] 5 | assignees: 6 | - octocat 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this report! 12 | - type: input 13 | id: contact 14 | attributes: 15 | label: Contact Details 16 | description: How can we get in touch with you if we need more info? 17 | placeholder: ex. email@example.com, github handle, etc. 18 | validations: 19 | required: false 20 | - type: markdown 21 | attributes: 22 | value: | 23 | Thank you for suggesting a new feature! Please answer the following questions. 24 | - type: textarea 25 | id: feature-description 26 | attributes: 27 | label: Feature description 28 | placeholder: What is the feature you are proposing? 29 | - type: textarea 30 | id: motivation 31 | attributes: 32 | label: feature value 33 | placeholder: Why is this feature important? 34 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yaml: -------------------------------------------------------------------------------- 1 | name: 🛠️ Build, Test & Scan 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build-go: 13 | name: 🐹 Build Go Project 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: 📥 Checkout Code 18 | uses: actions/checkout@v4 19 | 20 | - name: 🐹 Set Up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: "1.22" 24 | 25 | - name: 📦 Install Go Dependencies 26 | run: go mod tidy 27 | 28 | - name: 🛠️ Build Go Project 29 | run: go build -o helm-tui . 30 | 31 | test-go: 32 | name: ✅ Run Go Tests 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - name: 📥 Checkout Code 37 | uses: actions/checkout@v4 38 | 39 | - name: 🐹 Set Up Go 40 | uses: actions/setup-go@v5 41 | with: 42 | go-version: "1.22" 43 | 44 | - name: 📦 Install Go Dependencies 45 | run: go mod tidy 46 | 47 | - name: ✅ Run Go Tests with Coverage 48 | run: go test ./... 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | debug.log 2 | charts/* 3 | .DS_Store 4 | 5 | dist/ 6 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | builds: 19 | - env: 20 | - CGO_ENABLED=0 21 | goos: 22 | - linux 23 | - windows 24 | - darwin 25 | 26 | archives: 27 | - format: tar.gz 28 | # this name template makes the OS and Arch compatible with the results of `uname`. 29 | name_template: >- 30 | {{ .ProjectName }}_ 31 | {{- title .Os }}_ 32 | {{- if eq .Arch "amd64" }}x86_64 33 | {{- else if eq .Arch "386" }}i386 34 | {{- else }}{{ .Arch }}{{ end }} 35 | {{- if .Arm }}v{{ .Arm }}{{ end }} 36 | # use zip for windows archives 37 | format_overrides: 38 | - goos: windows 39 | format: zip 40 | 41 | changelog: 42 | sort: asc 43 | filters: 44 | exclude: 45 | - "^docs:" 46 | - "^test:" 47 | 48 | release: 49 | footer: >- 50 | 51 | --- 52 | 53 | Released by [GoReleaser](https://github.com/goreleaser/goreleaser). 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Pidanou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Helm-tui 2 | 3 | # Go build settings 4 | BINARY_NAME = helm-tui 5 | SOURCE_DIR = . 6 | BUILD_DIR = ./bin 7 | 8 | # Go commands 9 | GO = go 10 | GOFMT = gofmt 11 | GOTEST = go test 12 | GOBUILD = go build 13 | 14 | # Default target 15 | all: build 16 | 17 | # Clean build directory 18 | clean: 19 | rm -rf $(BUILD_DIR) 20 | 21 | # Format Go source files 22 | fmt: 23 | $(GOFMT) -w $(SOURCE_DIR) 24 | 25 | # Build the project 26 | build: clean fmt 27 | $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) $(SOURCE_DIR) 28 | 29 | # Run the project 30 | run: build 31 | $(BUILD_DIR)/$(BINARY_NAME) 32 | 33 | # Run tests 34 | test: 35 | $(GOTEST) ./... 36 | 37 | # Install dependencies 38 | deps: 39 | $(GO) mod tidy 40 | 41 | # Help message 42 | help: 43 | @echo "Available targets:" 44 | @echo " clean - Clean the build directory" 45 | @echo " fmt - Format the Go source code" 46 | @echo " build - Build the project" 47 | @echo " run - Run the built binary" 48 | @echo " test - Run tests" 49 | @echo " deps - Install and tidy dependencies" 50 | @echo " help - Show this help message" 51 | 52 | .PHONY: all clean fmt build run test deps help 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Helm-tui 2 | 3 | Demo of Soramail 4 | 5 | Helm-tui is a terminal-based UI application to manage your Helm releases, charts, repositories, and plugins with ease. 6 | 7 | ## Features 8 | 9 | - Manage Helm releases effortlessly. 10 | - Add, update, and remove Helm repositories. 11 | 12 | ## Requirements 13 | 14 | - [Helm 3](https://helm.sh/docs/intro/install/) 15 | 16 | ### Optional 17 | 18 | - [Go 1.22+](https://go.dev/doc/install) 19 | 20 | ## How to Use 21 | 22 | 1. Clone the repository: 23 | 24 | ```bash 25 | git clone https://github.com/pidanou/helm-tui.git 26 | cd helm-tui 27 | ``` 28 | 29 | 2. Run the app directly using: 30 | ```bash 31 | go run . 32 | ``` 33 | 34 | ## How to Install 35 | 36 | ### Install Helm-tui using `helm plugin install`: 37 | 38 | ```bash 39 | helm plugin install https://github.com/pidanou/helm-tui 40 | ``` 41 | 42 | Once installed, you can run `helm tui` directly from your terminal. 43 | 44 | 45 | ### Install using Go: 46 | 47 | ```bash 48 | go install https://github.com/pidanou/helm-tui@latest 49 | ``` 50 | 51 | Once installed, you can run `helm-tui` directly from your terminal. 52 | 53 | ## Contributing 54 | 55 | Contributions are welcome! If you find bugs or have feature requests, feel free to open an issue or submit a pull request. 56 | 57 | ## License 58 | 59 | This project is licensed under the [MIT License](LICENSE). 60 | -------------------------------------------------------------------------------- /code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | ## Our Pledge 3 | 4 | We, as contributors and maintainers of this project, pledge to foster an open, welcoming, and inclusive environment. We are committed to ensuring that participation in this project is free from harassment and discrimination for everyone, regardless of their background, experience, or personal identity. 5 | 6 | ## Our Standards 7 | 8 | **Examples of behavior that contribute to a positive environment**: 9 | 10 | - Being respectful and considerate in all interactions. 11 | - Providing constructive feedback and accepting it graciously. 12 | - Collaborating with empathy and patience. 13 | - Respecting different viewpoints and experiences. 14 | 15 | **Examples of unacceptable behavior**: 16 | 17 | - Harassment, discrimination, or derogatory comments. 18 | - Personal or political attacks. 19 | - Public or private harassment. 20 | - Any other behavior deemed inappropriate for a professional setting. 21 | 22 | ## Our Responsibilities 23 | 24 | Maintainers are responsible for enforcing this Code of Conduct and have the right to: 25 | 26 | Remove, edit, or reject comments, commits, or contributions that violate these standards. 27 | Ban contributors whose behavior is deemed inappropriate, harmful, or unethical. 28 | Scope 29 | This Code of Conduct applies to all project spaces, including repositories, issue trackers, and communications (both public and private) related to the project. 30 | 31 | ## Enforcement 32 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the maintainers at [your_email@example.com]. 33 | All complaints will be reviewed and investigated promptly and fairly. 34 | 35 | ## Acknowledgments 36 | This Code of Conduct is adapted from the Contributor Covenant, version 2.1. -------------------------------------------------------------------------------- /components/table.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/table" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/pidanou/helm-tui/styles" 8 | ) 9 | 10 | type ColumnDefinition struct { 11 | Title string 12 | Width int 13 | FlexFactor int 14 | } 15 | 16 | func SetTable(t *table.Model, cols []ColumnDefinition, targetWidth int) tea.Cmd { 17 | var columns = make([]table.Column, len(cols)) 18 | targetWidth = targetWidth - 2 // remove the border Width 19 | remainingWidthAfterFixed := targetWidth 20 | totalFlex := 0 21 | for _, col := range cols { 22 | if col.FlexFactor != 0 { 23 | totalFlex += col.FlexFactor 24 | } 25 | } 26 | for i, col := range cols { 27 | if col.Width != 0 { 28 | columns[i] = table.Column{ 29 | Title: col.Title, 30 | Width: col.Width, // remove cell padding 31 | } 32 | remainingWidthAfterFixed = remainingWidthAfterFixed - col.Width 33 | } 34 | } 35 | for i, col := range cols { 36 | if col.FlexFactor != 0 { 37 | columns[i] = table.Column{ 38 | Title: col.Title, 39 | Width: int(remainingWidthAfterFixed*col.FlexFactor/totalFlex) - 2, // -2 to remove the cell padding 40 | } 41 | } 42 | } 43 | // fill last column with the remaning Width due to integer division 44 | lastCol := columns[len(columns)-1] 45 | totalColWidth := 0 46 | for _, col := range columns { 47 | totalColWidth += col.Width + 2 // count the cell padding 48 | } 49 | lastCol.Width = lastCol.Width + targetWidth - totalColWidth 50 | columns[len(columns)-1] = lastCol 51 | t.SetColumns(columns) 52 | t.SetWidth(targetWidth) 53 | return nil 54 | } 55 | 56 | func GenerateTable() table.Model { 57 | t := table.New() 58 | s := table.DefaultStyles() 59 | k := table.DefaultKeyMap() 60 | k.HalfPageUp.Unbind() 61 | k.PageDown.Unbind() 62 | k.HalfPageDown.Unbind() 63 | k.HalfPageDown.Unbind() 64 | k.GotoBottom.Unbind() 65 | k.GotoTop.Unbind() 66 | s.Header = s.Header. 67 | BorderStyle(styles.Border). 68 | BorderForeground(lipgloss.Color("240")). 69 | BorderBottom(true). 70 | Bold(true) 71 | s.Selected = s.Selected. 72 | Foreground(lipgloss.Color("229")). 73 | Background(lipgloss.Color("57")). 74 | Bold(false) 75 | 76 | t.SetStyles(s) 77 | t.KeyMap = k 78 | return t 79 | } 80 | 81 | func RenderTable(t table.Model, height int, width int) string { 82 | var topBorder string 83 | t.SetHeight(height) 84 | t.SetWidth(width) 85 | view := t.View() 86 | var baseStyle lipgloss.Style 87 | topBorder = styles.GenerateTopBorderWithTitle(" Releases ", t.Width(), styles.Border, styles.InactiveStyle) 88 | baseStyle = styles.InactiveStyle.Border(styles.Border, false, true, true) 89 | view = baseStyle.Render(view) 90 | return lipgloss.JoinVertical(lipgloss.Left, topBorder, view) 91 | } 92 | -------------------------------------------------------------------------------- /demos/demo.tape: -------------------------------------------------------------------------------- 1 | Output overview.gif 2 | 3 | Require helm 4 | 5 | Set Shell "bash" 6 | Set FontSize 14 7 | Set Width 1200 8 | Set Height 600 9 | 10 | Type "helm tui" Sleep 300ms Enter 11 | Sleep 1s 12 | Enter 13 | Sleep 1s 14 | Right 15 | Sleep 1s 16 | Right 17 | Sleep 1s 18 | Right 19 | Sleep 1s 20 | Right 21 | Sleep 1s 22 | Right 23 | Sleep 1s 24 | Type "]" 25 | Sleep 1s 26 | Type "]" 27 | Sleep 1s 28 | Type "]" 29 | 30 | Sleep 5s 31 | -------------------------------------------------------------------------------- /demos/hub.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pidanou/helm-tui/87a4d620893720838cfe046c745a9be0782f8c39/demos/hub.gif -------------------------------------------------------------------------------- /demos/hub.tape: -------------------------------------------------------------------------------- 1 | Output hub.gif 2 | 3 | Require helm 4 | 5 | Set Shell "bash" 6 | Set FontSize 14 7 | Set Width 1200 8 | Set Height 600 9 | 10 | Type "helm tui" Sleep 300ms Enter 11 | Sleep 1s 12 | Type "]" 13 | Sleep 500ms 14 | Type "]" 15 | Sleep 1s 16 | Type "/" 17 | Sleep 500ms 18 | Type "prometheus" 19 | Sleep 500ms 20 | Enter 21 | Sleep 500ms 22 | Type "j" 23 | Sleep 500ms 24 | Type "a" 25 | Sleep 1s 26 | Type "bitnami" 27 | Sleep 500ms 28 | Enter 29 | Sleep 1s 30 | Type "[" 31 | 32 | Sleep 5s 33 | -------------------------------------------------------------------------------- /demos/overview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pidanou/helm-tui/87a4d620893720838cfe046c745a9be0782f8c39/demos/overview.gif -------------------------------------------------------------------------------- /demos/repositories.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pidanou/helm-tui/87a4d620893720838cfe046c745a9be0782f8c39/demos/repositories.gif -------------------------------------------------------------------------------- /demos/repositories.tape: -------------------------------------------------------------------------------- 1 | Output repositories.gif 2 | 3 | Require helm 4 | 5 | Set Shell "bash" 6 | Set FontSize 14 7 | Set Width 1200 8 | Set Height 600 9 | 10 | Type "helm tui" Sleep 300ms Enter 11 | Sleep 1s 12 | Type "]" 13 | Sleep 1s 14 | Type "a" 15 | Sleep 500ms 16 | Type "grafana" 17 | Sleep 500ms 18 | Enter 19 | Type "https://grafana.github.io/helm-charts" 20 | Sleep 500ms 21 | Enter 22 | Sleep 3s 23 | Type "D" 24 | 25 | Sleep 5s 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pidanou/helm-tui 2 | 3 | go 1.22.3 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v1.2.4 7 | github.com/stretchr/testify v1.10.0 8 | ) 9 | 10 | require ( 11 | github.com/atotto/clipboard v0.1.4 // indirect 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | 17 | require ( 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/charmbracelet/bubbles v0.20.0 20 | github.com/charmbracelet/lipgloss v1.0.0 21 | github.com/charmbracelet/x/ansi v0.4.5 // indirect 22 | github.com/charmbracelet/x/term v0.2.1 // indirect 23 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 24 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 25 | github.com/mattn/go-isatty v0.0.20 // indirect 26 | github.com/mattn/go-localereader v0.0.1 // indirect 27 | github.com/mattn/go-runewidth v0.0.16 // indirect 28 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 29 | github.com/muesli/cancelreader v0.2.2 // indirect 30 | github.com/muesli/termenv v0.15.2 // indirect 31 | github.com/rivo/uniseg v0.4.7 // indirect 32 | golang.org/x/sync v0.9.0 // indirect 33 | golang.org/x/sys v0.27.0 // indirect 34 | golang.org/x/text v0.3.8 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 6 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 7 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= 8 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 9 | github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= 10 | github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= 11 | github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= 12 | github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= 13 | github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= 14 | github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 15 | github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= 16 | github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 17 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 18 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 22 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 23 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 24 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 25 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 26 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 27 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 28 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 29 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 30 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 31 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 32 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 33 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 34 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 35 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 36 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 37 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 38 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 39 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 40 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 41 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 42 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 43 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 44 | golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= 45 | golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 46 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= 49 | golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 50 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 51 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 54 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 55 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 56 | -------------------------------------------------------------------------------- /helpers/constants.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | var UserDir string 4 | -------------------------------------------------------------------------------- /helpers/editor.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/pidanou/helm-tui/types" 9 | ) 10 | 11 | func WriteAndOpenFile(content []byte, file string) tea.Cmd { 12 | err := os.WriteFile(file, content, 0644) 13 | 14 | if err != nil { 15 | 16 | return func() tea.Msg { 17 | 18 | return types.EditorFinishedMsg{Err: err} 19 | 20 | } 21 | 22 | } 23 | editor := os.Getenv("EDITOR") 24 | if editor == "" { 25 | editor = "vim" 26 | } 27 | c := exec.Command(editor, file) 28 | return tea.ExecProcess(c, func(err error) tea.Msg { 29 | return types.EditorFinishedMsg{Err: err} 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /helpers/keymaps.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | ) 6 | 7 | // keyMap defines a set of keybindings. To work for help it must satisfy 8 | // key.Map. It could also very easily be a map[string]key.Binding. 9 | type keyMap struct { 10 | MenuNext key.Binding 11 | Quit key.Binding 12 | } 13 | 14 | // ShortHelp returns keybindings to be shown in the mini help view. It's part 15 | // of the key.Map interface. 16 | func (k keyMap) ShortHelp() []key.Binding { 17 | return []key.Binding{k.MenuNext, k.Quit} 18 | } 19 | 20 | // FullHelp returns keybindings for the expanded help view. It's part of the 21 | // key.Map interface. 22 | func (k keyMap) FullHelp() [][]key.Binding { 23 | return [][]key.Binding{} 24 | } 25 | 26 | var CommonKeys = keyMap{ 27 | MenuNext: key.NewBinding(key.WithKeys("[", "]"), key.WithHelp("[/]", "Change panel")), 28 | Quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "Quit")), 29 | } 30 | 31 | type SuggestionKeyMap struct { 32 | AcceptSuggestion key.Binding 33 | NextSuggestion key.Binding 34 | PrevSuggestion key.Binding 35 | } 36 | 37 | var SuggestionInputKeyMap = SuggestionKeyMap{ 38 | AcceptSuggestion: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "Accept suggestion")), 39 | NextSuggestion: key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down/ctrl+n", "Next suggestion")), 40 | PrevSuggestion: key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up/ctrl+p", "Previous suggestion")), 41 | } 42 | 43 | func (k SuggestionKeyMap) ShortHelp() []key.Binding { 44 | return []key.Binding{k.AcceptSuggestion, k.NextSuggestion, k.PrevSuggestion} 45 | } 46 | 47 | // FullHelp returns keybindings for the expanded help view. It's part of the 48 | // key.Map interface. 49 | func (k SuggestionKeyMap) FullHelp() [][]key.Binding { 50 | return [][]key.Binding{} 51 | } 52 | -------------------------------------------------------------------------------- /helpers/keymaps_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // TestCommonKeys verifies that the CommonKeys are correctly initialized. 11 | func TestCommonKeys(t *testing.T) { 12 | expectedMenuNext := key.NewBinding(key.WithKeys("[", "]"), key.WithHelp("[/]", "Change panel")) 13 | expectedQuit := key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "Quit")) 14 | 15 | assert.Equal(t, expectedMenuNext, CommonKeys.MenuNext, "MenuNext keybinding should match") 16 | assert.Equal(t, expectedQuit, CommonKeys.Quit, "Quit keybinding should match") 17 | } 18 | 19 | // TestShortHelp verifies that ShortHelp returns the correct keybindings for keyMap. 20 | func TestKeyMapShortHelp(t *testing.T) { 21 | expected := []key.Binding{ 22 | CommonKeys.MenuNext, 23 | CommonKeys.Quit, 24 | } 25 | 26 | shortHelp := CommonKeys.ShortHelp() 27 | assert.Equal(t, expected, shortHelp, "ShortHelp should return the correct keybindings") 28 | } 29 | 30 | // TestSuggestionInputKeyMap verifies that SuggestionInputKeyMap is correctly initialized. 31 | func TestSuggestionInputKeyMap(t *testing.T) { 32 | expectedAcceptSuggestion := key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "Accept suggestion")) 33 | expectedNextSuggestion := key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down/ctrl+n", "Next suggestion")) 34 | expectedPrevSuggestion := key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up/ctrl+p", "Previous suggestion")) 35 | 36 | assert.Equal(t, expectedAcceptSuggestion, SuggestionInputKeyMap.AcceptSuggestion, "AcceptSuggestion keybinding should match") 37 | assert.Equal(t, expectedNextSuggestion, SuggestionInputKeyMap.NextSuggestion, "NextSuggestion keybinding should match") 38 | assert.Equal(t, expectedPrevSuggestion, SuggestionInputKeyMap.PrevSuggestion, "PrevSuggestion keybinding should match") 39 | } 40 | 41 | // TestSuggestionKeyMapShortHelp verifies that ShortHelp returns the correct keybindings for SuggestionKeyMap. 42 | func TestSuggestionKeyMapShortHelp(t *testing.T) { 43 | expected := []key.Binding{ 44 | SuggestionInputKeyMap.AcceptSuggestion, 45 | SuggestionInputKeyMap.NextSuggestion, 46 | SuggestionInputKeyMap.PrevSuggestion, 47 | } 48 | 49 | shortHelp := SuggestionInputKeyMap.ShortHelp() 50 | assert.Equal(t, expected, shortHelp, "ShortHelp should return the correct keybindings") 51 | } 52 | -------------------------------------------------------------------------------- /helpers/logging.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | var LogFile *os.File 9 | 10 | func Println(args ...any) { 11 | args = append(args, "\n") 12 | fmt.Fprint(LogFile, args...) 13 | } 14 | -------------------------------------------------------------------------------- /hub/hub.go: -------------------------------------------------------------------------------- 1 | package hub 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/table" 6 | "github.com/charmbracelet/bubbles/textinput" 7 | "github.com/charmbracelet/bubbles/viewport" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/pidanou/helm-tui/components" 10 | "github.com/pidanou/helm-tui/types" 11 | ) 12 | 13 | type HubModel struct { 14 | searchBar textinput.Model 15 | resultTable table.Model 16 | defaultValueVP viewport.Model 17 | repoAddInput textinput.Model 18 | help help.Model 19 | width int 20 | height int 21 | view int 22 | } 23 | 24 | var resultsCols = []components.ColumnDefinition{ 25 | {Title: "id", Width: 0}, 26 | {Title: "version", Width: 0}, 27 | {Title: "Package", FlexFactor: 1}, 28 | {Title: "Repository", FlexFactor: 1}, 29 | {Title: "URL", FlexFactor: 3}, 30 | {Title: "Description", FlexFactor: 3}, 31 | } 32 | 33 | const ( 34 | searchView int = iota 35 | defaultValueView 36 | ) 37 | 38 | func InitModel() tea.Model { 39 | resultTable := components.GenerateTable() 40 | m := HubModel{ 41 | searchBar: textinput.New(), 42 | resultTable: resultTable, 43 | defaultValueVP: viewport.New(0, 0), 44 | help: help.New(), 45 | view: searchView, 46 | repoAddInput: textinput.New(), 47 | } 48 | m.searchBar.Placeholder = "/ to Search a package" 49 | m.repoAddInput.Placeholder = "Enter local repository name" 50 | return m 51 | } 52 | 53 | func (m HubModel) Init() tea.Cmd { 54 | return nil 55 | } 56 | 57 | func (m HubModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 58 | var cmd tea.Cmd 59 | var cmds []tea.Cmd 60 | switch msg := msg.(type) { 61 | case tea.WindowSizeMsg: 62 | m.width = msg.Width 63 | m.height = msg.Height 64 | m.searchBar.Width = msg.Width - 5 // -2 for border, -1 for input chevron 65 | components.SetTable(&m.resultTable, resultsCols, m.width) 66 | m.defaultValueVP.Width = m.width - 2 67 | m.repoAddInput.Width = m.width - 5 68 | case types.HubSearchResultMsg: 69 | m.resultTable.SetRows(msg.Content) 70 | case types.HubSearchDefaultValueMsg: 71 | m.defaultValueVP.SetContent(msg.Content) 72 | case types.AddRepoMsg: 73 | m.repoAddInput.SetValue("") 74 | m.repoAddInput.Blur() 75 | case tea.KeyMsg: 76 | switch msg.String() { 77 | case "a": 78 | if !m.repoAddInput.Focused() && !m.searchBar.Focused() { 79 | m.resultTable.Blur() 80 | m.searchBar.Blur() 81 | cmds = append(cmds, m.repoAddInput.Focus()) 82 | return m, tea.Batch(cmds...) 83 | } 84 | case "/": 85 | if m.view == searchView { 86 | m.resultTable.Blur() 87 | cmds = append(cmds, m.searchBar.Focus()) 88 | return m, tea.Batch(cmds...) 89 | } 90 | case "enter": 91 | if m.repoAddInput.Focused() { 92 | cmds = append(cmds, m.addRepo) 93 | return m, tea.Batch(cmds...) 94 | } 95 | if m.searchBar.Focused() { 96 | m.searchBar.Blur() 97 | m.resultTable.Focus() 98 | cmds = append(cmds, m.searchHub) 99 | return m, tea.Batch(cmds...) 100 | } 101 | m.resultTable.Focus() 102 | case "v": 103 | if m.resultTable.Focused() { 104 | if m.resultTable.SelectedRow() != nil { 105 | m.view = defaultValueView 106 | cmds = append(cmds, m.searchDefaultValue) 107 | } 108 | return m, tea.Batch(cmds...) 109 | } 110 | case "esc": 111 | m.view = searchView 112 | m.repoAddInput.Blur() 113 | m.defaultValueVP.GotoTop() 114 | } 115 | } 116 | m.searchBar, cmd = m.searchBar.Update(msg) 117 | cmds = append(cmds, cmd) 118 | m.resultTable, cmd = m.resultTable.Update(msg) 119 | cmds = append(cmds, cmd) 120 | m.defaultValueVP, cmd = m.defaultValueVP.Update(msg) 121 | cmds = append(cmds, cmd) 122 | m.repoAddInput, cmd = m.repoAddInput.Update(msg) 123 | cmds = append(cmds, cmd) 124 | return m, tea.Batch(cmds...) 125 | } 126 | -------------------------------------------------------------------------------- /hub/hub_commands.go: -------------------------------------------------------------------------------- 1 | package hub 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os/exec" 9 | 10 | "github.com/charmbracelet/bubbles/table" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/pidanou/helm-tui/types" 13 | ) 14 | 15 | func (m HubModel) searchHub() tea.Msg { 16 | type Package struct { 17 | ID string `json:"package_id"` 18 | NormalizedName string `json:"normalized_name"` 19 | Description string `json:"description"` 20 | Version string `json:"version"` 21 | Repository struct { 22 | Name string `json:"name"` 23 | URL string `json:"url"` 24 | } `json:"repository"` 25 | } 26 | 27 | type Response struct { 28 | Packages []Package `json:"packages"` 29 | } 30 | url := fmt.Sprintf("https://artifacthub.io/api/v1/packages/search?offset=0&limit=20&facets=false&ts_query_web=%s&kind=0&deprecated=false&sort=relevance", m.searchBar.Value()) 31 | 32 | // Create a new HTTP client 33 | client := &http.Client{} 34 | 35 | // Create a new GET request 36 | req, err := http.NewRequest("GET", url, nil) 37 | if err != nil { 38 | return types.HubSearchResultMsg{Err: err} 39 | } 40 | 41 | // Set the request header 42 | req.Header.Set("Accept", "application/json") 43 | 44 | // Perform the request 45 | resp, err := client.Do(req) 46 | if err != nil { 47 | return types.HubSearchResultMsg{Err: err} 48 | } 49 | defer resp.Body.Close() 50 | 51 | // Read the response body 52 | body, err := io.ReadAll(resp.Body) 53 | if err != nil { 54 | return types.HubSearchResultMsg{Err: err} 55 | } 56 | var response Response 57 | err = json.Unmarshal(body, &response) 58 | if err != nil { 59 | return types.HubSearchResultMsg{Err: err} 60 | } 61 | 62 | var rows []table.Row 63 | for _, pkg := range response.Packages { 64 | row := []string{} 65 | row = append(row, pkg.ID, pkg.Version, pkg.NormalizedName, pkg.Repository.Name, pkg.Repository.URL, pkg.Description) 66 | rows = append(rows, row) 67 | } 68 | 69 | return types.HubSearchResultMsg{Content: rows} 70 | } 71 | 72 | func (m HubModel) searchDefaultValue() tea.Msg { 73 | if m.resultTable.SelectedRow() == nil { 74 | return nil 75 | } 76 | 77 | url := fmt.Sprintf("https://artifacthub.io/api/v1/packages/%s/%s/values", m.resultTable.SelectedRow()[0], m.resultTable.SelectedRow()[1]) 78 | 79 | // Create a new HTTP client 80 | client := &http.Client{} 81 | 82 | // Create a new GET request 83 | req, err := http.NewRequest("GET", url, nil) 84 | if err != nil { 85 | return types.HubSearchDefaultValueMsg{Err: err} 86 | } 87 | 88 | // Set the request header 89 | req.Header.Set("Accept", "application/yaml") 90 | 91 | // Perform the request 92 | resp, err := client.Do(req) 93 | if err != nil { 94 | return types.HubSearchDefaultValueMsg{Err: err} 95 | } 96 | defer resp.Body.Close() 97 | 98 | // Read the response body 99 | body, err := io.ReadAll(resp.Body) 100 | if err != nil { 101 | return types.HubSearchDefaultValueMsg{Err: err} 102 | } 103 | 104 | return types.HubSearchDefaultValueMsg{Content: string(body)} 105 | } 106 | 107 | func (m HubModel) addRepo() tea.Msg { 108 | if m.repoAddInput.Value() == "" || m.resultTable.SelectedRow() == nil { 109 | return nil 110 | } 111 | cmd := exec.Command("helm", "repo", "add", m.repoAddInput.Value(), m.resultTable.SelectedRow()[4]) 112 | err := cmd.Run() 113 | if err != nil { 114 | return types.AddRepoMsg{Err: err} 115 | } 116 | return types.AddRepoMsg{Err: nil} 117 | } 118 | -------------------------------------------------------------------------------- /hub/hub_keymap.go: -------------------------------------------------------------------------------- 1 | package hub 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | type keyMap struct { 6 | AddRepo key.Binding 7 | Search key.Binding 8 | Show key.Binding 9 | Cancel key.Binding 10 | } 11 | 12 | func (k keyMap) ShortHelp() []key.Binding { 13 | return []key.Binding{k.AddRepo, k.Show, k.Search, k.Cancel} 14 | } 15 | 16 | // FullHelp returns keybindings for the expanded help view. It's part of the 17 | // key.Map interface. 18 | func (k keyMap) FullHelp() [][]key.Binding { 19 | return [][]key.Binding{} 20 | } 21 | 22 | var defaultKeysHelp = keyMap{ 23 | Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "Search")), 24 | Show: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "Focus table")), 25 | } 26 | 27 | var tableKeysHelp = keyMap{ 28 | Show: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "Show default values")), 29 | Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "Search")), 30 | AddRepo: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "Add repo")), 31 | } 32 | 33 | var searchKeyHelp = keyMap{ 34 | Search: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "Search")), 35 | } 36 | 37 | var addRepoKeyHelp = keyMap{ 38 | Search: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "Search")), 39 | } 40 | 41 | var defaultValuesKeyHelp = keyMap{ 42 | Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "Search")), 43 | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "Cancel")), 44 | } 45 | -------------------------------------------------------------------------------- /hub/hub_test.go: -------------------------------------------------------------------------------- 1 | package hub 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/charmbracelet/bubbles/table" 7 | "github.com/charmbracelet/bubbles/textinput" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/pidanou/helm-tui/types" 10 | ) 11 | 12 | // Test the initialization of the HubModel 13 | func TestInitModel(t *testing.T) { 14 | model := InitModel() 15 | if _, ok := model.(HubModel); !ok { 16 | t.Error("InitModel did not return a HubModel") 17 | } 18 | } 19 | 20 | // Test the Update function with a WindowSizeMsg 21 | func TestHubModelUpdateWindowSizeMsg(t *testing.T) { 22 | model := InitModel().(HubModel) 23 | msg := tea.WindowSizeMsg{Width: 100, Height: 40} 24 | updatedModel, _ := model.Update(msg) 25 | 26 | hubModel, ok := updatedModel.(HubModel) 27 | if !ok { 28 | t.Error("Expected HubModel after update") 29 | } 30 | 31 | if hubModel.width != 100 || hubModel.height != 40 { 32 | t.Errorf("Expected width=100 and height=40, got width=%d and height=%d", hubModel.width, hubModel.height) 33 | } 34 | } 35 | 36 | // Test the searchHub function with an empty search input 37 | func TestSearchHub(t *testing.T) { 38 | model := HubModel{} 39 | model.searchBar.SetValue("") 40 | 41 | msg := model.searchHub() 42 | if msg == nil { 43 | t.Error("Expected a non-nil message from searchHub") 44 | } 45 | 46 | if _, ok := msg.(types.HubSearchResultMsg); !ok { 47 | t.Error("Expected message of type HubSearchResultMsg") 48 | } 49 | } 50 | 51 | // Test the addRepo function with empty input 52 | func TestAddRepo(t *testing.T) { 53 | model := HubModel{ 54 | repoAddInput: textinput.New(), 55 | resultTable: table.New( 56 | table.WithColumns([]table.Column{ 57 | {Title: "ID", Width: 10}, 58 | {Title: "Version", Width: 10}, 59 | {Title: "Package", Width: 20}, 60 | {Title: "Repository", Width: 20}, 61 | {Title: "URL", Width: 30}, 62 | {Title: "Description", Width: 40}, 63 | }), 64 | table.WithRows([]table.Row{ 65 | {"1", "1.0.0", "example-package", "example-repo", "https://example.com", "An example package"}, 66 | }), 67 | ), 68 | } 69 | 70 | // Select the first row in the table 71 | model.resultTable.SetCursor(0) 72 | 73 | // Set a value for repoAddInput 74 | model.repoAddInput.SetValue("example-repo") 75 | 76 | msg := model.addRepo() 77 | if msg == nil { 78 | t.Error("Expected a non-nil message when adding a repo with valid input") 79 | } 80 | 81 | if _, ok := msg.(types.AddRepoMsg); !ok { 82 | t.Error("Expected message of type AddRepoMsg") 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /hub/hub_view.go: -------------------------------------------------------------------------------- 1 | package hub 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/pidanou/helm-tui/helpers" 6 | "github.com/pidanou/helm-tui/styles" 7 | ) 8 | 9 | func (m HubModel) View() string { 10 | header := styles.InactiveStyle.Border(styles.Border).Render(m.searchBar.View()) 11 | remainingHeight := m.height - lipgloss.Height(header) - 2 - 1 // searchbar padding + releaseTable padding + helper 12 | if m.repoAddInput.Focused() { 13 | remainingHeight -= 3 14 | } 15 | if m.view == defaultValueView { 16 | m.defaultValueVP.Height = m.height - 2 - 1 17 | return m.renderDefaultValueView() 18 | } 19 | m.resultTable.SetHeight(remainingHeight) 20 | if m.searchBar.Focused() { 21 | header = styles.ActiveStyle.Border(styles.Border).Render(m.searchBar.View()) 22 | } 23 | helperStyle := m.help.Styles.ShortSeparator 24 | helpView := m.help.View(defaultKeysHelp) 25 | if m.searchBar.Focused() { 26 | helpView = m.help.View(searchKeyHelp) 27 | } 28 | if m.resultTable.Focused() { 29 | helpView = m.help.View(tableKeysHelp) 30 | } 31 | if m.repoAddInput.Focused() { 32 | helpView = m.help.View(addRepoKeyHelp) 33 | } 34 | helpView += helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 35 | style := styles.ActiveStyle.Border(styles.Border) 36 | if m.repoAddInput.Focused() { 37 | return header + "\n" + m.renderSearchTableView() + "\n" + style.Render(m.repoAddInput.View()) + "\n" + helpView 38 | } 39 | return header + "\n" + m.renderSearchTableView() + "\n" + helpView 40 | } 41 | 42 | func (m HubModel) renderSearchTableView() string { 43 | var releasesTopBorder string 44 | tableView := m.resultTable.View() 45 | var baseStyle lipgloss.Style 46 | releasesTopBorder = styles.GenerateTopBorderWithTitle(" Results ", m.resultTable.Width(), styles.Border, styles.InactiveStyle) 47 | baseStyle = styles.InactiveStyle.Border(styles.Border, false, true, true) 48 | if m.resultTable.Focused() { 49 | releasesTopBorder = styles.GenerateTopBorderWithTitle(" Results ", m.resultTable.Width(), styles.Border, styles.ActiveStyle.Foreground(styles.HighlightColor)) 50 | baseStyle = styles.ActiveStyle.Border(styles.Border, false, true, true) 51 | } 52 | tableView = baseStyle.Render(tableView) 53 | return lipgloss.JoinVertical(lipgloss.Top, releasesTopBorder, tableView) 54 | } 55 | 56 | func (m HubModel) renderDefaultValueView() string { 57 | defaultValueTopBorder := styles.GenerateTopBorderWithTitle(" Default Values ", m.defaultValueVP.Width, styles.Border, styles.InactiveStyle) 58 | baseStyle := styles.InactiveStyle.Border(styles.Border, false, true, true) 59 | helperStyle := m.help.Styles.ShortSeparator 60 | helpView := helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 61 | return lipgloss.JoinVertical(lipgloss.Top, defaultValueTopBorder, baseStyle.Render(m.defaultValueVP.View()), m.help.View(defaultValuesKeyHelp)+helpView) 62 | } 63 | -------------------------------------------------------------------------------- /install-binary.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Shamelessly copied from https://github.com/databus23/helm-diff 4 | 5 | PROJECT_NAME="helm-tui" 6 | PROJECT_GH="pidanou/$PROJECT_NAME" 7 | export GREP_COLOR="never" 8 | 9 | # Convert HELM_BIN and HELM_PLUGIN_DIR to unix if cygpath is 10 | # available. This is the case when using MSYS2 or Cygwin 11 | # on Windows where helm returns a Windows path but we 12 | # need a Unix path 13 | 14 | if command -v cygpath >/dev/null 2>&1; then 15 | HELM_BIN="$(cygpath -u "${HELM_BIN}")" 16 | HELM_PLUGIN_DIR="$(cygpath -u "${HELM_PLUGIN_DIR}")" 17 | fi 18 | 19 | [ -z "$HELM_BIN" ] && HELM_BIN=$(command -v helm) 20 | 21 | [ -z "$HELM_HOME" ] && HELM_HOME=$(helm env | grep 'HELM_DATA_HOME' | cut -d '=' -f2 | tr -d '"') 22 | 23 | mkdir -p "$HELM_HOME" 24 | 25 | : "${HELM_PLUGIN_DIR:="$HELM_HOME/plugins/helm-tui"}" 26 | 27 | if [ "$SKIP_BIN_INSTALL" = "1" ]; then 28 | echo "Skipping binary install" 29 | exit 30 | fi 31 | 32 | # which mode is the common installer script running in 33 | SCRIPT_MODE="install" 34 | if [ "$1" = "-u" ]; then 35 | SCRIPT_MODE="update" 36 | fi 37 | 38 | # initArch discovers the architecture for this system. 39 | initArch() { 40 | ARCH=$(uname -m) 41 | case $ARCH in 42 | aarch64) ARCH="arm64" ;; 43 | x86_64) ARCH="x86_64" ;; 44 | i386) ARCH="i386" ;; 45 | esac 46 | } 47 | 48 | # initOS discovers the operating system for this system. 49 | initOS() { 50 | OS=$(uname -s) 51 | 52 | case "$OS" in 53 | Windows_NT) OS='Windows' ;; 54 | # Msys support 55 | MSYS*) OS='Windows' ;; 56 | # Minimalist GNU for Windows 57 | MINGW*) OS='Windows' ;; 58 | CYGWIN*) OS='Windows' ;; 59 | Darwin) OS='Darwin' ;; 60 | Linux) OS='Linux' ;; 61 | esac 62 | } 63 | 64 | # verifySupported checks that the os/arch combination is supported for 65 | # binary builds. 66 | verifySupported() { 67 | supported="Linux_x86_64\nLinux_arm64\nLinux_i386\nDarwin_x86_64\nDarwin_arm64\nWindows_arm64\nWindows_i386\nWindows_x86_64" 68 | if ! echo "${supported}" | grep -q "${OS}_${ARCH}"; then 69 | echo "No prebuild binary for ${OS}_${ARCH}." 70 | exit 1 71 | fi 72 | 73 | if 74 | ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1 75 | then 76 | echo "Either curl or wget is required" 77 | exit 1 78 | fi 79 | } 80 | 81 | # getDownloadURL checks the latest available version. 82 | getDownloadURL() { 83 | version=$(git -C "$HELM_PLUGIN_DIR" describe --tags --exact-match 2>/dev/null || :) 84 | if [ "$SCRIPT_MODE" = "install" ] && [ -n "$version" ]; then 85 | DOWNLOAD_URL="https://github.com/$PROJECT_GH/releases/download/$version/helm-tui_${OS}_${ARCH}.tar.gz" 86 | else 87 | DOWNLOAD_URL="https://github.com/$PROJECT_GH/releases/latest/download/helm-tui_${OS}_${ARCH}.tar.gz" 88 | fi 89 | } 90 | 91 | # Temporary dir 92 | mkTempDir() { 93 | HELM_TMP="$(mktemp -d -t "${PROJECT_NAME}-XXXXXX")" 94 | } 95 | rmTempDir() { 96 | if [ -d "${HELM_TMP:-/tmp/helm-tui-tmp}" ]; then 97 | rm -rf "${HELM_TMP:-/tmp/helm-tui-tmp}" 98 | fi 99 | } 100 | 101 | # downloadFile downloads the latest binary package and also the checksum 102 | # for that binary. 103 | downloadFile() { 104 | PLUGIN_TMP_FILE="${HELM_TMP}/${PROJECT_NAME}.tar.gz" 105 | 106 | echo "Downloading $DOWNLOAD_URL" 107 | if 108 | command -v curl >/dev/null 2>&1 109 | then 110 | curl -sSf -L "$DOWNLOAD_URL" >"$PLUGIN_TMP_FILE" 111 | elif 112 | command -v wget >/dev/null 2>&1 113 | then 114 | wget -q -O - "$DOWNLOAD_URL" >"$PLUGIN_TMP_FILE" 115 | fi 116 | } 117 | 118 | # installFile verifies the SHA256 for the file, then unpacks and 119 | # installs it. 120 | installFile() { 121 | tar xzf "$PLUGIN_TMP_FILE" -C "$HELM_TMP" 122 | HELM_TMP_BIN="$HELM_TMP/helm-tui" 123 | if [ "${OS}" = "windows" ]; then 124 | HELM_TMP_BIN="$HELM_TMP_BIN.exe" 125 | fi 126 | echo "Preparing to install into ${HELM_PLUGIN_DIR}" 127 | mkdir -p "${HELM_PLUGIN_DIR}/bin" 128 | cp "$HELM_TMP_BIN" "$HELM_PLUGIN_DIR/bin/helm-tui" 129 | } 130 | 131 | # exit_trap is executed if on exit (error or not). 132 | exit_trap() { 133 | result=$? 134 | rmTempDir 135 | if [ "$result" != "0" ]; then 136 | echo "Failed to install $PROJECT_NAME" 137 | printf '\tFor support, go to https://github.com/pidanou/helm-tui.\n' 138 | fi 139 | exit $result 140 | } 141 | 142 | # Execution 143 | 144 | #Stop execution on any error 145 | trap "exit_trap" EXIT 146 | set -e 147 | initArch 148 | initOS 149 | verifySupported 150 | getDownloadURL 151 | mkTempDir 152 | downloadFile 153 | installFile 154 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/pidanou/helm-tui/helpers" 10 | ) 11 | 12 | func main() { 13 | f, err := tea.LogToFile("debug.log", "debug") 14 | if err != nil { 15 | fmt.Println("fatal:", err) 16 | os.Exit(1) 17 | } 18 | helpers.LogFile = f 19 | defer f.Close() 20 | defer os.Truncate("debug.log", 0) 21 | 22 | var tabs []string 23 | 24 | // Iterate over the map and collect the values 25 | for _, value := range tabLabels { 26 | tabs = append(tabs, value) 27 | } 28 | 29 | p := tea.NewProgram(newModel(tabs), tea.WithAltScreen()) 30 | 31 | if _, err := p.Run(); err != nil { 32 | log.Fatal(err) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /plugin.yaml: -------------------------------------------------------------------------------- 1 | name: "tui" 2 | # Version is the version of Helm plus the number of official builds for this 3 | # plugin 4 | version: "0.5.0" 5 | usage: "Simple terminal UI for Helm" 6 | description: "Simple terminal UI for Helm" 7 | useTunnel: true 8 | command: "${HELM_PLUGIN_DIR}/bin/helm-tui" 9 | hooks: 10 | install: "${HELM_PLUGIN_DIR}/install-binary.sh" 11 | update: "${HELM_PLUGIN_DIR}/install-binary.sh -u" 12 | -------------------------------------------------------------------------------- /plugins/overview.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/key" 6 | "github.com/charmbracelet/bubbles/table" 7 | "github.com/charmbracelet/bubbles/textinput" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/pidanou/helm-tui/components" 10 | "github.com/pidanou/helm-tui/types" 11 | ) 12 | 13 | var pluginsCols = []components.ColumnDefinition{ 14 | {Title: "Name", FlexFactor: 1}, 15 | {Title: "Version", FlexFactor: 1}, 16 | {Title: "description", FlexFactor: 3}, 17 | } 18 | 19 | type PluginsModel struct { 20 | pluginsTable table.Model 21 | installPluginInput textinput.Model 22 | help help.Model 23 | keys keyMap 24 | width int 25 | height int 26 | } 27 | 28 | func InitModel() PluginsModel { 29 | table := components.GenerateTable() 30 | input := textinput.New() 31 | input.Placeholder = "Enter plugin path/url" 32 | return PluginsModel{pluginsTable: table, help: help.New(), keys: overviewKeys, installPluginInput: input} 33 | } 34 | 35 | func (m PluginsModel) Init() tea.Cmd { 36 | return m.list 37 | } 38 | 39 | func (m PluginsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 40 | var cmd tea.Cmd 41 | var cmds []tea.Cmd 42 | 43 | switch msg := msg.(type) { 44 | case tea.WindowSizeMsg: 45 | m.width = msg.Width 46 | m.height = msg.Height 47 | components.SetTable(&m.pluginsTable, pluginsCols, m.width) 48 | case types.PluginsListMsg: 49 | m.pluginsTable.SetRows(msg.Content) 50 | case types.PluginInstallMsg: 51 | m.installPluginInput.Blur() 52 | m.installPluginInput.SetValue("") 53 | return m, m.list 54 | case types.PluginUninstallMsg: 55 | return m, m.list 56 | case tea.KeyMsg: 57 | switch { 58 | case key.Matches(msg, m.keys.Install): 59 | cmds = append(cmds, m.installPluginInput.Focus()) 60 | return m, tea.Batch(cmds...) 61 | case key.Matches(msg, m.keys.Uninstall): 62 | if !m.installPluginInput.Focused() { 63 | return m, m.uninstall 64 | } 65 | case key.Matches(msg, m.keys.Update): 66 | if !m.installPluginInput.Focused() { 67 | return m, m.update 68 | } 69 | case key.Matches(msg, m.keys.Cancel): 70 | m.installPluginInput.Blur() 71 | return m, tea.Batch(cmds...) 72 | case key.Matches(msg, m.keys.Refresh): 73 | return m, m.list 74 | case msg.String() == "enter": 75 | if m.installPluginInput.Focused() { 76 | return m, m.install 77 | } 78 | } 79 | } 80 | m.installPluginInput, cmd = m.installPluginInput.Update(msg) 81 | cmds = append(cmds, cmd) 82 | return m, tea.Batch(cmds...) 83 | } 84 | -------------------------------------------------------------------------------- /plugins/overview_commands.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/table" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/pidanou/helm-tui/types" 12 | ) 13 | 14 | func (m PluginsModel) list() tea.Msg { 15 | var stdout bytes.Buffer 16 | var rows = []table.Row{} 17 | 18 | // Create the command 19 | cmd := exec.Command("helm", "plugin", "ls") 20 | cmd.Stdout = &stdout 21 | err := cmd.Run() 22 | if err != nil { 23 | return types.ListReleasesMsg{Err: err} 24 | } 25 | 26 | lines := strings.Split(stdout.String(), "\n") 27 | lines = lines[1 : len(lines)-1] 28 | 29 | for _, line := range lines { 30 | fields := strings.Fields(line) 31 | name := fields[0] 32 | version := fields[1] 33 | description := strings.Join(fields[2:], " ") 34 | row := []string{name, version, description} 35 | rows = append(rows, row) 36 | } 37 | return types.PluginsListMsg{Content: rows} 38 | } 39 | 40 | func (m PluginsModel) install() tea.Msg { 41 | pluginName := m.installPluginInput.Value() 42 | if pluginName == "" { 43 | return types.PluginInstallMsg{Err: errors.New("No plugin")} 44 | } 45 | cmd := exec.Command("helm", "plugin", "install", strings.TrimSpace(pluginName)) 46 | err := cmd.Run() 47 | if err != nil { 48 | return types.PluginInstallMsg{Err: errors.New("Cannot install plugin")} 49 | } 50 | return types.PluginInstallMsg{Err: nil} 51 | } 52 | 53 | func (m PluginsModel) update() tea.Msg { 54 | if m.pluginsTable.SelectedRow() == nil { 55 | return types.PluginUpdateMsg{Err: errors.New("No plugin selected")} 56 | } 57 | pluginName := m.pluginsTable.SelectedRow()[0] 58 | cmd := exec.Command("helm", "plugin", "update", pluginName) 59 | err := cmd.Run() 60 | 61 | if err != nil { 62 | return types.PluginUpdateMsg{Err: errors.New("Cannot update plugin")} 63 | } 64 | return types.PluginUpdateMsg{Err: nil} 65 | } 66 | 67 | func (m PluginsModel) uninstall() tea.Msg { 68 | if m.pluginsTable.SelectedRow() == nil { 69 | return types.PluginUninstallMsg{Err: errors.New("No plugin selected")} 70 | } 71 | pluginName := m.pluginsTable.SelectedRow()[0] 72 | cmd := exec.Command("helm", "plugin", "uninstall", strings.TrimSpace(pluginName)) 73 | err := cmd.Run() 74 | if err != nil { 75 | return types.PluginUninstallMsg{Err: errors.New("Cannot update plugin")} 76 | } 77 | return types.PluginUninstallMsg{Err: nil} 78 | } 79 | -------------------------------------------------------------------------------- /plugins/overview_keymap.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | type keyMap struct { 6 | Install key.Binding 7 | Update key.Binding 8 | Uninstall key.Binding 9 | Cancel key.Binding 10 | Refresh key.Binding 11 | } 12 | 13 | var overviewKeys = keyMap{ 14 | Uninstall: key.NewBinding(key.WithKeys("U"), key.WithHelp("U", "Uninstall")), 15 | Install: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "Install")), 16 | Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "Update")), 17 | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "Cancel")), 18 | Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "Refresh")), 19 | } 20 | 21 | func (k keyMap) ShortHelp() []key.Binding { 22 | return []key.Binding{k.Update, k.Install, k.Uninstall, k.Refresh, k.Cancel} 23 | } 24 | 25 | // FullHelp returns keybindings for the expanded help view. It's part of the 26 | // key.Map interface. 27 | func (k keyMap) FullHelp() [][]key.Binding { 28 | return [][]key.Binding{} 29 | } 30 | -------------------------------------------------------------------------------- /plugins/overview_test.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | -------------------------------------------------------------------------------- /plugins/overview_view.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/pidanou/helm-tui/components" 6 | "github.com/pidanou/helm-tui/helpers" 7 | "github.com/pidanou/helm-tui/styles" 8 | ) 9 | 10 | func (m PluginsModel) View() string { 11 | var remainingHeight = m.height 12 | if m.installPluginInput.Focused() { 13 | remainingHeight -= 3 14 | } 15 | helperStyle := m.help.Styles.ShortSeparator 16 | helpView := m.help.View(m.keys) + helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 17 | view := components.RenderTable(m.pluginsTable, remainingHeight-3, m.width-2) 18 | m.installPluginInput.Width = m.width - 5 19 | if m.installPluginInput.Focused() { 20 | view += "\n" + styles.ActiveStyle.Border(styles.Border).Render(m.installPluginInput.View()) 21 | } 22 | view = lipgloss.JoinVertical(lipgloss.Left, view, helpView) 23 | return view 24 | } 25 | -------------------------------------------------------------------------------- /releases/install.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/help" 8 | "github.com/charmbracelet/bubbles/textinput" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/pidanou/helm-tui/helpers" 11 | "github.com/pidanou/helm-tui/types" 12 | ) 13 | 14 | const ( 15 | installChartReleaseNameStep int = iota 16 | installChartNameStep 17 | installChartVersionStep 18 | installChartNamespaceStep 19 | installChartValuesStep 20 | installChartConfirmStep 21 | ) 22 | 23 | var installInputsHelper = []string{ 24 | "Enter release name", 25 | "Enter chart", 26 | "Enter chart version (empty for latest)", 27 | "Enter namespace (empty for default)", 28 | "Edit default values ? y/n", 29 | "Enter to install", 30 | } 31 | 32 | const debounce = 500 * time.Millisecond 33 | 34 | type InstallModel struct { 35 | installStep int 36 | Chart string 37 | Version string 38 | Inputs []textinput.Model 39 | width int 40 | height int 41 | help help.Model 42 | keys keyMap 43 | tag int 44 | } 45 | 46 | func InitInstallModel() InstallModel { 47 | chart := textinput.New() 48 | version := textinput.New() 49 | name := textinput.New() 50 | namespace := textinput.New() 51 | value := textinput.New() 52 | confirm := textinput.New() 53 | inputs := []textinput.Model{name, chart, version, namespace, value, confirm} 54 | m := InstallModel{installStep: installChartReleaseNameStep, Inputs: inputs, help: help.New(), keys: installKeys} 55 | m.Inputs[installChartNameStep].ShowSuggestions = true 56 | m.Inputs[installChartVersionStep].ShowSuggestions = true 57 | return m 58 | } 59 | 60 | func (m InstallModel) Init() tea.Cmd { 61 | return m.Inputs[0].Focus() 62 | } 63 | 64 | func (m InstallModel) Update(msg tea.Msg) (InstallModel, tea.Cmd) { 65 | var cmd tea.Cmd 66 | cmds := make([]tea.Cmd, len(m.Inputs)) 67 | switch msg := msg.(type) { 68 | case tea.WindowSizeMsg: 69 | m.width = msg.Width 70 | m.height = msg.Height 71 | m.help.Width = msg.Width 72 | m.Inputs[installChartReleaseNameStep].Width = msg.Width - 6 - len(installInputsHelper[0]) 73 | case types.EditorFinishedMsg: 74 | m.installStep++ 75 | for i := 0; i <= len(m.Inputs)-1; i++ { 76 | if i == int(m.installStep) { 77 | cmds[i] = m.Inputs[i].Focus() 78 | continue 79 | } 80 | m.Inputs[i].Blur() 81 | } 82 | return m, tea.Batch(cmds...) 83 | case types.InstallMsg: 84 | m.installStep = 0 85 | releaseName := m.Inputs[installChartReleaseNameStep].Value() 86 | namespace := m.Inputs[installChartNamespaceStep].Value() 87 | if namespace == "" { 88 | namespace = "default" 89 | } 90 | folder := fmt.Sprintf("%s/%s/%s", helpers.UserDir, namespace, releaseName) 91 | cmds = append(cmds, m.cleanValueFile(folder), m.blurAllInputs(), m.resetAllInputs()) 92 | 93 | return m, tea.Batch(cmds...) 94 | case types.DebounceEndMsg: 95 | if msg.Tag == m.tag { 96 | if m.Inputs[installChartNameStep].Focused() { 97 | m.Inputs[installChartNameStep].SetSuggestions(m.searchLocalPackage()) 98 | } 99 | if m.Inputs[installChartVersionStep].Focused() { 100 | m.Inputs[installChartVersionStep].SetSuggestions(m.searchLocalPackageVersion()) 101 | } 102 | } 103 | case tea.KeyMsg: 104 | m.tag++ 105 | switch msg.String() { 106 | case "enter": 107 | if m.installStep == installChartConfirmStep { 108 | m.installStep = 0 109 | 110 | cmd = m.installPackage(m.Inputs[installChartValuesStep].Value()) 111 | cmds = append(cmds, cmd) 112 | 113 | return m, tea.Batch(cmds...) 114 | } 115 | 116 | if m.installStep == installChartValuesStep { 117 | switch m.Inputs[installChartValuesStep].Value() { 118 | case "y": 119 | return m, m.openEditorDefaultValues() 120 | case "n": 121 | default: 122 | return m, nil 123 | } 124 | } 125 | 126 | m.installStep++ 127 | 128 | for i := 0; i <= len(m.Inputs)-1; i++ { 129 | if i == int(m.installStep) { 130 | cmds[i] = m.Inputs[i].Focus() 131 | continue 132 | } 133 | m.Inputs[i].Blur() 134 | } 135 | 136 | return m, tea.Batch(cmds...) 137 | case "esc": 138 | m.installStep = 0 139 | for i := 0; i <= len(m.Inputs)-1; i++ { 140 | m.Inputs[i].Blur() 141 | m.Inputs[i].SetValue("") 142 | } 143 | default: 144 | return m, tea.Batch(m.updateInputs(msg), tea.Tick(debounce, func(_ time.Time) tea.Msg { 145 | return types.DebounceEndMsg{Tag: m.tag} 146 | })) 147 | } 148 | } 149 | return m, m.updateInputs(msg) 150 | } 151 | 152 | func (m *InstallModel) updateInputs(msg tea.Msg) tea.Cmd { 153 | cmds := make([]tea.Cmd, len(m.Inputs)) 154 | 155 | // Only text inputs with Focus() set will respond, so it's safe to simply 156 | // update all of them here without any further logic. 157 | for i := range m.Inputs { 158 | m.Inputs[i], cmds[i] = m.Inputs[i].Update(msg) 159 | } 160 | return tea.Batch(cmds...) 161 | } 162 | 163 | func (m InstallModel) blurAllInputs() tea.Cmd { 164 | for i := range m.Inputs { 165 | m.Inputs[i].Blur() 166 | } 167 | return nil 168 | } 169 | 170 | func (m InstallModel) resetAllInputs() tea.Cmd { 171 | for i := range m.Inputs { 172 | m.Inputs[i].SetValue("") 173 | } 174 | return nil 175 | } 176 | -------------------------------------------------------------------------------- /releases/install_commands.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/pidanou/helm-tui/helpers" 12 | "github.com/pidanou/helm-tui/types" 13 | ) 14 | 15 | func (m InstallModel) installPackage(mode string) tea.Cmd { 16 | chartName := m.Inputs[installChartNameStep].Value() 17 | version := m.Inputs[installChartVersionStep].Value() 18 | releaseName := m.Inputs[installChartReleaseNameStep].Value() 19 | namespace := m.Inputs[installChartNamespaceStep].Value() 20 | if namespace == "" { 21 | namespace = "default" 22 | } 23 | folder := fmt.Sprintf("%s/%s/%s", helpers.UserDir, namespace, releaseName) 24 | file := fmt.Sprintf("%s/values.yaml", folder) 25 | return func() tea.Msg { 26 | var stdout, stderr bytes.Buffer 27 | 28 | var cmd *exec.Cmd 29 | // Create the command 30 | if mode == "y" { 31 | cmd = exec.Command("helm", "install", releaseName, chartName, "--version", version, "--values", file, "--namespace", namespace, "--create-namespace") 32 | } else { 33 | cmd = exec.Command("helm", "install", releaseName, chartName, "--version", version, "--namespace", namespace, "--create-namespace") 34 | } 35 | cmd.Stdout = &stdout 36 | cmd.Stderr = &stderr 37 | 38 | // Run the command 39 | err := cmd.Run() 40 | if err != nil { 41 | return types.InstallMsg{Err: err} 42 | } 43 | 44 | return types.InstallMsg{Err: nil} 45 | } 46 | } 47 | 48 | func (m InstallModel) openEditorDefaultValues() tea.Cmd { 49 | var stdout, stderr bytes.Buffer 50 | releaseName := m.Inputs[installChartReleaseNameStep].Value() 51 | namespace := m.Inputs[installChartNamespaceStep].Value() 52 | if namespace == "" { 53 | namespace = "default" 54 | } 55 | folder := fmt.Sprintf("%s/%s/%s", helpers.UserDir, namespace, releaseName) 56 | _ = os.MkdirAll(folder, 0755) 57 | file := fmt.Sprintf("%s/values.yaml", folder) 58 | packageName := m.Inputs[installChartNameStep].Value() 59 | version := m.Inputs[installChartVersionStep].Value() 60 | 61 | cmd := exec.Command("helm", "show", "values", packageName, "--version", version) 62 | cmd.Stdout = &stdout 63 | cmd.Stderr = &stderr 64 | err := cmd.Run() 65 | if err != nil { 66 | return func() tea.Msg { return types.EditorFinishedMsg{Err: err} } 67 | } 68 | return helpers.WriteAndOpenFile(stdout.Bytes(), file) 69 | } 70 | 71 | func (m InstallModel) searchLocalPackage() []string { 72 | if m.Inputs[installChartNameStep].Value() == "" { 73 | return []string{} 74 | } 75 | var stdout bytes.Buffer 76 | cmd := exec.Command("helm", "search", "repo", m.Inputs[installChartNameStep].Value(), "--output", "json") 77 | cmd.Stdout = &stdout 78 | err := cmd.Run() 79 | if err != nil { 80 | return []string{} 81 | } 82 | var pkgs []types.Pkg 83 | err = json.Unmarshal(stdout.Bytes(), &pkgs) 84 | if err != nil { 85 | return []string{} 86 | } 87 | var suggestions []string 88 | for _, p := range pkgs { 89 | suggestions = append(suggestions, p.Name) 90 | } 91 | 92 | return suggestions 93 | } 94 | 95 | func (m InstallModel) searchLocalPackageVersion() []string { 96 | var stdout bytes.Buffer 97 | cmd := exec.Command("helm", "search", "repo", "--regexp", "\v"+m.Inputs[installChartNameStep].Value()+"\v", "--versions", "--output", "json") 98 | cmd.Stdout = &stdout 99 | err := cmd.Run() 100 | if err != nil { 101 | return []string{} 102 | } 103 | var pkgs []types.Pkg 104 | err = json.Unmarshal(stdout.Bytes(), &pkgs) 105 | if err != nil { 106 | return []string{} 107 | } 108 | 109 | var suggestions []string 110 | for _, pkg := range pkgs { 111 | suggestions = append(suggestions, pkg.Version) 112 | } 113 | 114 | m.Inputs[installChartNameStep].SetSuggestions(suggestions) 115 | return suggestions 116 | } 117 | 118 | func (m InstallModel) cleanValueFile(folder string) tea.Cmd { 119 | return func() tea.Msg { 120 | _ = os.RemoveAll(folder) 121 | return nil 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /releases/install_keymap.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | var installKeys = keyMap{ 6 | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "Cancel")), 7 | } 8 | -------------------------------------------------------------------------------- /releases/install_test.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/pidanou/helm-tui/types" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // TestInitInstallModel verifies that the InstallModel initializes correctly. 12 | func TestInitInstallModel(t *testing.T) { 13 | model := InitInstallModel() 14 | 15 | assert.Equal(t, installChartReleaseNameStep, model.installStep, "Initial installStep should be installChartReleaseNameStep") 16 | assert.Equal(t, 6, len(model.Inputs), "InstallModel should have 6 inputs") 17 | } 18 | 19 | // TestInstallModelEnterKey verifies that the Enter key advances the install step. 20 | func TestInstallModelEnterKey(t *testing.T) { 21 | model := InitInstallModel() 22 | msg := tea.KeyMsg{Type: tea.KeyEnter} 23 | 24 | updatedModel, _ := model.Update(msg) 25 | 26 | assert.Equal(t, installChartNameStep, updatedModel.installStep, "installStep should advance to installChartNameStep after pressing Enter") 27 | assert.True(t, updatedModel.Inputs[installChartNameStep].Focused(), "Next input should be focused after pressing Enter") 28 | } 29 | 30 | // TestInstallModelEscKey verifies that the Esc key resets the install step and clears inputs. 31 | func TestInstallModelEscKey(t *testing.T) { 32 | model := InitInstallModel() 33 | model.Inputs[installChartReleaseNameStep].SetValue("test-release") 34 | 35 | msg := tea.KeyMsg{Type: tea.KeyEsc} 36 | updatedModel, _ := model.Update(msg) 37 | 38 | assert.Equal(t, installChartReleaseNameStep, updatedModel.installStep, "installStep should be reset to installChartReleaseNameStep") 39 | for _, input := range updatedModel.Inputs { 40 | assert.Empty(t, input.Value(), "All inputs should be cleared after pressing Esc") 41 | } 42 | } 43 | 44 | // TestInstallMsgHandling verifies that the model resets after handling an InstallMsg. 45 | func TestInstallMsgHandling(t *testing.T) { 46 | model := InitInstallModel() 47 | model.Inputs[installChartReleaseNameStep].SetValue("test-release") 48 | 49 | msg := types.InstallMsg{} 50 | updatedModel, _ := model.Update(msg) 51 | 52 | assert.Equal(t, installChartReleaseNameStep, updatedModel.installStep, "installStep should be reset to installChartReleaseNameStep after InstallMsg") 53 | for _, input := range updatedModel.Inputs { 54 | assert.Empty(t, input.Value(), "All inputs should be cleared after InstallMsg") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /releases/install_view.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/pidanou/helm-tui/helpers" 8 | "github.com/pidanou/helm-tui/styles" 9 | ) 10 | 11 | func (m InstallModel) View() string { 12 | helperStyle := m.help.Styles.ShortSeparator 13 | helpView := m.help.View(m.keys) + helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 14 | if m.Inputs[installChartNameStep].Focused() { 15 | helpView = m.help.View(m.keys) + helperStyle.Render(" • ") + m.help.View(helpers.SuggestionInputKeyMap) + helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 16 | } 17 | var inputs string 18 | for step := 0; step < len(m.Inputs); step++ { 19 | if step == 0 { 20 | inputs = fmt.Sprintf("%s %s", installInputsHelper[step], m.Inputs[step].View()) 21 | continue 22 | } 23 | inputs = lipgloss.JoinVertical(lipgloss.Top, inputs, fmt.Sprintf("%s %s", installInputsHelper[step], m.Inputs[step].View())) 24 | } 25 | inputs = styles.ActiveStyle.Border(styles.Border).Render(inputs) 26 | inputs = lipgloss.JoinVertical(lipgloss.Top, inputs) 27 | return lipgloss.JoinVertical(lipgloss.Top, inputs, helpView) 28 | } 29 | -------------------------------------------------------------------------------- /releases/overview.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/table" 6 | "github.com/charmbracelet/bubbles/viewport" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/pidanou/helm-tui/components" 9 | "github.com/pidanou/helm-tui/types" 10 | ) 11 | 12 | type selectedView int 13 | 14 | const ( 15 | releasesView selectedView = iota 16 | historyView 17 | notesView 18 | metadataView 19 | hooksView 20 | valuesView 21 | manifestView 22 | ) 23 | 24 | type Model struct { 25 | selectedView selectedView 26 | keys []keyMap 27 | help help.Model 28 | releaseTable table.Model 29 | historyTable table.Model 30 | notesVP viewport.Model 31 | metadataVP viewport.Model 32 | hooksVP viewport.Model 33 | valuesVP viewport.Model 34 | manifestVP viewport.Model 35 | installModel InstallModel 36 | installing bool 37 | upgradeModel UpgradeModel 38 | upgrading bool 39 | deleting bool 40 | width int 41 | height int 42 | } 43 | 44 | var releaseCols = []components.ColumnDefinition{ 45 | {Title: "Name", FlexFactor: 1}, 46 | {Title: "Namespace", FlexFactor: 1}, 47 | {Title: "Revision", Width: 10}, 48 | {Title: "Updated", Width: 36}, 49 | {Title: "Status", FlexFactor: 1}, 50 | {Title: "Chart", FlexFactor: 1}, 51 | {Title: "App version", FlexFactor: 1}, 52 | } 53 | 54 | var historyCols = []components.ColumnDefinition{ 55 | {Title: "Revision", FlexFactor: 1}, 56 | {Title: "Updated", Width: 36}, 57 | {Title: "Status", FlexFactor: 1}, 58 | {Title: "Chart", FlexFactor: 1}, 59 | {Title: "App version", FlexFactor: 1}, 60 | {Title: "Description", FlexFactor: 1}, 61 | } 62 | 63 | var menuItem = []string{ 64 | "History", 65 | "Notes", 66 | "Metadata", 67 | "Hooks", 68 | "Values", 69 | "Manifest", 70 | } 71 | 72 | var releaseTableCache table.Model 73 | 74 | func InitModel() (Model, tea.Cmd) { 75 | table := components.GenerateTable() 76 | k := generateKeys() 77 | m := Model{releaseTable: table, historyTable: table, help: help.New(), keys: k, upgrading: false, 78 | installModel: InitInstallModel(), installing: false, upgradeModel: InitUpgradeModel(), deleting: false, 79 | } 80 | 81 | m.releaseTable.Focus() 82 | return m, nil 83 | } 84 | 85 | func (m Model) Init() tea.Cmd { 86 | return m.list 87 | } 88 | 89 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 90 | var cmd tea.Cmd 91 | var cmds []tea.Cmd 92 | if m.installing { 93 | switch msg := msg.(type) { 94 | case tea.KeyMsg: 95 | if msg.String() == "esc" { 96 | m.installing = false 97 | } 98 | case types.InstallMsg: 99 | m.installing = false 100 | cmds = append(cmds, m.list) 101 | } 102 | m.installModel, cmd = m.installModel.Update(msg) 103 | cmds = append(cmds, cmd) 104 | return m, tea.Batch(cmds...) 105 | } 106 | if m.upgrading { 107 | switch msg := msg.(type) { 108 | case tea.KeyMsg: 109 | if msg.String() == "esc" { 110 | m.upgrading = false 111 | } 112 | case types.UpgradeMsg: 113 | m.upgrading = false 114 | cmds = append(cmds, m.list) 115 | } 116 | m.upgradeModel, cmd = m.upgradeModel.Update(msg) 117 | cmds = append(cmds, cmd) 118 | return m, tea.Batch(cmds...) 119 | } 120 | if m.deleting { 121 | switch msg := msg.(type) { 122 | case tea.KeyMsg: 123 | switch msg.String() { 124 | case "y": 125 | return m, m.delete 126 | case "n": 127 | m.deleting = false 128 | return m, nil 129 | } 130 | } 131 | } 132 | switch m.selectedView { 133 | case releasesView: 134 | m.releaseTable, cmd = m.releaseTable.Update(msg) 135 | cmds = append(cmds, cmd) 136 | case historyView: 137 | m.historyTable, cmd = m.historyTable.Update(msg) 138 | cmds = append(cmds, cmd) 139 | case notesView: 140 | m.notesVP, cmd = m.notesVP.Update(msg) 141 | cmds = append(cmds, cmd) 142 | case metadataView: 143 | m.metadataVP, cmd = m.metadataVP.Update(msg) 144 | cmds = append(cmds, cmd) 145 | case hooksView: 146 | m.hooksVP, cmd = m.hooksVP.Update(msg) 147 | cmds = append(cmds, cmd) 148 | case valuesView: 149 | m.valuesVP, cmd = m.valuesVP.Update(msg) 150 | cmds = append(cmds, cmd) 151 | case manifestView: 152 | m.manifestVP, cmd = m.manifestVP.Update(msg) 153 | cmds = append(cmds, cmd) 154 | } 155 | 156 | switch msg := msg.(type) { 157 | case tea.WindowSizeMsg: 158 | m.width = msg.Width 159 | m.height = msg.Height 160 | components.SetTable(&m.releaseTable, releaseCols, m.width) 161 | components.SetTable(&m.historyTable, historyCols, m.width) 162 | m.notesVP = viewport.New(m.width-6, 0) 163 | m.metadataVP = viewport.New(m.width-6, 0) 164 | m.hooksVP = viewport.New(m.width-6, 0) 165 | m.valuesVP = viewport.New(m.width-6, 0) 166 | m.manifestVP = viewport.New(m.width-6, 0) 167 | m.help.Width = msg.Width 168 | m.installModel, _ = m.installModel.Update(msg) 169 | m.upgradeModel, _ = m.upgradeModel.Update(msg) 170 | case types.ListReleasesMsg: 171 | if m.selectedView == releasesView { 172 | m.releaseTable.SetRows(msg.Content) 173 | } 174 | releaseTableCache = table.New(table.WithRows(msg.Content), table.WithColumns(m.releaseTable.Columns())) 175 | m.releaseTable, cmd = m.releaseTable.Update(msg) 176 | cmds = append(cmds, cmd, m.history, m.getNotes, m.getMetadata, m.getHooks, m.getValues, m.getManifest) 177 | case types.HistoryMsg: 178 | m.historyTable.SetRows(msg.Content) 179 | m.historyTable.SetCursor(0) 180 | m.historyTable, cmd = m.historyTable.Update(msg) 181 | cmds = append(cmds, cmd) 182 | case types.UpgradeMsg: 183 | cmds = append(cmds, m.list) 184 | m.selectedView = releasesView 185 | case types.DeleteMsg: 186 | m.deleting = false 187 | cmds = append(cmds, m.list) 188 | m.releaseTable.SetCursor(0) 189 | m.selectedView = releasesView 190 | case types.RollbackMsg: 191 | cmds = append(cmds, m.history) 192 | m.historyTable.SetCursor(0) 193 | case types.NotesMsg: 194 | m.notesVP.SetContent(msg.Content) 195 | m.notesVP, cmd = m.notesVP.Update(msg) 196 | cmds = append(cmds, cmd) 197 | case types.MetadataMsg: 198 | m.metadataVP.SetContent(msg.Content) 199 | m.metadataVP, cmd = m.metadataVP.Update(msg) 200 | cmds = append(cmds, cmd) 201 | case types.HooksMsg: 202 | m.hooksVP.SetContent(msg.Content) 203 | m.hooksVP, cmd = m.hooksVP.Update(msg) 204 | cmds = append(cmds, cmd) 205 | case types.ValuesMsg: 206 | m.valuesVP.SetContent(msg.Content) 207 | m.valuesVP, cmd = m.valuesVP.Update(msg) 208 | cmds = append(cmds, cmd) 209 | case types.ManifestMsg: 210 | m.manifestVP.SetContent(msg.Content) 211 | m.manifestVP, cmd = m.manifestVP.Update(msg) 212 | cmds = append(cmds, cmd) 213 | case types.InstallMsg: 214 | cmds = append(cmds, m.list) 215 | 216 | case tea.KeyMsg: 217 | switch msg.String() { 218 | case "i": 219 | m.installing = true 220 | cmd = m.installModel.Init() 221 | cmds = append(cmds, cmd) 222 | return m, tea.Batch(cmds...) 223 | case "r": 224 | switch m.selectedView { 225 | case releasesView: 226 | cmds = append(cmds, m.list) 227 | } 228 | case "R": 229 | switch m.selectedView { 230 | case historyView: 231 | return m, m.rollback 232 | } 233 | case "D": 234 | m.deleting = true 235 | case "u": 236 | m.upgrading = true 237 | m.upgradeModel.ReleaseName = m.releaseTable.SelectedRow()[0] 238 | m.upgradeModel.Namespace = m.releaseTable.SelectedRow()[1] 239 | cmd = m.upgradeModel.Init() 240 | cmds = append(cmds, cmd) 241 | return m, tea.Batch(cmds...) 242 | case "esc": 243 | m.installing = false 244 | m.upgrading = false 245 | switch m.selectedView { 246 | case releasesView: 247 | default: 248 | m.historyTable.SetCursor(0) 249 | m.selectedView = releasesView 250 | m.historyTable.Blur() 251 | m.releaseTable = releaseTableCache 252 | } 253 | case "enter", " ": 254 | switch m.selectedView { 255 | case releasesView: 256 | m.selectedView = historyView 257 | releaseTableCache = m.releaseTable 258 | m.releaseTable.SetHeight(3) 259 | m.releaseTable.SetRows([]table.Row{m.releaseTable.SelectedRow()}) 260 | m.releaseTable.GotoTop() 261 | m.historyTable.Focus() 262 | cmds = append(cmds, m.history, m.getNotes, m.getMetadata, m.getHooks, m.getValues, m.getManifest) 263 | } 264 | case "l", "right": 265 | switch m.selectedView { 266 | case releasesView: 267 | case manifestView: 268 | m.selectedView = historyView 269 | default: 270 | m.selectedView++ 271 | } 272 | case "h", "left": 273 | switch m.selectedView { 274 | case releasesView: 275 | case historyView: 276 | m.selectedView = manifestView 277 | default: 278 | m.selectedView-- 279 | } 280 | } 281 | } 282 | return m, tea.Batch(cmds...) 283 | } 284 | -------------------------------------------------------------------------------- /releases/overview_commands.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "os/exec" 9 | "strings" 10 | 11 | "github.com/charmbracelet/bubbles/table" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/pidanou/helm-tui/types" 14 | ) 15 | 16 | func (m Model) list() tea.Msg { 17 | var stdout bytes.Buffer 18 | var releases = []table.Row{} 19 | 20 | // Create the command 21 | cmd := exec.Command("helm", "ls", "--all-namespaces", "--output", "json") 22 | cmd.Stdout = &stdout 23 | 24 | // Run the command 25 | err := cmd.Run() 26 | if err != nil { 27 | return types.ListReleasesMsg{Err: err} 28 | } 29 | var rls []types.Release 30 | err = json.Unmarshal(stdout.Bytes(), &rls) 31 | if err != nil { 32 | return types.ListReleasesMsg{Content: releases} 33 | } 34 | 35 | for _, rel := range rls { 36 | row := []string{rel.Name, rel.Namespace, rel.Revision, rel.Updated, rel.Status, rel.Chart, rel.AppVersion} 37 | releases = append(releases, row) 38 | } 39 | return types.ListReleasesMsg{Content: releases, Err: nil} 40 | } 41 | 42 | func (m *Model) history() tea.Msg { 43 | var stdout bytes.Buffer 44 | 45 | if m.releaseTable.SelectedRow() == nil { 46 | return types.HistoryMsg{Content: nil, Err: errors.New("no release selected")} 47 | } 48 | 49 | // Create the command 50 | cmd := exec.Command("helm", "history", m.releaseTable.SelectedRow()[0], "--namespace", m.releaseTable.SelectedRow()[1], "--output", "json") 51 | cmd.Stdout = &stdout 52 | 53 | // Run the command 54 | err := cmd.Run() 55 | if err != nil { 56 | return types.HistoryMsg{Err: err} 57 | } 58 | var history []types.History 59 | var rows = []table.Row{} 60 | err = json.Unmarshal(stdout.Bytes(), &history) 61 | if err != nil { 62 | return types.HistoryMsg{Content: rows} 63 | } 64 | 65 | for _, line := range history { 66 | row := []string{fmt.Sprint(line.Revision), line.Updated, line.Status, line.Chart, line.AppVersion, line.Description} 67 | rows = append(rows, row) 68 | } 69 | return types.HistoryMsg{Content: rows, Err: nil} 70 | } 71 | 72 | func (m *Model) delete() tea.Msg { 73 | 74 | if m.releaseTable.SelectedRow() == nil { 75 | return types.DeleteMsg{Err: errors.New("No release selected")} 76 | } 77 | 78 | // Create the command 79 | cmd := exec.Command("helm", "uninstall", m.releaseTable.SelectedRow()[0], "--namespace", m.releaseTable.SelectedRow()[1]) 80 | 81 | // Run the command 82 | err := cmd.Run() 83 | if err != nil { 84 | return types.DeleteMsg{Err: err} 85 | } 86 | return types.DeleteMsg{Err: nil} 87 | } 88 | 89 | func (m Model) rollback() tea.Msg { 90 | 91 | // Create the command 92 | cmd := exec.Command("helm", "rollback", m.releaseTable.SelectedRow()[0], m.historyTable.SelectedRow()[0], "--namespace", m.releaseTable.SelectedRow()[1]) 93 | 94 | // Run the command 95 | err := cmd.Run() 96 | if err != nil { 97 | return types.RollbackMsg{Err: err} 98 | } 99 | return types.RollbackMsg{Err: nil} 100 | } 101 | 102 | func (m Model) getNotes() tea.Msg { 103 | var stdout bytes.Buffer 104 | 105 | if m.releaseTable.SelectedRow() == nil { 106 | return types.NotesMsg{Err: errors.New("no release selected")} 107 | } 108 | 109 | cmd := exec.Command("helm", "get", "notes", m.releaseTable.SelectedRow()[0], "--namespace", m.releaseTable.SelectedRow()[1]) 110 | cmd.Stdout = &stdout 111 | err := cmd.Run() 112 | if err != nil { 113 | return types.NotesMsg{Err: err} 114 | } 115 | 116 | return types.NotesMsg{Content: stdout.String(), Err: nil} 117 | } 118 | 119 | func (m Model) getMetadata() tea.Msg { 120 | var stdout bytes.Buffer 121 | 122 | if m.releaseTable.SelectedRow() == nil { 123 | return types.NotesMsg{Err: errors.New("no release selected")} 124 | } 125 | 126 | cmd := exec.Command("helm", "get", "metadata", m.releaseTable.SelectedRow()[0], "--namespace", m.releaseTable.SelectedRow()[1]) 127 | cmd.Stdout = &stdout 128 | err := cmd.Run() 129 | if err != nil { 130 | return types.MetadataMsg{Err: err} 131 | } 132 | 133 | return types.MetadataMsg{Content: stdout.String(), Err: nil} 134 | } 135 | 136 | func (m Model) getHooks() tea.Msg { 137 | var stdout bytes.Buffer 138 | 139 | if m.releaseTable.SelectedRow() == nil { 140 | return types.HooksMsg{Err: errors.New("no release selected")} 141 | } 142 | 143 | cmd := exec.Command("helm", "get", "hooks", m.releaseTable.SelectedRow()[0], "--namespace", m.releaseTable.SelectedRow()[1]) 144 | cmd.Stdout = &stdout 145 | err := cmd.Run() 146 | if err != nil { 147 | return types.HooksMsg{Err: err} 148 | } 149 | 150 | return types.HooksMsg{Content: stdout.String(), Err: nil} 151 | } 152 | 153 | func (m Model) getValues() tea.Msg { 154 | var stdout bytes.Buffer 155 | 156 | if m.releaseTable.SelectedRow() == nil { 157 | return types.ValuesMsg{Err: errors.New("no release selected")} 158 | } 159 | 160 | cmd := exec.Command("helm", "get", "values", m.releaseTable.SelectedRow()[0], "--namespace", m.releaseTable.SelectedRow()[1]) 161 | cmd.Stdout = &stdout 162 | err := cmd.Run() 163 | if err != nil { 164 | return types.ValuesMsg{Err: err} 165 | } 166 | lines := strings.Split(stdout.String(), "\n") 167 | if len(lines) <= 1 { 168 | return types.ValuesMsg{Err: errors.New("no values found")} 169 | } 170 | lines = lines[1:] 171 | 172 | return types.ValuesMsg{Content: strings.Join(lines, "\n"), Err: nil} 173 | } 174 | 175 | func (m Model) getManifest() tea.Msg { 176 | var stdout bytes.Buffer 177 | 178 | if m.releaseTable.SelectedRow() == nil { 179 | return types.ManifestMsg{Err: errors.New("no release selected")} 180 | } 181 | 182 | cmd := exec.Command("helm", "get", "manifest", m.releaseTable.SelectedRow()[0], "--namespace", m.releaseTable.SelectedRow()[1]) 183 | cmd.Stdout = &stdout 184 | err := cmd.Run() 185 | if err != nil { 186 | return types.ManifestMsg{Err: err} 187 | } 188 | return types.ManifestMsg{Content: stdout.String(), Err: nil} 189 | } 190 | -------------------------------------------------------------------------------- /releases/overview_keymap.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | ) 6 | 7 | // keyMap defines a set of keybindings. To work for help it must satisfy 8 | // key.Map. It could also very easily be a map[string]key.Binding. 9 | type keyMap struct { 10 | Install key.Binding 11 | Delete key.Binding 12 | Rollback key.Binding 13 | Refresh key.Binding 14 | Select key.Binding 15 | ChangeTab key.Binding 16 | Back key.Binding 17 | Upgrade key.Binding 18 | Cancel key.Binding 19 | } 20 | 21 | // ShortHelp returns keybindings to be shown in the mini help view. It's part 22 | // of the key.Map interface. 23 | func (k keyMap) ShortHelp() []key.Binding { 24 | return []key.Binding{k.Install, k.Delete, k.Upgrade, k.Select, k.Refresh, k.Rollback, k.ChangeTab, k.Cancel, k.Back} 25 | } 26 | 27 | // FullHelp returns keybindings for the expanded help view. It's part of the 28 | // key.Map interface. 29 | func (k keyMap) FullHelp() [][]key.Binding { 30 | return [][]key.Binding{} 31 | } 32 | 33 | var releasesKeys = keyMap{ 34 | Install: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "Install new release")), 35 | Delete: key.NewBinding( 36 | key.WithKeys("D"), 37 | key.WithHelp("D", "Delete release"), 38 | ), 39 | Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "Refresh")), 40 | Select: key.NewBinding(key.WithKeys("enter/space"), key.WithHelp("enter/space", "Details")), 41 | Upgrade: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "Upgrade release")), 42 | } 43 | 44 | var historyKeys = keyMap{ 45 | Install: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "Install new release")), 46 | Rollback: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "Rollback to revision")), 47 | Delete: key.NewBinding( 48 | key.WithKeys("D"), 49 | key.WithHelp("D", "Delete release"), 50 | ), 51 | Upgrade: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "Upgrade release")), 52 | ChangeTab: key.NewBinding(key.WithKeys("h", "l", "right", "left"), key.WithHelp("hl/←→", "Navigate tabs")), 53 | Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "Back")), 54 | } 55 | 56 | var readOnlyKeys = keyMap{ 57 | Install: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "Install new release")), 58 | Delete: key.NewBinding( 59 | key.WithKeys("D"), 60 | key.WithHelp("D", "Delete release"), 61 | ), 62 | Upgrade: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "Upgrade release")), 63 | ChangeTab: key.NewBinding(key.WithKeys("h", "l", "right", "left"), key.WithHelp("hl/←→", "Navigate tabs")), 64 | Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "Back")), 65 | } 66 | 67 | func generateKeys() []keyMap { 68 | return []keyMap{releasesKeys, historyKeys, readOnlyKeys, readOnlyKeys, readOnlyKeys, readOnlyKeys, readOnlyKeys} 69 | } 70 | -------------------------------------------------------------------------------- /releases/overview_test.go: -------------------------------------------------------------------------------- 1 | package releases 2 | -------------------------------------------------------------------------------- /releases/overview_view.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/pidanou/helm-tui/helpers" 9 | "github.com/pidanou/helm-tui/styles" 10 | ) 11 | 12 | func (m Model) View() string { 13 | var view string 14 | if m.installing { 15 | return m.installModel.View() 16 | } 17 | if m.upgrading { 18 | return m.upgradeModel.View() 19 | } 20 | if m.deleting { 21 | confirmMsg := " No release selected. Press n to go back " 22 | if m.releaseTable.SelectedRow() != nil { 23 | confirmMsg = fmt.Sprintf(" Delete release %s? y/n ", m.releaseTable.SelectedRow()[0]) 24 | } 25 | return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, styles.ActiveStyle.Border(styles.Border).Render(confirmMsg)) 26 | } 27 | 28 | switch m.selectedView { 29 | case releasesView: 30 | tHeight := m.height - 2 - 1 // releaseTable padding + helper 31 | m.releaseTable.SetHeight(tHeight) 32 | view = m.renderReleasesTableView() 33 | default: 34 | view = m.renderReleaseDetail() 35 | } 36 | 37 | helperStyle := m.help.Styles.ShortSeparator 38 | helpView := m.help.View(m.keys[m.selectedView]) + helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 39 | return view + "\n" + helpView 40 | } 41 | 42 | func (m Model) menuView() string { 43 | doc := strings.Builder{} 44 | 45 | var renderedTabs []string 46 | 47 | for i, t := range menuItem { 48 | var style lipgloss.Style 49 | isFirst, isActive := i == 0, i == int(m.selectedView)-1 50 | if isActive { 51 | style = styles.ActiveTabStyle 52 | } else { 53 | style = styles.InactiveTabStyle 54 | } 55 | border, _, _, _, _ := style.GetBorder() 56 | if isFirst && isActive { 57 | border.BottomLeft = "│" 58 | } else if isFirst && !isActive { 59 | border.BottomLeft = "├" 60 | } 61 | style = style.Border(border) 62 | renderedTabs = append(renderedTabs, style.Render(t)) 63 | } 64 | 65 | row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) 66 | doc.WriteString(row + strings.Repeat("─", m.width-lipgloss.Width(row)-1) + styles.Border.TopRight) 67 | return doc.String() 68 | } 69 | 70 | func (m Model) renderReleaseDetail() string { 71 | header := m.renderReleasesTableView() + "\n" + m.menuView() 72 | remainingHeight := m.height - lipgloss.Height(header) + lipgloss.Height(m.menuView()) - 2 - 1 // releaseTable padding + helper 73 | var view string 74 | switch m.selectedView { 75 | case historyView: 76 | m.historyTable.SetHeight(remainingHeight - 2) 77 | view = header + "\n" + m.renderHistoryTableView() 78 | case notesView: 79 | m.notesVP.Height = remainingHeight - 4 80 | view = header + "\n" + m.renderNotesView() 81 | case metadataView: 82 | m.metadataVP.Height = remainingHeight - 4 // -4: 2*1 Padding + 2 borders 83 | view = header + "\n" + m.renderMetadataView() 84 | case hooksView: 85 | m.hooksVP.Height = remainingHeight - 4 // -4: 2*1 Padding + 2 borders 86 | view = header + "\n" + m.renderHooksView() 87 | case valuesView: 88 | m.valuesVP.Height = remainingHeight - 4 // -4: 2*1 Padding + 2 borders 89 | view = header + "\n" + m.renderValuesView() 90 | case manifestView: 91 | m.manifestVP.Height = remainingHeight - 4 // -4: 2*1 Padding + 2 borders 92 | view = header + "\n" + m.renderManifestView() 93 | } 94 | return view 95 | } 96 | 97 | func (m Model) renderReleasesTableView() string { 98 | var releasesTopBorder string 99 | tableView := m.releaseTable.View() 100 | var baseStyle lipgloss.Style 101 | releasesTopBorder = styles.GenerateTopBorderWithTitle(" Releases ", m.releaseTable.Width(), styles.Border, styles.InactiveStyle) 102 | baseStyle = styles.InactiveStyle.Border(styles.Border, false, true, true) 103 | tableView = baseStyle.Render(tableView) 104 | return lipgloss.JoinVertical(lipgloss.Top, releasesTopBorder, tableView) 105 | } 106 | 107 | func (m Model) renderHistoryTableView() string { 108 | tableView := m.historyTable.View() 109 | var baseStyle lipgloss.Style 110 | baseStyle = styles.InactiveStyle.Border(styles.Border).UnsetBorderTop() 111 | tableView = baseStyle.Render(tableView) 112 | return tableView 113 | } 114 | 115 | func (m Model) renderNotesView() string { 116 | view := m.notesVP.View() 117 | var baseStyle lipgloss.Style 118 | baseStyle = styles.InactiveStyle.Padding(1, 2).Border(styles.Border, false, true, true) 119 | view = baseStyle.Render(view) 120 | return view 121 | } 122 | 123 | func (m Model) renderMetadataView() string { 124 | view := m.metadataVP.View() 125 | baseStyle := styles.InactiveStyle.Padding(1, 2).Border(styles.Border, false, true, true) 126 | view = baseStyle.Render(view) 127 | return view 128 | } 129 | 130 | func (m Model) renderHooksView() string { 131 | view := m.hooksVP.View() 132 | baseStyle := styles.InactiveStyle.Padding(1, 2).Border(styles.Border, false, true, true) 133 | view = baseStyle.Render(view) 134 | return view 135 | } 136 | 137 | func (m Model) renderValuesView() string { 138 | view := m.valuesVP.View() 139 | baseStyle := styles.InactiveStyle.Padding(1, 2).Border(styles.Border, false, true, true) 140 | view = baseStyle.Render(view) 141 | return view 142 | } 143 | 144 | func (m Model) renderManifestView() string { 145 | view := m.manifestVP.View() 146 | baseStyle := styles.InactiveStyle.Padding(1, 2).Border(styles.Border, false, true, true) 147 | view = baseStyle.Render(view) 148 | return view 149 | } 150 | -------------------------------------------------------------------------------- /releases/upgrade.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/help" 8 | "github.com/charmbracelet/bubbles/textinput" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/pidanou/helm-tui/helpers" 11 | "github.com/pidanou/helm-tui/types" 12 | ) 13 | 14 | const ( 15 | upgradeReleaseChartStep int = iota 16 | upgradeReleaseVersionStep 17 | upgradeReleaseValuesStep 18 | upgradeReleaseConfirmStep 19 | ) 20 | 21 | var upgradeInputsHelper = []string{ 22 | "Enter a chart name or chart directory (absolute path)", 23 | "Version (empty for latest)", 24 | "Edit values yes/no/use default ? y/n/d", 25 | "Confirm ? enter/esc", 26 | } 27 | 28 | type UpgradeModel struct { 29 | ReleaseName string 30 | Namespace string 31 | upgradeStep int 32 | Chart string 33 | Version string 34 | Inputs []textinput.Model 35 | width int 36 | height int 37 | help help.Model 38 | keys keyMap 39 | tag int 40 | } 41 | 42 | func InitUpgradeModel() UpgradeModel { 43 | chart := textinput.New() 44 | version := textinput.New() 45 | value := textinput.New() 46 | confirm := textinput.New() 47 | inputs := []textinput.Model{chart, version, value, confirm} 48 | m := UpgradeModel{upgradeStep: upgradeReleaseChartStep, Inputs: inputs, help: help.New(), keys: upgradeKeys} 49 | m.Inputs[upgradeReleaseChartStep].ShowSuggestions = true 50 | m.Inputs[upgradeReleaseVersionStep].ShowSuggestions = true 51 | return m 52 | } 53 | 54 | func (m UpgradeModel) Init() tea.Cmd { 55 | return m.Inputs[0].Focus() 56 | } 57 | 58 | func (m UpgradeModel) Update(msg tea.Msg) (UpgradeModel, tea.Cmd) { 59 | var cmd tea.Cmd 60 | cmds := make([]tea.Cmd, len(m.Inputs)) 61 | switch msg := msg.(type) { 62 | case tea.WindowSizeMsg: 63 | m.Inputs[upgradeReleaseChartStep].Width = msg.Width - 6 - len(upgradeInputsHelper[0]) 64 | m.Inputs[upgradeReleaseValuesStep].Width = msg.Width - 6 - len(upgradeInputsHelper[1]) 65 | case types.UpgradeMsg: 66 | m.upgradeStep = 0 67 | if m.Namespace == "" { 68 | m.Namespace = "default" 69 | } 70 | folder := fmt.Sprintf("%s/%s/%s", helpers.UserDir, m.Namespace, m.ReleaseName) 71 | cmds = append(cmds, m.cleanValueFile(folder), m.blurAllInputs(), m.resetAllInputs()) 72 | return m, tea.Batch(cmds...) 73 | 74 | case types.DebounceEndMsg: 75 | if msg.Tag == m.tag { 76 | if m.Inputs[upgradeReleaseChartStep].Focused() { 77 | m.Inputs[upgradeReleaseChartStep].SetSuggestions(m.searchLocalPackage()) 78 | } 79 | if m.Inputs[upgradeReleaseVersionStep].Focused() { 80 | m.Inputs[upgradeReleaseVersionStep].SetSuggestions(m.searchLocalPackageVersion()) 81 | } 82 | } 83 | case types.EditorFinishedMsg: 84 | m.upgradeStep++ 85 | for i := 0; i <= len(m.Inputs)-1; i++ { 86 | if i == int(m.upgradeStep) { 87 | cmds[i] = m.Inputs[i].Focus() 88 | continue 89 | } 90 | m.Inputs[i].Blur() 91 | } 92 | return m, tea.Batch(cmds...) 93 | case tea.KeyMsg: 94 | m.tag++ 95 | switch msg.String() { 96 | case "enter": 97 | if m.upgradeStep == upgradeReleaseConfirmStep { 98 | m.upgradeStep = 0 99 | cmd = m.blurAllInputs() 100 | cmds = append(cmds, cmd, m.upgrade) 101 | 102 | return m, tea.Batch(cmds...) 103 | } 104 | 105 | if m.upgradeStep == upgradeReleaseValuesStep { 106 | switch m.Inputs[upgradeReleaseValuesStep].Value() { 107 | case "d": 108 | defaultValue := true 109 | return m, m.openEditorWithValues(defaultValue) 110 | case "n": 111 | case "y": 112 | defaultValue := false 113 | return m, m.openEditorWithValues(defaultValue) 114 | default: 115 | return m, nil 116 | } 117 | } 118 | 119 | m.upgradeStep++ 120 | 121 | for i := 0; i <= len(m.Inputs)-1; i++ { 122 | if i == int(m.upgradeStep) { 123 | cmds[i] = m.Inputs[i].Focus() 124 | continue 125 | } 126 | m.Inputs[i].Blur() 127 | } 128 | 129 | return m, tea.Batch(cmds...) 130 | case "esc": 131 | m.upgradeStep = 0 132 | for i := 0; i <= len(m.Inputs)-1; i++ { 133 | m.Inputs[i].Blur() 134 | m.Inputs[i].SetValue("") 135 | } 136 | default: 137 | return m, tea.Batch(m.updateInputs(msg), tea.Tick(debounce, func(_ time.Time) tea.Msg { 138 | return types.DebounceEndMsg{Tag: m.tag} 139 | })) 140 | 141 | } 142 | } 143 | cmd = m.updateInputs(msg) 144 | return m, cmd 145 | } 146 | -------------------------------------------------------------------------------- /releases/upgrade_commands.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/pidanou/helm-tui/helpers" 12 | "github.com/pidanou/helm-tui/types" 13 | ) 14 | 15 | func (m *UpgradeModel) updateInputs(msg tea.Msg) tea.Cmd { 16 | cmds := make([]tea.Cmd, len(m.Inputs)) 17 | 18 | // Only text Inputs with Focus() set will respond, so it's safe to simply 19 | // update all of them here without any further logic. 20 | for i := range m.Inputs { 21 | m.Inputs[i], cmds[i] = m.Inputs[i].Update(msg) 22 | } 23 | return tea.Batch(cmds...) 24 | } 25 | 26 | func (m UpgradeModel) blurAllInputs() tea.Cmd { 27 | for i := range m.Inputs { 28 | m.Inputs[i].Blur() 29 | } 30 | return nil 31 | } 32 | 33 | func (m UpgradeModel) resetAllInputs() tea.Cmd { 34 | for i := range m.Inputs { 35 | m.Inputs[i].SetValue("") 36 | } 37 | return nil 38 | } 39 | 40 | func (m UpgradeModel) upgrade() tea.Msg { 41 | folder := fmt.Sprintf("%s/%s/%s", helpers.UserDir, m.Namespace, m.ReleaseName) 42 | _ = os.MkdirAll(folder, 0755) 43 | file := fmt.Sprintf("%s/values.yaml", folder) 44 | var cmd *exec.Cmd 45 | if m.Inputs[upgradeReleaseValuesStep].Value() == "y" || m.Inputs[upgradeReleaseValuesStep].Value() == "d" { 46 | cmd = exec.Command("helm", "upgrade", m.ReleaseName, m.Inputs[upgradeReleaseChartStep].Value(), "--values", file, "--namespace", m.Namespace) 47 | } else { 48 | cmd = exec.Command("helm", "upgrade", m.ReleaseName, m.Inputs[upgradeReleaseChartStep].Value(), "--namespace", m.Namespace) 49 | } 50 | var stderr bytes.Buffer 51 | cmd.Stderr = &stderr 52 | // Run the command 53 | err := cmd.Run() 54 | if err != nil { 55 | return types.UpgradeMsg{Err: err} 56 | } 57 | return types.UpgradeMsg{Err: nil} 58 | } 59 | 60 | func (m UpgradeModel) openEditorWithValues(defaultValues bool) tea.Cmd { 61 | var stdout, stderr bytes.Buffer 62 | folder := fmt.Sprintf("%s/%s/%s", helpers.UserDir, m.Namespace, m.ReleaseName) 63 | _ = os.MkdirAll(folder, 0755) 64 | file := fmt.Sprintf("%s/values.yaml", folder) 65 | packageName := m.Inputs[upgradeReleaseChartStep].Value() 66 | version := m.Inputs[upgradeReleaseVersionStep].Value() 67 | 68 | var cmd *exec.Cmd 69 | if defaultValues { 70 | cmd = exec.Command("helm", "show", "values", packageName, "--version", version) 71 | } else { 72 | cmd = exec.Command("helm", "get", "values", m.ReleaseName, "--namespace", m.Namespace) 73 | } 74 | cmd.Stdout = &stdout 75 | cmd.Stderr = &stderr 76 | err := cmd.Run() 77 | if err != nil { 78 | return func() tea.Msg { return types.EditorFinishedMsg{Err: err} } 79 | } 80 | return helpers.WriteAndOpenFile(stdout.Bytes(), file) 81 | } 82 | 83 | func (m UpgradeModel) searchLocalPackage() []string { 84 | if m.Inputs[upgradeReleaseChartStep].Value() == "" { 85 | return []string{} 86 | } 87 | var stdout bytes.Buffer 88 | cmd := exec.Command("helm", "search", "repo", m.Inputs[upgradeReleaseChartStep].Value(), "--output", "json") 89 | cmd.Stdout = &stdout 90 | err := cmd.Run() 91 | if err != nil { 92 | return []string{} 93 | } 94 | var pkgs []types.Pkg 95 | err = json.Unmarshal(stdout.Bytes(), &pkgs) 96 | if err != nil { 97 | return []string{} 98 | } 99 | var suggestions []string 100 | for _, p := range pkgs { 101 | suggestions = append(suggestions, p.Name) 102 | } 103 | 104 | return suggestions 105 | } 106 | 107 | func (m UpgradeModel) searchLocalPackageVersion() []string { 108 | var stdout bytes.Buffer 109 | cmd := exec.Command("helm", "search", "repo", "--regexp", "\v"+m.Inputs[upgradeReleaseChartStep].Value()+"\v", "--versions", "--output", "json") 110 | cmd.Stdout = &stdout 111 | err := cmd.Run() 112 | if err != nil { 113 | return []string{} 114 | } 115 | var pkgs []types.Pkg 116 | err = json.Unmarshal(stdout.Bytes(), &pkgs) 117 | if err != nil { 118 | return []string{} 119 | } 120 | 121 | var suggestions []string 122 | for _, pkg := range pkgs { 123 | suggestions = append(suggestions, pkg.Version) 124 | } 125 | 126 | return suggestions 127 | } 128 | 129 | func (m UpgradeModel) cleanValueFile(folder string) tea.Cmd { 130 | return func() tea.Msg { 131 | _ = os.RemoveAll(folder) 132 | return nil 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /releases/upgrade_keymap.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | var upgradeKeys = keyMap{ 6 | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "Cancel")), 7 | } 8 | -------------------------------------------------------------------------------- /releases/upgrade_test.go: -------------------------------------------------------------------------------- 1 | package releases 2 | -------------------------------------------------------------------------------- /releases/upgrade_view.go: -------------------------------------------------------------------------------- 1 | package releases 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/pidanou/helm-tui/helpers" 8 | "github.com/pidanou/helm-tui/styles" 9 | ) 10 | 11 | func (m UpgradeModel) View() string { 12 | helperStyle := m.help.Styles.ShortSeparator 13 | helpView := m.help.View(m.keys) + helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 14 | if m.Inputs[upgradeReleaseChartStep].Focused() { 15 | helpView = m.help.View(m.keys) + helperStyle.Render(" • ") + m.help.View(helpers.SuggestionInputKeyMap) + helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 16 | } 17 | var Inputs string 18 | for step := 0; step < len(m.Inputs); step++ { 19 | if step == 0 { 20 | Inputs = fmt.Sprintf("%s %s", upgradeInputsHelper[step], m.Inputs[step].View()) 21 | continue 22 | } 23 | Inputs = lipgloss.JoinVertical(lipgloss.Top, Inputs, fmt.Sprintf("%s %s", upgradeInputsHelper[step], m.Inputs[step].View())) 24 | } 25 | Inputs = styles.ActiveStyle.Border(styles.Border).Render(Inputs) 26 | Inputs = lipgloss.JoinVertical(lipgloss.Top, Inputs) 27 | return lipgloss.JoinVertical(lipgloss.Top, Inputs, helpView) 28 | } 29 | -------------------------------------------------------------------------------- /repositories/add.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/textinput" 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | const ( 10 | repoNameStep int = iota 11 | urlStep 12 | ) 13 | 14 | var addInputsHelper = []string{ 15 | "Enter repo name", 16 | "Enter repo URL", 17 | } 18 | 19 | type AddModel struct { 20 | addStep int 21 | Inputs []textinput.Model 22 | width int 23 | height int 24 | help help.Model 25 | keys keyMap 26 | } 27 | 28 | func InitAddModel() AddModel { 29 | repoName := textinput.New() 30 | url := textinput.New() 31 | inputs := []textinput.Model{repoName, url} 32 | m := AddModel{addStep: repoNameStep, Inputs: inputs, help: help.New(), keys: addKeys} 33 | return m 34 | } 35 | 36 | func (m AddModel) Init() tea.Cmd { 37 | return m.Inputs[repoNameStep].Focus() 38 | } 39 | 40 | func (m AddModel) Update(msg tea.Msg) (AddModel, tea.Cmd) { 41 | var cmd tea.Cmd 42 | cmds := make([]tea.Cmd, len(m.Inputs)) 43 | switch msg := msg.(type) { 44 | case tea.WindowSizeMsg: 45 | m.width = msg.Width 46 | m.height = msg.Height 47 | m.help.Width = msg.Width 48 | m.Inputs[repoNameStep].Width = msg.Width - 5 - len(inputsHelper[0]) 49 | m.Inputs[urlStep].Width = msg.Width - 5 - len(inputsHelper[1]) 50 | case tea.KeyMsg: 51 | switch msg.String() { 52 | case "enter": 53 | if m.addStep == urlStep { 54 | cmds = append(cmds, m.addRepo(m.Inputs[repoNameStep].Value(), m.Inputs[urlStep].Value())) 55 | cmd = m.resetAllInputs() 56 | cmds = append(cmds, cmd) 57 | cmd = m.blurAllInputs() 58 | cmds = append(cmds, cmd) 59 | cmd = m.Inputs[repoNameStep].Focus() 60 | cmds = append(cmds, cmd) 61 | return m, tea.Batch(cmds...) 62 | } 63 | 64 | m.addStep++ 65 | 66 | for i := 0; i <= len(m.Inputs)-1; i++ { 67 | if i == int(m.addStep) { 68 | cmds[i] = m.Inputs[i].Focus() 69 | continue 70 | } 71 | m.Inputs[i].Blur() 72 | } 73 | 74 | return m, tea.Batch(cmds...) 75 | case "esc": 76 | m.addStep = 0 77 | for i := 0; i <= len(m.Inputs)-1; i++ { 78 | m.Inputs[i].Blur() 79 | m.Inputs[i].SetValue("") 80 | } 81 | cmd = m.Inputs[repoNameStep].Focus() 82 | cmds = append(cmds, cmd) 83 | } 84 | } 85 | cmds = append(cmds, m.updateInputs(msg)) 86 | return m, tea.Batch(cmds...) 87 | } 88 | -------------------------------------------------------------------------------- /repositories/add_commands.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "bytes" 5 | "os/exec" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/pidanou/helm-tui/types" 9 | ) 10 | 11 | func (m *AddModel) updateInputs(msg tea.Msg) tea.Cmd { 12 | cmds := make([]tea.Cmd, len(m.Inputs)) 13 | 14 | // Only text inputs with Focus() set will respond, so it's safe to simply 15 | // update all of them here without any further logic. 16 | for i := range m.Inputs { 17 | m.Inputs[i], cmds[i] = m.Inputs[i].Update(msg) 18 | } 19 | return tea.Batch(cmds...) 20 | } 21 | 22 | func (m AddModel) blurAllInputs() tea.Cmd { 23 | for i := range m.Inputs { 24 | m.Inputs[i].Blur() 25 | } 26 | return nil 27 | } 28 | 29 | func (m AddModel) resetAllInputs() tea.Cmd { 30 | for i := range m.Inputs { 31 | m.Inputs[i].SetValue("") 32 | } 33 | return nil 34 | } 35 | 36 | func (m AddModel) addRepo(repoName, url string) tea.Cmd { 37 | return func() tea.Msg { 38 | var stdout, stderr bytes.Buffer 39 | 40 | var cmd *exec.Cmd 41 | cmd = exec.Command("helm", "repo", "add", repoName, url) 42 | // Create the command 43 | cmd.Stdout = &stdout 44 | cmd.Stderr = &stderr 45 | 46 | // Run the command 47 | err := cmd.Run() 48 | if err != nil { 49 | return types.AddRepoMsg{Err: err} 50 | } 51 | 52 | return types.AddRepoMsg{Err: nil} 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /repositories/add_keymap.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | var addKeys = keyMap{ 6 | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "Cancel")), 7 | } 8 | -------------------------------------------------------------------------------- /repositories/add_test.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // TestInitAddModel verifies that the AddModel initializes correctly. 11 | func TestInitAddModel(t *testing.T) { 12 | model := InitAddModel() 13 | 14 | assert.Equal(t, repoNameStep, model.addStep, "Initial installStep should be repoNameStep") 15 | assert.Equal(t, 2, len(model.Inputs), "AddModel should have 2 inputs") 16 | } 17 | 18 | // TestAddModelEnterKey verifies that pressing Enter advances the install step. 19 | func TestAddModelEnterKey(t *testing.T) { 20 | model := InitAddModel() 21 | msg := tea.KeyMsg{Type: tea.KeyEnter} 22 | 23 | updatedModel, _ := model.Update(msg) 24 | 25 | assert.Equal(t, urlStep, updatedModel.addStep, "installStep should advance to urlStep after pressing Enter") 26 | assert.True(t, updatedModel.Inputs[urlStep].Focused(), "Second input should be focused after pressing Enter") 27 | } 28 | 29 | // TestAddModelEscKey verifies that pressing Esc resets the install step and clears inputs. 30 | func TestAddModelEscKey(t *testing.T) { 31 | model := InitAddModel() 32 | model.Inputs[repoNameStep].SetValue("test-repo") 33 | model.Inputs[urlStep].SetValue("http://example.com") 34 | 35 | msg := tea.KeyMsg{Type: tea.KeyEsc} 36 | updatedModel, _ := model.Update(msg) 37 | 38 | assert.Equal(t, repoNameStep, updatedModel.addStep, "installStep should be reset to repoNameStep") 39 | for _, input := range updatedModel.Inputs { 40 | assert.Empty(t, input.Value(), "All inputs should be cleared after pressing Esc") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /repositories/add_view.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/pidanou/helm-tui/helpers" 8 | "github.com/pidanou/helm-tui/styles" 9 | ) 10 | 11 | func (m AddModel) View() string { 12 | helperStyle := m.help.Styles.ShortSeparator 13 | helpView := m.help.View(m.keys) + helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 14 | var inputs string 15 | for step := 0; step < len(m.Inputs); step++ { 16 | if step == 0 { 17 | inputs = fmt.Sprintf("%s %s", addInputsHelper[step], m.Inputs[step].View()) 18 | continue 19 | } 20 | inputs = lipgloss.JoinVertical(lipgloss.Top, inputs, fmt.Sprintf("%s %s", addInputsHelper[step], m.Inputs[step].View())) 21 | } 22 | inputs = styles.ActiveStyle.Border(styles.Border).Render(inputs) 23 | inputs = lipgloss.JoinVertical(lipgloss.Top, inputs) 24 | return lipgloss.JoinVertical(lipgloss.Top, inputs, helpView) 25 | } 26 | -------------------------------------------------------------------------------- /repositories/install.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/help" 7 | "github.com/charmbracelet/bubbles/textinput" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/pidanou/helm-tui/helpers" 10 | "github.com/pidanou/helm-tui/types" 11 | ) 12 | 13 | const ( 14 | nameStep installStep = iota 15 | namespaceStep 16 | valuesStep 17 | confirmStep 18 | ) 19 | 20 | var inputsHelper = []string{ 21 | "Enter release name", 22 | "Enter namespace (empty for default)", 23 | "Edit default values ? y/n", 24 | "Enter to install", 25 | } 26 | 27 | type InstallModel struct { 28 | installStep installStep 29 | Chart string 30 | Version string 31 | Inputs []textinput.Model 32 | width int 33 | height int 34 | help help.Model 35 | keys keyMap 36 | } 37 | 38 | func InitInstallModel(chart, version string) InstallModel { 39 | name := textinput.New() 40 | namespace := textinput.New() 41 | value := textinput.New() 42 | confirm := textinput.New() 43 | inputs := []textinput.Model{name, namespace, value, confirm} 44 | m := InstallModel{installStep: nameStep, Inputs: inputs, help: help.New(), Chart: chart, Version: version, keys: installKeys} 45 | return m 46 | } 47 | 48 | func (m InstallModel) Init() tea.Cmd { 49 | return m.Inputs[0].Focus() 50 | } 51 | 52 | func (m InstallModel) Update(msg tea.Msg) (InstallModel, tea.Cmd) { 53 | var cmd tea.Cmd 54 | cmds := make([]tea.Cmd, len(m.Inputs)) 55 | switch msg := msg.(type) { 56 | case tea.WindowSizeMsg: 57 | m.width = msg.Width 58 | m.height = msg.Height 59 | m.help.Width = msg.Width 60 | m.Inputs[nameStep].Width = msg.Width - 5 - len(inputsHelper[0]) 61 | m.Inputs[namespaceStep].Width = msg.Width - 5 - len(inputsHelper[1]) 62 | m.Inputs[valuesStep].Width = msg.Width - 5 - len(inputsHelper[2]) 63 | m.Inputs[confirmStep].Width = msg.Width - 5 - len(inputsHelper[3]) 64 | case types.EditorFinishedMsg: 65 | m.installStep++ 66 | for i := 0; i <= len(m.Inputs)-1; i++ { 67 | if i == int(m.installStep) { 68 | cmds[i] = m.Inputs[i].Focus() 69 | continue 70 | } 71 | m.Inputs[i].Blur() 72 | } 73 | return m, tea.Batch(cmds...) 74 | case types.InstallMsg: 75 | m.installStep = 0 76 | releaseName := m.Inputs[nameStep].Value() 77 | namespace := m.Inputs[namespaceStep].Value() 78 | if namespace == "" { 79 | namespace = "default" 80 | } 81 | folder := fmt.Sprintf("%s/%s/%s", helpers.UserDir, namespace, releaseName) 82 | cmds = append(cmds, m.cleanValueFile(folder), m.blurAllInputs(), m.resetAllInputs(), m.Inputs[nameStep].Focus()) 83 | 84 | return m, tea.Batch(cmds...) 85 | case tea.KeyMsg: 86 | switch msg.String() { 87 | case "enter": 88 | if m.installStep == confirmStep { 89 | m.installStep = 0 90 | 91 | cmd = m.installPackage(m.Inputs[valuesStep].Value()) 92 | cmds = append(cmds, cmd) 93 | 94 | m.Inputs[confirmStep].Blur() 95 | cmd = m.Inputs[nameStep].Focus() 96 | cmds = append(cmds, cmd) 97 | return m, tea.Batch(cmds...) 98 | } 99 | 100 | if m.installStep == valuesStep { 101 | switch m.Inputs[valuesStep].Value() { 102 | case "y": 103 | return m, m.openEditorDefaultValues() 104 | case "n": 105 | default: 106 | return m, nil 107 | } 108 | } 109 | 110 | m.installStep++ 111 | 112 | for i := 0; i <= len(m.Inputs)-1; i++ { 113 | if i == int(m.installStep) { 114 | cmds[i] = m.Inputs[i].Focus() 115 | continue 116 | } 117 | m.Inputs[i].Blur() 118 | } 119 | 120 | return m, tea.Batch(cmds...) 121 | case "esc": 122 | m.installStep = 0 123 | for i := 0; i <= len(m.Inputs)-1; i++ { 124 | m.Inputs[i].Blur() 125 | m.Inputs[i].SetValue("") 126 | } 127 | cmds = append(cmds, m.Inputs[repoNameStep].Focus()) 128 | } 129 | } 130 | cmds = append(cmds, m.updateInputs(msg)) 131 | return m, tea.Batch(cmds...) 132 | } 133 | -------------------------------------------------------------------------------- /repositories/install_commands.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/pidanou/helm-tui/helpers" 11 | "github.com/pidanou/helm-tui/types" 12 | ) 13 | 14 | func (m *InstallModel) updateInputs(msg tea.Msg) tea.Cmd { 15 | cmds := make([]tea.Cmd, len(m.Inputs)) 16 | 17 | // Only text inputs with Focus() set will respond, so it's safe to simply 18 | // update all of them here without any further logic. 19 | for i := range m.Inputs { 20 | m.Inputs[i], cmds[i] = m.Inputs[i].Update(msg) 21 | } 22 | return tea.Batch(cmds...) 23 | } 24 | 25 | func (m InstallModel) blurAllInputs() tea.Cmd { 26 | for i := range m.Inputs { 27 | m.Inputs[i].Blur() 28 | } 29 | return nil 30 | } 31 | 32 | func (m InstallModel) resetAllInputs() tea.Cmd { 33 | for i := range m.Inputs { 34 | m.Inputs[i].SetValue("") 35 | } 36 | return nil 37 | } 38 | 39 | func (m InstallModel) installPackage(mode string) tea.Cmd { 40 | releaseName := m.Inputs[nameStep].Value() 41 | namespace := m.Inputs[namespaceStep].Value() 42 | if namespace == "" { 43 | namespace = "default" 44 | } 45 | folder := fmt.Sprintf("%s/%s/%s", helpers.UserDir, namespace, releaseName) 46 | file := fmt.Sprintf("%s/values.yaml", folder) 47 | return func() tea.Msg { 48 | var stdout, stderr bytes.Buffer 49 | 50 | var cmd *exec.Cmd 51 | // Create the command 52 | if mode == "y" { 53 | cmd = exec.Command("helm", "install", releaseName, m.Chart, "--version", m.Version, "--values", file, "--namespace", namespace, "--create-namespace") 54 | } else { 55 | cmd = exec.Command("helm", "install", releaseName, m.Chart, "--version", m.Version, "--namespace", namespace, "--create-namespace") 56 | } 57 | cmd.Stdout = &stdout 58 | cmd.Stderr = &stderr 59 | 60 | // Run the command 61 | err := cmd.Run() 62 | if err != nil { 63 | return types.InstallMsg{Err: err} 64 | } 65 | 66 | return types.InstallMsg{Err: nil} 67 | } 68 | } 69 | 70 | func (m InstallModel) openEditorDefaultValues() tea.Cmd { 71 | var stdout, stderr bytes.Buffer 72 | releaseName := m.Inputs[nameStep].Value() 73 | namespace := m.Inputs[namespaceStep].Value() 74 | if namespace == "" { 75 | namespace = "default" 76 | } 77 | folder := fmt.Sprintf("%s/%s/%s", helpers.UserDir, namespace, releaseName) 78 | _ = os.MkdirAll(folder, 0755) 79 | file := fmt.Sprintf("%s/values.yaml", folder) 80 | packageName := m.Chart 81 | version := m.Version 82 | 83 | cmd := exec.Command("helm", "show", "values", packageName, "--version", version) 84 | cmd.Stdout = &stdout 85 | cmd.Stderr = &stderr 86 | err := cmd.Run() 87 | if err != nil { 88 | return func() tea.Msg { return types.EditorFinishedMsg{Err: err} } 89 | } 90 | err = os.WriteFile(file, stdout.Bytes(), 0644) 91 | if err != nil { 92 | return func() tea.Msg { 93 | return types.EditorFinishedMsg{Err: err} 94 | } 95 | } 96 | editor := os.Getenv("EDITOR") 97 | if editor == "" { 98 | editor = "vim" 99 | } 100 | c := exec.Command(editor, file) 101 | return tea.ExecProcess(c, func(err error) tea.Msg { 102 | return types.EditorFinishedMsg{Err: err} 103 | }) 104 | } 105 | 106 | func (m InstallModel) cleanValueFile(folder string) tea.Cmd { 107 | return func() tea.Msg { 108 | _ = os.RemoveAll(folder) 109 | return nil 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /repositories/install_keymap.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | var installKeys = keyMap{ 6 | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "Cancel")), 7 | } 8 | -------------------------------------------------------------------------------- /repositories/install_test.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | -------------------------------------------------------------------------------- /repositories/install_view.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/pidanou/helm-tui/helpers" 8 | "github.com/pidanou/helm-tui/styles" 9 | ) 10 | 11 | func (m InstallModel) View() string { 12 | helperStyle := m.help.Styles.ShortSeparator 13 | helpView := m.help.View(m.keys) + helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 14 | var inputs string 15 | for step := 0; step < len(m.Inputs); step++ { 16 | if step == 0 { 17 | inputs = fmt.Sprintf("%s %s", inputsHelper[step], m.Inputs[step].View()) 18 | continue 19 | } 20 | inputs = lipgloss.JoinVertical(lipgloss.Top, inputs, fmt.Sprintf("%s %s", inputsHelper[step], m.Inputs[step].View())) 21 | } 22 | inputs = styles.ActiveStyle.Border(styles.Border).Render(inputs) 23 | inputs = lipgloss.JoinVertical(lipgloss.Top, inputs) 24 | return lipgloss.JoinVertical(lipgloss.Top, "\n", "Installing "+m.Chart+" "+m.Version, "\n", inputs, helpView) 25 | } 26 | -------------------------------------------------------------------------------- /repositories/overview.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/table" 6 | "github.com/charmbracelet/bubbles/viewport" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/pidanou/helm-tui/components" 9 | "github.com/pidanou/helm-tui/types" 10 | ) 11 | 12 | type selectedView int 13 | type installStep int 14 | 15 | const ( 16 | listView selectedView = iota 17 | packagesView 18 | versionsView 19 | ) 20 | 21 | type Model struct { 22 | selectedView selectedView 23 | keys []keyMap 24 | tables []table.Model 25 | installModel InstallModel 26 | addModel AddModel 27 | help help.Model 28 | installing bool 29 | adding bool 30 | defaultValueVP viewport.Model 31 | showDefaultValue bool 32 | width int 33 | height int 34 | } 35 | 36 | var repositoryCols = []components.ColumnDefinition{ 37 | {Title: "Name", FlexFactor: 1}, 38 | {Title: "URL", FlexFactor: 3}, 39 | } 40 | 41 | var packagesCols = []components.ColumnDefinition{ 42 | {Title: "Name", FlexFactor: 1}, 43 | } 44 | 45 | var versionsCols = []components.ColumnDefinition{ 46 | {Title: "Chart Version", Width: 13}, 47 | {Title: "App Version", Width: 13}, 48 | {Title: "Description", FlexFactor: 1}, 49 | } 50 | 51 | func InitModel() (tea.Model, tea.Cmd) { 52 | tables := []table.Model{} 53 | t := components.GenerateTable() 54 | repoTable := t 55 | tablePackagesView := t 56 | tableVersionsView := t 57 | repoTable.Focus() 58 | tables = append(tables, repoTable, tablePackagesView, tableVersionsView) 59 | repoTable.Focus() 60 | keys := generateKeys() 61 | m := Model{ 62 | tables: tables, 63 | selectedView: listView, 64 | keys: keys, 65 | installModel: InitInstallModel("", ""), 66 | addModel: InitAddModel(), 67 | help: help.New(), 68 | installing: false, 69 | adding: false, 70 | defaultValueVP: viewport.New(0, 0), 71 | showDefaultValue: false, 72 | } 73 | return m, nil 74 | } 75 | 76 | func (m Model) Init() tea.Cmd { 77 | return m.list 78 | } 79 | 80 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 81 | var cmd tea.Cmd 82 | var cmds []tea.Cmd 83 | if m.installing { 84 | switch msg := msg.(type) { 85 | case tea.KeyMsg: 86 | if msg.String() == "esc" { 87 | m.installing = false 88 | m.installModel, cmd = m.installModel.Update(msg) 89 | cmds = append(cmds, cmd) 90 | return m, tea.Batch(cmds...) 91 | } 92 | case types.InstallMsg: 93 | m.installing = false 94 | cmds = append(cmds, m.list) 95 | } 96 | m.installModel, cmd = m.installModel.Update(msg) 97 | cmds = append(cmds, cmd) 98 | return m, tea.Batch(cmds...) 99 | } 100 | if m.adding { 101 | switch msg := msg.(type) { 102 | case tea.KeyMsg: 103 | if msg.String() == "esc" { 104 | m.adding = false 105 | m.addModel, cmd = m.addModel.Update(msg) 106 | cmds = append(cmds, cmd) 107 | return m, tea.Batch(cmds...) 108 | } 109 | case types.AddRepoMsg: 110 | m.adding = false 111 | return m, m.list 112 | } 113 | m.addModel, cmd = m.addModel.Update(msg) 114 | cmds = append(cmds, cmd) 115 | return m, tea.Batch(cmds...) 116 | } 117 | // handle messages 118 | switch msg := msg.(type) { 119 | case tea.WindowSizeMsg: 120 | m.width = msg.Width 121 | m.height = msg.Height 122 | components.SetTable(&m.tables[listView], repositoryCols, m.width/4) 123 | components.SetTable(&m.tables[packagesView], packagesCols, m.width/4) 124 | components.SetTable(&m.tables[versionsView], versionsCols, 2*m.width/4) 125 | m.defaultValueVP.Width = m.width - 2 126 | m.installModel.Update(msg) 127 | m.addModel.Update(msg) 128 | m.help.Width = msg.Width 129 | case types.ListRepoMsg: 130 | m.tables[listView].SetRows(msg.Content) 131 | m.tables[listView], cmd = m.tables[listView].Update(msg) 132 | cmds = append(cmds, cmd, m.searchPackages) 133 | case types.PackagesMsg: 134 | m.tables[packagesView].SetRows(msg.Content) 135 | m.tables[packagesView], cmd = m.tables[packagesView].Update(msg) 136 | cmds = append(cmds, cmd, m.searchPackageVersions) 137 | case types.PackageVersionsMsg: 138 | m.tables[versionsView].SetRows(msg.Content) 139 | m.tables[versionsView], cmd = m.tables[versionsView].Update(msg) 140 | cmds = append(cmds, cmd) 141 | case types.RemoveMsg: 142 | cmds = append(cmds, m.list) 143 | m.tables[listView].SetCursor(0) 144 | m.selectedView = listView 145 | case types.InstallMsg: 146 | m.installing = false 147 | case types.AddRepoMsg: 148 | m.adding = false 149 | cmds = append(cmds, m.list) 150 | case types.UpdateRepoMsg: 151 | cmds = append(cmds, m.list) 152 | case types.DefaultValueMsg: 153 | m.defaultValueVP.SetContent(msg.Content) 154 | 155 | // handle key presses 156 | case tea.KeyMsg: 157 | switch msg.String() { 158 | case "i": 159 | if m.tables[packagesView].SelectedRow() != nil && m.tables[versionsView].SelectedRow() != nil { 160 | m.installModel.Chart = m.tables[packagesView].SelectedRow()[0] 161 | m.installModel.Version = m.tables[versionsView].SelectedRow()[0] 162 | m.installing = true 163 | cmd = m.installModel.Init() 164 | return m, cmd 165 | } 166 | case "a": 167 | m.adding = true 168 | cmd = m.addModel.Init() 169 | return m, cmd 170 | case "v": 171 | m.showDefaultValue = true 172 | return m, m.getDefaultValue 173 | case "down", "up", "j", "k": 174 | switch m.selectedView { 175 | case listView: 176 | cmds = append(cmds, m.searchPackages) 177 | case packagesView: 178 | cmds = append(cmds, m.searchPackageVersions) 179 | } 180 | case "l", "right": 181 | switch m.selectedView { 182 | case versionsView: 183 | default: 184 | m.selectedView++ 185 | } 186 | m.FocusOnlyTable(m.selectedView) 187 | case "D": 188 | cmds = append(cmds, m.remove) 189 | case "h", "left": 190 | switch m.selectedView { 191 | case listView: 192 | default: 193 | m.selectedView-- 194 | } 195 | m.FocusOnlyTable(m.selectedView) 196 | case "u": 197 | return m, m.update 198 | case "r": 199 | return m, m.list 200 | case "esc": 201 | m.installing = false 202 | m.adding = false 203 | m.showDefaultValue = false 204 | m.selectedView = listView 205 | } 206 | } 207 | m.tables[listView], cmd = m.tables[listView].Update(msg) 208 | cmds = append(cmds, cmd) 209 | m.tables[packagesView], cmd = m.tables[packagesView].Update(msg) 210 | cmds = append(cmds, cmd) 211 | m.tables[versionsView], cmd = m.tables[versionsView].Update(msg) 212 | cmds = append(cmds, cmd) 213 | m.defaultValueVP, cmd = m.defaultValueVP.Update(msg) 214 | cmds = append(cmds, cmd) 215 | return m, tea.Batch(cmds...) 216 | } 217 | 218 | func (m *Model) FocusOnlyTable(index selectedView) { 219 | m.tables[listView].Blur() 220 | m.tables[packagesView].Blur() 221 | m.tables[versionsView].Blur() 222 | m.tables[index].Focus() 223 | } 224 | -------------------------------------------------------------------------------- /repositories/overview_commands.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "os/exec" 9 | 10 | "github.com/charmbracelet/bubbles/table" 11 | tea "github.com/charmbracelet/bubbletea" 12 | 13 | "github.com/pidanou/helm-tui/types" 14 | ) 15 | 16 | func (m Model) list() tea.Msg { 17 | var stdout bytes.Buffer 18 | repositories := []table.Row{} 19 | 20 | // Create the command 21 | cmd := exec.Command("helm", "repo", "update") 22 | err := cmd.Run() 23 | 24 | if err != nil { 25 | return types.ListRepoMsg{Err: err} 26 | } 27 | cmd = exec.Command("helm", "repo", "ls", "--output", "json") 28 | cmd.Stdout = &stdout 29 | 30 | // Run the command 31 | err = cmd.Run() 32 | if err != nil { 33 | return types.ListRepoMsg{Err: err} 34 | } 35 | 36 | var repos []types.Repository 37 | err = json.Unmarshal(stdout.Bytes(), &repos) 38 | if err != nil { 39 | return []string{} 40 | } 41 | 42 | for _, repo := range repos { 43 | row := []string{repo.Name, repo.URL} 44 | repositories = append(repositories, row) 45 | } 46 | return types.ListRepoMsg{Content: repositories, Err: nil} 47 | } 48 | 49 | func (m Model) update() tea.Msg { 50 | if m.tables[listView].SelectedRow() == nil { 51 | return types.UpdateRepoMsg{Err: errors.New("no repo selected")} 52 | } 53 | var stdout bytes.Buffer 54 | 55 | // Create the command 56 | cmd := exec.Command("helm", "repo", "update", m.tables[listView].SelectedRow()[0]) 57 | cmd.Stdout = &stdout 58 | 59 | // Run the command 60 | err := cmd.Run() 61 | if err != nil { 62 | return types.UpdateRepoMsg{Err: err} 63 | } 64 | 65 | return types.UpdateRepoMsg{Err: nil} 66 | } 67 | 68 | func (m Model) remove() tea.Msg { 69 | if m.tables[listView].SelectedRow() == nil { 70 | return types.RemoveMsg{Err: errors.New("no repo selected")} 71 | } 72 | var stdout bytes.Buffer 73 | 74 | // Create the command 75 | cmd := exec.Command("helm", "repo", "remove", m.tables[listView].SelectedRow()[0]) 76 | cmd.Stdout = &stdout 77 | 78 | // Run the command 79 | err := cmd.Run() 80 | if err != nil { 81 | return types.RemoveMsg{Err: err} 82 | } 83 | 84 | return types.RemoveMsg{Err: nil} 85 | } 86 | 87 | func (m Model) searchPackages() tea.Msg { 88 | var stdout bytes.Buffer 89 | releases := []table.Row{} 90 | if m.tables[listView].SelectedRow() == nil { 91 | return types.PackagesMsg{Content: releases, Err: errors.New("no repo selected")} 92 | } 93 | 94 | // Create the command 95 | cmd := exec.Command("helm", "search", "repo", fmt.Sprintf("%s/", m.tables[listView].SelectedRow()[0]), "--output", "json") 96 | cmd.Stdout = &stdout 97 | 98 | // Run the command 99 | err := cmd.Run() 100 | if err != nil { 101 | return types.PackagesMsg{Content: releases, Err: err} 102 | } 103 | var pkgs []types.Pkg 104 | err = json.Unmarshal(stdout.Bytes(), &pkgs) 105 | if err != nil { 106 | return []string{} 107 | } 108 | 109 | for _, pkg := range pkgs { 110 | releases = append(releases, table.Row{pkg.Name}) 111 | } 112 | return types.PackagesMsg{Content: releases, Err: nil} 113 | } 114 | 115 | func (m Model) searchPackageVersions() tea.Msg { 116 | var stdout bytes.Buffer 117 | versions := []table.Row{} 118 | if m.tables[packagesView].SelectedRow() == nil { 119 | return types.PackageVersionsMsg{Content: versions, Err: errors.New("no package selected")} 120 | } 121 | 122 | // Create the command 123 | cmd := exec.Command("helm", "search", "repo", fmt.Sprintf("%s", m.tables[packagesView].SelectedRow()[0]), "--versions", "--output", "json") 124 | cmd.Stdout = &stdout 125 | 126 | // Run the command 127 | err := cmd.Run() 128 | if err != nil { 129 | return types.PackageVersionsMsg{Content: versions, Err: err} 130 | } 131 | var pkgs []types.Pkg 132 | err = json.Unmarshal(stdout.Bytes(), &pkgs) 133 | if err != nil { 134 | return []string{} 135 | } 136 | 137 | for _, pkg := range pkgs { 138 | versions = append(versions, table.Row{pkg.Version, pkg.AppVersion, pkg.Description}) 139 | } 140 | 141 | return types.PackageVersionsMsg{Content: versions, Err: nil} 142 | } 143 | 144 | func (m Model) getDefaultValue() tea.Msg { 145 | var stdout bytes.Buffer 146 | cmd := exec.Command("helm", "show", "values", fmt.Sprintf("%s", m.tables[packagesView].SelectedRow()[0]), "--version", m.tables[versionsView].SelectedRow()[0]) 147 | cmd.Stdout = &stdout 148 | err := cmd.Run() 149 | if err != nil { 150 | return types.DefaultValueMsg{Content: "Unable to get default values"} 151 | } 152 | return types.DefaultValueMsg{Content: stdout.String()} 153 | } 154 | -------------------------------------------------------------------------------- /repositories/overview_keymap.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | ) 6 | 7 | // keyMap defines a set of keybindings. To work for help it must satisfy 8 | // key.Map. It could also very easily be a map[string]key.Binding. 9 | type keyMap struct { 10 | Delete key.Binding 11 | Refresh key.Binding 12 | Move key.Binding 13 | Update key.Binding 14 | Install key.Binding 15 | Select key.Binding 16 | Cancel key.Binding 17 | } 18 | 19 | // ShortHelp returns keybindings to be shown in the mini help view. It's part 20 | // of the key.Map interface. 21 | func (k keyMap) ShortHelp() []key.Binding { 22 | return []key.Binding{k.Delete, k.Update, k.Move, k.Select, k.Refresh, k.Install, k.Cancel} 23 | } 24 | 25 | // FullHelp returns keybindings for the expanded help view. It's part of the 26 | // key.Map interface. 27 | func (k keyMap) FullHelp() [][]key.Binding { 28 | return [][]key.Binding{} 29 | } 30 | 31 | var defaultValuesKeyHelp = keyMap{ 32 | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "Cancel")), 33 | } 34 | 35 | var repoListKeys = keyMap{ 36 | Delete: key.NewBinding( 37 | key.WithKeys("D"), 38 | key.WithHelp("D", "Delete repo"), 39 | ), 40 | Move: key.NewBinding(key.WithKeys("h", " j", "k", "l", "up", "down"), key.WithHelp("hjkl/←↑↓→", "Move")), 41 | Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "Refresh")), 42 | Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "Select")), 43 | Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "Update repo")), 44 | Install: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "Install version")), 45 | } 46 | 47 | var chartsListKeys = keyMap{ 48 | Delete: key.NewBinding( 49 | key.WithKeys("D"), 50 | key.WithHelp("D", "Delete repo"), 51 | ), 52 | Move: key.NewBinding(key.WithKeys("h", " j", "k", "l", "left", "right", "up", "down"), key.WithHelp("hjkl/←/↑/↓/→", "Move")), 53 | Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "Select")), 54 | Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "Update repo")), 55 | Install: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "Install version")), 56 | } 57 | 58 | var versionsKeys = keyMap{ 59 | Delete: key.NewBinding( 60 | key.WithKeys("D"), 61 | key.WithHelp("D", "Delete repo"), 62 | ), 63 | Move: key.NewBinding(key.WithKeys("h", " j", "k", "l", "left", "right", "up", "down"), key.WithHelp("hjkl/←↑↓→", "Move")), 64 | Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "Upgrade repo")), 65 | Install: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "Install version")), 66 | } 67 | 68 | func generateKeys() []keyMap { 69 | return []keyMap{repoListKeys, chartsListKeys, versionsKeys} 70 | } 71 | -------------------------------------------------------------------------------- /repositories/overview_test.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | -------------------------------------------------------------------------------- /repositories/overview_view.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/table" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/pidanou/helm-tui/helpers" 7 | "github.com/pidanou/helm-tui/styles" 8 | ) 9 | 10 | func (m Model) View() string { 11 | helpView := m.help.View(m.keys[m.selectedView]) 12 | repoView := m.renderTable(m.tables[listView], " Repositories ", m.selectedView == listView) 13 | packagesView := m.renderTable(m.tables[packagesView], " Packages ", m.selectedView == packagesView) 14 | versionsView := m.renderTable(m.tables[versionsView], " Versions ", m.selectedView == versionsView) 15 | view := lipgloss.JoinHorizontal(lipgloss.Top, repoView, packagesView, versionsView) 16 | if m.installing { 17 | return m.installModel.View() 18 | } 19 | if m.adding { 20 | return m.addModel.View() 21 | } 22 | if m.showDefaultValue { 23 | return m.renderDefaultValueView() 24 | } 25 | helperStyle := m.help.Styles.ShortSeparator 26 | return view + "\n" + helpView + helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 27 | } 28 | 29 | func (m Model) renderTable(table table.Model, title string, active bool) string { 30 | var topBorder string 31 | table.SetHeight(m.height - 3) 32 | tableView := table.View() 33 | var baseStyle lipgloss.Style 34 | baseStyle = styles.InactiveStyle.Border(styles.Border, false, true, true) 35 | topBorder = styles.GenerateTopBorderWithTitle(title, table.Width(), styles.Border, styles.InactiveStyle) 36 | if active { 37 | topBorder = styles.GenerateTopBorderWithTitle(title, table.Width(), styles.Border, styles.ActiveStyle.Foreground(styles.HighlightColor)) 38 | baseStyle = styles.ActiveStyle.Border(styles.Border, false, true, true) 39 | } 40 | tableView = baseStyle.Render(tableView) 41 | return lipgloss.JoinVertical(lipgloss.Top, topBorder, tableView) 42 | } 43 | 44 | func (m Model) renderDefaultValueView() string { 45 | m.defaultValueVP.Height = m.height - 2 - 1 46 | defaultValueTopBorder := styles.GenerateTopBorderWithTitle(" Default Values ", m.defaultValueVP.Width, styles.Border, styles.InactiveStyle) 47 | baseStyle := styles.InactiveStyle.Border(styles.Border, false, true, true) 48 | helperStyle := m.help.Styles.ShortSeparator 49 | helpView := helperStyle.Render(" • ") + m.help.View(helpers.CommonKeys) 50 | return lipgloss.JoinVertical(lipgloss.Top, defaultValueTopBorder, baseStyle.Render(m.defaultValueVP.View()), m.help.View(defaultValuesKeyHelp)+helpView) 51 | } 52 | -------------------------------------------------------------------------------- /styles/helpers.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | func GenerateTopBorderWithTitle(title string, width int, border lipgloss.Border, style lipgloss.Style) string { 10 | var topBorder string 11 | // total length of top border runes, not including corners 12 | length := max(0, width-lipgloss.Width(title)) 13 | leftLength := length / 2 14 | rightLength := max(0, length-leftLength) 15 | topBorder = lipgloss.JoinHorizontal(lipgloss.Left, 16 | border.TopLeft, 17 | strings.Repeat(border.Top, leftLength), 18 | title, 19 | strings.Repeat(border.Top, rightLength), 20 | border.TopRight, 21 | ) 22 | return style.Render(topBorder) 23 | } 24 | -------------------------------------------------------------------------------- /styles/styles.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | func tabBorderWithBottom(left, middle, right string) lipgloss.Border { 9 | border := lipgloss.RoundedBorder() 10 | border.BottomLeft = left 11 | border.Bottom = middle 12 | border.BottomRight = right 13 | return border 14 | } 15 | 16 | var ( 17 | InactiveTabBorder = tabBorderWithBottom("┴", "─", "┴") 18 | ActiveTabBorder = tabBorderWithBottom("┘", " ", "└") 19 | InactiveTabStyle = lipgloss.NewStyle().Border(InactiveTabBorder, true).Padding(0, 1) 20 | ActiveTabStyle = InactiveTabStyle.Border(ActiveTabBorder, true) 21 | WindowSize tea.WindowSizeMsg 22 | Border = lipgloss.Border(lipgloss.RoundedBorder()) 23 | HighlightColor = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} 24 | InactiveStyle = lipgloss.NewStyle() 25 | ActiveStyle = InactiveStyle.BorderForeground(HighlightColor) 26 | ) 27 | -------------------------------------------------------------------------------- /types/helm.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Pkg struct { 4 | Name string `json:"name"` 5 | Version string `json:"version"` 6 | AppVersion string `json:"app_version"` 7 | Description string `json:"description"` 8 | } 9 | 10 | type Release struct { 11 | Name string `json:"name"` 12 | Namespace string `json:"namespace"` 13 | Revision string `json:"revision"` 14 | Updated string `json:"updated"` 15 | Status string `json:"status"` 16 | Chart string `json:"chart"` 17 | AppVersion string `json:"app_version"` 18 | } 19 | 20 | type History struct { 21 | Revision int `json:"revision"` 22 | Updated string `json:"updated"` 23 | Status string `json:"status"` 24 | Chart string `json:"chart"` 25 | AppVersion string `json:"app_version"` 26 | Description string `json:"description"` 27 | } 28 | 29 | type Repository struct { 30 | Name string `json:"name"` 31 | URL string `json:"url"` 32 | } 33 | 34 | type Plugin struct { 35 | Name string `json:"name"` 36 | Version string `json:"version"` 37 | Description string `json:"description"` 38 | } 39 | -------------------------------------------------------------------------------- /types/messages.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/charmbracelet/bubbles/table" 4 | 5 | type InitAppMsg struct { 6 | Err error 7 | } 8 | 9 | type DeleteMsg struct { 10 | Err error 11 | } 12 | 13 | type ListReleasesMsg struct { 14 | Content []table.Row 15 | Err error 16 | } 17 | type HistoryMsg struct { 18 | Content []table.Row 19 | Err error 20 | } 21 | 22 | type RollbackMsg struct { 23 | Err error 24 | } 25 | 26 | type UpgradeMsg struct { 27 | Err error 28 | } 29 | 30 | type NotesMsg struct { 31 | Err error 32 | Content string 33 | } 34 | 35 | type MetadataMsg struct { 36 | Err error 37 | Content string 38 | } 39 | 40 | type HooksMsg struct { 41 | Err error 42 | Content string 43 | } 44 | 45 | type ValuesMsg struct { 46 | Err error 47 | Content string 48 | } 49 | 50 | type ManifestMsg struct { 51 | Err error 52 | Content string 53 | } 54 | 55 | type RemoveMsg struct { 56 | Err error 57 | } 58 | 59 | type ListRepoMsg struct { 60 | Content []table.Row 61 | Err error 62 | } 63 | 64 | type PackagesMsg struct { 65 | Content []table.Row 66 | Err error 67 | } 68 | 69 | type PackageVersionsMsg struct { 70 | Content []table.Row 71 | Err error 72 | } 73 | 74 | type InstallMsg struct { 75 | Err error 76 | } 77 | 78 | type EditorFinishedMsg struct { 79 | Err error 80 | } 81 | 82 | type AddRepoMsg struct { 83 | Err error 84 | } 85 | 86 | type UpdateRepoMsg struct { 87 | Err error 88 | } 89 | 90 | type DebounceEndMsg struct { 91 | Tag int 92 | } 93 | 94 | type HubSearchResultMsg struct { 95 | Content []table.Row 96 | Err error 97 | } 98 | 99 | type HubSearchDefaultValueMsg struct { 100 | Content string 101 | Err error 102 | } 103 | 104 | type DefaultValueMsg struct { 105 | Content string 106 | Err error 107 | } 108 | 109 | type PluginsListMsg struct { 110 | Content []table.Row 111 | Err error 112 | } 113 | 114 | type PluginInstallMsg struct { 115 | Err error 116 | } 117 | 118 | type PluginUpdateMsg struct { 119 | Err error 120 | } 121 | 122 | type PluginUninstallMsg struct { 123 | Err error 124 | } 125 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/textinput" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/pidanou/helm-tui/helpers" 12 | "github.com/pidanou/helm-tui/hub" 13 | "github.com/pidanou/helm-tui/plugins" 14 | "github.com/pidanou/helm-tui/releases" 15 | "github.com/pidanou/helm-tui/repositories" 16 | "github.com/pidanou/helm-tui/styles" 17 | "github.com/pidanou/helm-tui/types" 18 | ) 19 | 20 | type tabIndex uint 21 | 22 | var tabLabels = []string{"Releases", "Repositories", "Hub", "Plugins"} 23 | 24 | const ( 25 | releasesTab tabIndex = iota 26 | repositoriesTab 27 | hubTab 28 | pluginsTab 29 | ) 30 | 31 | type mainModel struct { 32 | state tabIndex 33 | index int 34 | width int 35 | height int 36 | tabs []string 37 | tabContent []tea.Model 38 | loaded bool 39 | } 40 | 41 | func newModel(tabs []string) mainModel { 42 | m := mainModel{state: releasesTab, tabs: tabs, tabContent: make([]tea.Model, len(tabs)), loaded: false} 43 | m.tabContent[releasesTab], _ = releases.InitModel() 44 | m.tabContent[repositoriesTab], _ = repositories.InitModel() 45 | m.tabContent[hubTab] = hub.InitModel() 46 | m.tabContent[pluginsTab] = plugins.InitModel() 47 | return m 48 | } 49 | 50 | func (m mainModel) Init() tea.Cmd { 51 | var cmds = []tea.Cmd{createWorkingDir, textinput.Blink} 52 | for _, i := range m.tabContent { 53 | cmds = append(cmds, i.Init()) 54 | } 55 | return tea.Batch(cmds...) 56 | } 57 | 58 | func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 59 | var cmd tea.Cmd 60 | var cmds []tea.Cmd 61 | switch msg := msg.(type) { 62 | case types.InitAppMsg: 63 | if msg.Err != nil { 64 | return m, tea.Quit 65 | } 66 | m.loaded = true 67 | case types.EditorFinishedMsg: 68 | switch m.state { 69 | case releasesTab: 70 | m.tabContent[releasesTab], cmd = m.tabContent[releasesTab].Update(msg) 71 | cmds = append(cmds, cmd) 72 | return m, tea.Batch(cmds...) 73 | case repositoriesTab: 74 | m.tabContent[repositoriesTab], cmd = m.tabContent[repositoriesTab].Update(msg) 75 | cmds = append(cmds, cmd) 76 | return m, tea.Batch(cmds...) 77 | } 78 | case tea.WindowSizeMsg: 79 | m.width = msg.Width 80 | m.height = msg.Height 81 | m.tabContent[releasesTab], cmd = m.tabContent[releasesTab].Update(tea.WindowSizeMsg{Width: m.width, Height: msg.Height - lipgloss.Height(m.renderMenu())}) 82 | m.tabContent[repositoriesTab], cmd = m.tabContent[repositoriesTab].Update(tea.WindowSizeMsg{Width: m.width, Height: msg.Height - lipgloss.Height(m.renderMenu())}) 83 | m.tabContent[hubTab], cmd = m.tabContent[hubTab].Update(tea.WindowSizeMsg{Width: m.width, Height: msg.Height - lipgloss.Height(m.renderMenu())}) 84 | m.tabContent[pluginsTab], cmd = m.tabContent[pluginsTab].Update(tea.WindowSizeMsg{Width: m.width, Height: msg.Height - lipgloss.Height(m.renderMenu())}) 85 | return m, tea.Batch(cmds...) 86 | case tea.KeyMsg: 87 | switch msg.String() { 88 | case "ctrl+c": 89 | return m, tea.Quit 90 | case "]": 91 | if m.state == pluginsTab { 92 | m.state = 0 93 | } else { 94 | m.state++ 95 | } 96 | case "[": 97 | if m.state == releasesTab { 98 | m.state = pluginsTab 99 | } else { 100 | m.state-- 101 | } 102 | } 103 | switch m.state { 104 | case releasesTab: 105 | m.tabContent[releasesTab], cmd = m.tabContent[releasesTab].Update(msg) 106 | cmds = append(cmds, cmd) 107 | return m, tea.Batch(cmds...) 108 | case repositoriesTab: 109 | m.tabContent[repositoriesTab], cmd = m.tabContent[repositoriesTab].Update(msg) 110 | cmds = append(cmds, cmd) 111 | return m, tea.Batch(cmds...) 112 | case hubTab: 113 | m.tabContent[hubTab], cmd = m.tabContent[hubTab].Update(msg) 114 | cmds = append(cmds, cmd) 115 | return m, tea.Batch(cmds...) 116 | case pluginsTab: 117 | m.tabContent[pluginsTab], cmd = m.tabContent[pluginsTab].Update(msg) 118 | cmds = append(cmds, cmd) 119 | return m, tea.Batch(cmds...) 120 | } 121 | } 122 | m.tabContent[releasesTab], cmd = m.tabContent[releasesTab].Update(msg) 123 | cmds = append(cmds, cmd) 124 | m.tabContent[repositoriesTab], cmd = m.tabContent[repositoriesTab].Update(msg) 125 | cmds = append(cmds, cmd) 126 | m.tabContent[hubTab], cmd = m.tabContent[hubTab].Update(msg) 127 | cmds = append(cmds, cmd) 128 | m.tabContent[pluginsTab], cmd = m.tabContent[pluginsTab].Update(msg) 129 | cmds = append(cmds, cmd) 130 | return m, tea.Batch(cmds...) 131 | } 132 | 133 | func (m mainModel) View() string { 134 | doc := strings.Builder{} 135 | if !m.loaded || len(m.tabContent) == 0 { 136 | return "loading..." 137 | } 138 | doc.WriteString(m.renderMenu()) 139 | doc.WriteString("\n") 140 | doc.WriteString(m.tabContent[m.state].View()) 141 | return doc.String() 142 | } 143 | 144 | func (m mainModel) renderMenu() string { 145 | doc := strings.Builder{} 146 | 147 | var renderedTabs []string 148 | 149 | for i, t := range m.tabs { 150 | var style lipgloss.Style 151 | isActive := i == int(m.state) 152 | if isActive { 153 | style = styles.ActiveStyle.Background(styles.HighlightColor).Padding(0, 1) 154 | } else { 155 | style = styles.InactiveStyle.Padding(0, 1) 156 | } 157 | renderedTabs = append(renderedTabs, style.Render(t)) 158 | } 159 | menu := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) 160 | doc.WriteString(menu) 161 | return doc.String() 162 | } 163 | 164 | func createWorkingDir() tea.Msg { 165 | homeDir, err := os.UserHomeDir() 166 | if err != nil { 167 | return types.InitAppMsg{Err: err} 168 | } 169 | workingDir := path.Join(homeDir, ".helm-tui") 170 | err = os.MkdirAll(workingDir, 0755) 171 | if err != nil { 172 | return types.InitAppMsg{Err: err} 173 | } 174 | helpers.UserDir = workingDir 175 | return types.InitAppMsg{Err: nil} 176 | } 177 | --------------------------------------------------------------------------------