add action items menu

This commit is contained in:
juancwu 2026-01-21 21:27:08 +00:00
commit 4266bfbfc2
5 changed files with 172 additions and 20 deletions

2
go.mod
View file

@ -5,7 +5,6 @@ go 1.25.6
require ( require (
github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/joho/godotenv v1.5.1
) )
require ( require (
@ -25,6 +24,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/text v0.3.8 // indirect

10
go.sum
View file

@ -2,6 +2,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@ -14,12 +16,14 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@ -37,6 +41,8 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=

View file

@ -5,6 +5,7 @@ import (
"git.juancwu.dev/juancwu/porkbacon/internal/pass" "git.juancwu.dev/juancwu/porkbacon/internal/pass"
"git.juancwu.dev/juancwu/porkbacon/internal/porkbun" "git.juancwu.dev/juancwu/porkbacon/internal/porkbun"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -13,7 +14,9 @@ import (
const ( const (
StateInit uint = iota StateInit uint = iota
StateLoadingCredentials StateLoadingCredentials
StateIdle StateMenu
StateExecuting
StateResult
) )
type CredentialsLoadedMsg struct { type CredentialsLoadedMsg struct {
@ -21,8 +24,20 @@ type CredentialsLoadedMsg struct {
SecretKey string SecretKey string
} }
type PingResultMsg struct {
IP string
}
type ErrMsg error type ErrMsg error
type item struct {
title, desc string
}
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }
type App struct { type App struct {
state uint state uint
@ -32,8 +47,10 @@ type App struct {
textInput textinput.Model textInput textinput.Model
spinner spinner.Model spinner spinner.Model
list list.Model
err error result string
err error
} }
func New() App { func New() App {
@ -45,10 +62,24 @@ func New() App {
s := spinner.New() s := spinner.New()
s.Spinner = spinner.Dot s.Spinner = spinner.Dot
items := []list.Item{
item{title: "Domain: List All", desc: "List all domains in your account"},
item{title: "Domain: Get Details", desc: "Get details for a specific domain"},
item{title: "DNS: Retrieve Records", desc: "Retrieve DNS records for a domain"},
item{title: "DNS: Create Record", desc: "Create a new DNS record"},
item{title: "DNS: Edit Record", desc: "Edit an existing DNS record"},
item{title: "DNS: Delete Record", desc: "Delete a DNS record"},
item{title: "Util: Ping", desc: "Ping Porkbun"},
}
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "Porkbun Actions"
return App{ return App{
state: StateInit, state: StateInit,
textInput: ti, textInput: ti,
spinner: s, spinner: s,
list: l,
} }
} }
@ -65,20 +96,38 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
return a, tea.Quit return a, tea.Quit
case "esc":
if a.state == StateResult {
a.state = StateMenu
a.result = ""
a.err = nil
return a, nil
}
case "enter": case "enter":
if a.state == StateInit { if a.state == StateInit {
return a.handleEnter() return a.handleEnter()
} else if a.state == StateMenu {
return a.handleMenuEnter()
} }
} }
case tea.WindowSizeMsg:
a.list.SetWidth(msg.Width)
a.list.SetHeight(msg.Height)
case CredentialsLoadedMsg: case CredentialsLoadedMsg:
a.client = porkbun.New(msg.APIKey, msg.SecretKey) a.client = porkbun.New(msg.APIKey, msg.SecretKey)
a.state = StateIdle a.state = StateMenu
return a, nil
case PingResultMsg:
a.result = fmt.Sprintf("Ping successful!\nYour IP: %s", msg.IP)
a.state = StateResult
return a, nil return a, nil
case ErrMsg: case ErrMsg:
a.err = msg a.err = msg
a.state = StateIdle // Or stay in error state a.state = StateResult // Show error in result view
return a, nil return a, nil
} }
@ -87,17 +136,26 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} }
if a.state == StateLoadingCredentials { if a.state == StateLoadingCredentials || a.state == StateExecuting {
a.spinner, cmd = a.spinner.Update(msg) a.spinner, cmd = a.spinner.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} }
if a.state == StateMenu {
a.list, cmd = a.list.Update(msg)
cmds = append(cmds, cmd)
}
return a, tea.Sequence(cmds...) return a, tea.Sequence(cmds...)
} }
func (a *App) View() string { func (a *App) View() string {
if a.err != nil { if a.state == StateResult {
return fmt.Sprintf("Error: %v\n\nPress Ctrl+C to quit.", a.err) content := a.result
if a.err != nil {
content = fmt.Sprintf("Error: %v", a.err)
}
return fmt.Sprintf("%s\n\n(Press Esc to go back)", content)
} }
switch a.state { switch a.state {
@ -105,20 +163,14 @@ func (a *App) View() string {
return a.renderPorkbunCredentialForm() return a.renderPorkbunCredentialForm()
case StateLoadingCredentials: case StateLoadingCredentials:
return fmt.Sprintf("%s Loading credentials...", a.spinner.View()) return fmt.Sprintf("%s Loading credentials...", a.spinner.View())
case StateIdle: case StateMenu:
return fmt.Sprintf("Ready!\nAPI Key: %s...\nSecret: %s...\n", return a.list.View()
mask(a.client.APIKey), mask(a.client.SecretAPIKey)) case StateExecuting:
return fmt.Sprintf("%s Executing...", a.spinner.View())
} }
return "Invalid state" return "Invalid state"
} }
func mask(s string) string {
if len(s) > 4 {
return s[:4] + "...."
}
return "...."
}
func (a *App) renderPorkbunCredentialForm() string { func (a *App) renderPorkbunCredentialForm() string {
title := "Enter Porkbun API Key Name (pass entry):" title := "Enter Porkbun API Key Name (pass entry):"
if a.apiKeyName != "" { if a.apiKeyName != "" {
@ -151,6 +203,30 @@ func (a *App) handleEnter() (tea.Model, tea.Cmd) {
return a, nil return a, nil
} }
func (a *App) handleMenuEnter() (tea.Model, tea.Cmd) {
i, ok := a.list.SelectedItem().(item)
if !ok {
return a, nil
}
switch i.title {
case "Util: Ping":
a.state = StateExecuting
return a, a.performPing()
}
return a, nil
}
func (a *App) performPing() tea.Cmd {
return func() tea.Msg {
resp, err := a.client.Ping()
if err != nil {
return ErrMsg(err)
}
return PingResultMsg{IP: resp.YourIP}
}
}
func retrievePorkbunCredentialsCmd(apiKeyName, secretKeyName string) tea.Cmd { func retrievePorkbunCredentialsCmd(apiKeyName, secretKeyName string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
apiKey, err := pass.Get(apiKeyName) apiKey, err := pass.Get(apiKeyName)

View file

@ -25,3 +25,49 @@ func New(apiKey, secretKey string) *Client {
}, },
} }
} }
func (c *Client) Ping() (*PingResponse, error) {
reqBody := PingRequest{
BaseRequest: BaseRequest{
APIKey: c.APIKey,
SecretAPIKey: c.SecretAPIKey,
},
}
var resp PingResponse
err := c.post("/ping", reqBody, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
func (c *Client) post(endpoint string, body interface{}, target interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequest("POST", BaseURL+endpoint, bytes.NewBuffer(jsonBody))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := c.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return fmt.Errorf("API returned error status: %d", res.StatusCode)
}
if err := json.NewDecoder(res.Body).Decode(target); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
return nil
}

View file

@ -0,0 +1,24 @@
package porkbun
// BaseRequest contains the authentication fields required for most Porkbun API calls.
type BaseRequest struct {
APIKey string `json:"apikey"`
SecretAPIKey string `json:"secretapikey"`
}
// BaseResponse contains the common fields returned by the Porkbun API.
type BaseResponse struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
// PingRequest is used to verify credentials.
type PingRequest struct {
BaseRequest
}
// PingResponse is the response from the ping endpoint.
type PingResponse struct {
BaseResponse
YourIP string `json:"yourIp"`
}