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

View file

@ -5,6 +5,7 @@ import (
"git.juancwu.dev/juancwu/porkbacon/internal/pass"
"git.juancwu.dev/juancwu/porkbacon/internal/porkbun"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
@ -13,7 +14,9 @@ import (
const (
StateInit uint = iota
StateLoadingCredentials
StateIdle
StateMenu
StateExecuting
StateResult
)
type CredentialsLoadedMsg struct {
@ -21,8 +24,20 @@ type CredentialsLoadedMsg struct {
SecretKey string
}
type PingResultMsg struct {
IP string
}
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 {
state uint
@ -32,8 +47,10 @@ type App struct {
textInput textinput.Model
spinner spinner.Model
list list.Model
err error
result string
err error
}
func New() App {
@ -45,10 +62,24 @@ func New() App {
s := spinner.New()
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{
state: StateInit,
textInput: ti,
spinner: s,
list: l,
}
}
@ -65,20 +96,38 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return a, tea.Quit
case "esc":
if a.state == StateResult {
a.state = StateMenu
a.result = ""
a.err = nil
return a, nil
}
case "enter":
if a.state == StateInit {
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:
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
case ErrMsg:
a.err = msg
a.state = StateIdle // Or stay in error state
a.state = StateResult // Show error in result view
return a, nil
}
@ -87,17 +136,26 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
if a.state == StateLoadingCredentials {
if a.state == StateLoadingCredentials || a.state == StateExecuting {
a.spinner, cmd = a.spinner.Update(msg)
cmds = append(cmds, cmd)
}
if a.state == StateMenu {
a.list, cmd = a.list.Update(msg)
cmds = append(cmds, cmd)
}
return a, tea.Sequence(cmds...)
}
func (a *App) View() string {
if a.err != nil {
return fmt.Sprintf("Error: %v\n\nPress Ctrl+C to quit.", a.err)
if a.state == StateResult {
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 {
@ -105,20 +163,14 @@ func (a *App) View() string {
return a.renderPorkbunCredentialForm()
case StateLoadingCredentials:
return fmt.Sprintf("%s Loading credentials...", a.spinner.View())
case StateIdle:
return fmt.Sprintf("Ready!\nAPI Key: %s...\nSecret: %s...\n",
mask(a.client.APIKey), mask(a.client.SecretAPIKey))
case StateMenu:
return a.list.View()
case StateExecuting:
return fmt.Sprintf("%s Executing...", a.spinner.View())
}
return "Invalid state"
}
func mask(s string) string {
if len(s) > 4 {
return s[:4] + "...."
}
return "...."
}
func (a *App) renderPorkbunCredentialForm() string {
title := "Enter Porkbun API Key Name (pass entry):"
if a.apiKeyName != "" {
@ -151,6 +203,30 @@ func (a *App) handleEnter() (tea.Model, tea.Cmd) {
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 {
return func() tea.Msg {
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"`
}