From 4a3b2beb5b46f1ef6c23dbb3ff0a3f5a2bfe1d47 Mon Sep 17 00:00:00 2001 From: stawan15 Date: Sun, 19 Jul 2026 13:25:23 +0700 Subject: [PATCH] feat: replace action list panel with a centered keyboard-driven menu overlay --- kamal.go | 91 +++++++++++++-------------- main.go | 184 ++++++++++++++++++++++++++---------------------------- styles.go | 61 +++++++++++------- 3 files changed, 174 insertions(+), 162 deletions(-) diff --git a/kamal.go b/kamal.go index c5cd73b..3a6d8d1 100644 --- a/kamal.go +++ b/kamal.go @@ -16,6 +16,7 @@ import ( // actionItem describes one action available in the main menu. type actionItem struct { + key string title string desc string needsVersion bool // rollback needs a version/commit hash typed in @@ -29,45 +30,51 @@ func (a actionItem) FilterValue() string { return a.title } func actions() []actionItem { return []actionItem{ { - title: "󰚰 Deploy", + key: "d", + title: "Deploy", desc: "kamal deploy -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"deploy"}, dest) }, }, { - title: "󰒓 Setup", - desc: "kamal setup -d (provision servers & deploy)", + key: "R", + title: "Redeploy", + desc: "kamal redeploy -d ", buildArgs: func(dest, _ string) []string { - return withDest([]string{"setup"}, dest) + return withDest([]string{"redeploy"}, dest) }, }, { - title: "󰈙 Env Push", - desc: "kamal env push -d (push .env variables to servers)", - buildArgs: func(dest, _ string) []string { - return withDest([]string{"env", "push"}, dest) + key: "r", + title: "Rollback", + desc: "kamal rollback -d ", + needsVersion: true, + buildArgs: func(dest, version string) []string { + args := []string{"rollback", version} + return withDest(args, dest) }, }, { - title: "󰑙 Redeploy", - desc: "kamal redeploy -d (skip build cache invalidation steps)", + key: "e", + title: "Env Push", + desc: "kamal env push -d ", buildArgs: func(dest, _ string) []string { - return withDest([]string{"redeploy"}, dest) + return withDest([]string{"env", "push"}, dest) }, }, { - title: "󰁯 Rollback", - desc: "kamal rollback -d ", - needsVersion: true, - buildArgs: func(dest, version string) []string { - args := []string{"rollback", version} - return withDest(args, dest) + key: "l", + title: "App Logs", + desc: "kamal app logs -d ", + buildArgs: func(dest, _ string) []string { + return withDest([]string{"app", "logs"}, dest) }, }, { - title: "󰆼 DB Dump (Backup)", - desc: "kamal app exec -i -- /bin/sh -c 'pg_dump ...'", + key: "D", + title: "DB Dump", + desc: "kamal app exec -i -- pg_dump ...", buildArgs: func(dest, _ string) []string { args := []string{"app", "exec", "-i"} args = withDest(args, dest) @@ -76,8 +83,9 @@ func actions() []actionItem { }, }, { - title: "󰗨 DB Restore", - desc: "kamal app exec -i -- /bin/sh -c 'pg_restore ...'", + key: "S", + title: "DB Restore", + desc: "kamal app exec -i -- pg_restore ...", buildArgs: func(dest, _ string) []string { args := []string{"app", "exec", "-i"} args = withDest(args, dest) @@ -86,36 +94,17 @@ func actions() []actionItem { }, }, { - title: "󰋩 App Details", - desc: "kamal app details -d ", - buildArgs: func(dest, _ string) []string { - return withDest([]string{"app", "details"}, dest) - }, - }, - { - title: "󰅩 App Logs", - desc: "kamal app logs -d (last lines, no follow)", - buildArgs: func(dest, _ string) []string { - return withDest([]string{"app", "logs"}, dest) - }, - }, - { - title: "󰀵 App Boot", - desc: "kamal app boot -d ", - buildArgs: func(dest, _ string) []string { - return withDest([]string{"app", "boot"}, dest) - }, - }, - { - title: "󰚌 Audit", - desc: "kamal audit -d (recent deploy history)", + key: "a", + title: "Audit", + desc: "kamal audit -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"audit"}, dest) }, }, { - title: "󰆴 Remove", - desc: "kamal remove -d (remove containers and images from servers)", + key: "X", + title: "Remove", + desc: "kamal remove -d ", buildArgs: func(dest, _ string) []string { return withDest([]string{"remove"}, dest) }, @@ -123,6 +112,16 @@ func actions() []actionItem { } } +// actionByKey finds an action by its shortcut key. +func actionByKey(key string) (actionItem, bool) { + for _, a := range actions() { + if a.key == key { + return a, true + } + } + return actionItem{}, false +} + // withDest appends "-d " unless dest is the empty/default destination. func withDest(args []string, dest string) []string { if dest != "" { diff --git a/main.go b/main.go index 2d85dbf..bbde009 100644 --- a/main.go +++ b/main.go @@ -20,7 +20,6 @@ type panel int const ( panelDestinations panel = iota - panelActions panelLogs ) @@ -59,11 +58,10 @@ type model struct { activePanel panel - actionList list.Model - destList list.Model - verInput textinput.Model - viewport viewport.Model - spinner spinner.Model + destList list.Model + verInput textinput.Model + viewport viewport.Model + spinner spinner.Model selectedAction actionItem selectedDest string @@ -81,6 +79,9 @@ type model struct { showVersionInput bool versionAction actionItem + // Menu overlay + showMenu bool + // Secrets Manager State showSecrets bool addingSecret bool @@ -133,17 +134,6 @@ func detectGitBranch() string { } func initialModel() model { - items := make([]list.Item, 0, len(actions())) - for _, a := range actions() { - items = append(items, a) - } - al := list.New(items, list.NewDefaultDelegate(), 0, 0) - al.Title = "Menu" - al.SetShowStatusBar(false) - al.SetFilteringEnabled(false) - al.SetShowHelp(false) - al.Styles.Title = titleStyle - dests := discoverDestinations() ditems := make([]list.Item, 0, len(dests)) for _, d := range dests { @@ -186,12 +176,11 @@ func initialModel() model { return model{ activePanel: panelDestinations, - actionList: al, destList: dl, verInput: ti, viewport: vp, spinner: sp, - outputBuf: []string{"Welcome to kamal-tui! Select a destination and action.", "Press 's' to manage secrets."}, + outputBuf: []string{"Welcome to kamal-tui! Select a destination and press x for menu.", "Press 's' to manage secrets."}, secList: secList, secKeyIn: secKeyIn, secValIn: secValIn, @@ -234,11 +223,8 @@ func (m *model) layout() { } rightW := m.width - leftW - destH := bodyH / 2 - actionH := bodyH - destH - - m.destList.SetSize(leftW-4, destH-2) - m.actionList.SetSize(leftW-4, actionH-2) + // Destinations fills the full left column height + m.destList.SetSize(leftW-4, bodyH-2) m.viewport.Width = rightW - 4 m.viewport.Height = bodyH - 2 @@ -255,16 +241,8 @@ func (m *model) refreshSecrets() { m.secList.SetItems(items) } -func (m model) handleShortcutAction(titleSubstr string) (tea.Model, tea.Cmd) { - var action actionItem - found := false - for _, a := range actions() { - if strings.Contains(a.title, titleSubstr) { - action = a - found = true - break - } - } +func (m model) handleActionByKey(key string) (tea.Model, tea.Cmd) { + action, found := actionByKey(key) if !found { return m, nil } @@ -304,7 +282,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.MouseMsg: - if m.showSecrets || m.addingSecret || m.showVersionInput || m.showConfirm { + if m.showSecrets || m.addingSecret || m.showVersionInput || m.showConfirm || m.showMenu { return m, nil } leftW := 30 @@ -312,18 +290,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { leftW = m.width / 3 } if msg.X < leftW { - destH := (m.height - 1) / 2 - if msg.Y < destH { - m.activePanel = panelDestinations - var cmd tea.Cmd - m.destList, cmd = m.destList.Update(msg) - cmds = append(cmds, cmd) - } else { - m.activePanel = panelActions - var cmd tea.Cmd - m.actionList, cmd = m.actionList.Update(msg) - cmds = append(cmds, cmd) - } + m.activePanel = panelDestinations + var cmd tea.Cmd + m.destList, cmd = m.destList.Update(msg) + cmds = append(cmds, cmd) } else { m.activePanel = panelLogs var cmd tea.Cmd @@ -339,13 +309,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Quit case "q": - if !m.showVersionInput && !m.running && !m.showSecrets && !m.addingSecret && !m.showConfirm { + if !m.showVersionInput && !m.running && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu { if m.cancel != nil { m.cancel() } return m, tea.Quit } case "esc": + if m.showMenu { + m.showMenu = false + return m, nil + } if m.showConfirm { m.showConfirm = false return m, nil @@ -369,13 +343,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break // use ctrl+c to abort } case "tab": - if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { - m.activePanel = (m.activePanel + 1) % 3 + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu { + m.activePanel = (m.activePanel + 1) % 2 return m, nil } case "shift+tab": - if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { - m.activePanel = (m.activePanel - 1 + 3) % 3 + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu { + m.activePanel = (m.activePanel - 1 + 2) % 2 return m, nil } } @@ -454,6 +428,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) } + if m.showVersionInput { switch msg.String() { case "enter": @@ -476,39 +451,43 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } + // Menu overlay: handle action key presses + if m.showMenu { + key := msg.String() + if _, found := actionByKey(key); found { + m.showMenu = false + return m.handleActionByKey(key) + } + // Unknown key — close menu + m.showMenu = false + return m, nil + } + + // Normal mode shortcuts if !m.running { switch msg.String() { - case "d": - return m.handleShortcutAction("Deploy") - case "r": - return m.handleShortcutAction("Rollback") - case "l": - return m.handleShortcutAction("App Logs") + case "x": + m.showMenu = true + return m, nil case "s": m.showSecrets = true m.refreshSecrets() return m, nil + // Direct shortcuts (without opening menu) + case "d": + return m.handleActionByKey("d") } } // Panel specific updates - if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm { + if !m.showVersionInput && !m.showSecrets && !m.addingSecret && !m.showConfirm && !m.showMenu { switch m.activePanel { case panelDestinations: var cmd tea.Cmd m.destList, cmd = m.destList.Update(msg) cmds = append(cmds, cmd) if msg.String() == "enter" { - m.activePanel = panelActions - } - case panelActions: - var cmd tea.Cmd - m.actionList, cmd = m.actionList.Update(msg) - cmds = append(cmds, cmd) - if msg.String() == "enter" { - if it, ok := m.actionList.SelectedItem().(actionItem); ok { - return m.handleShortcutAction(it.title) - } + m.activePanel = panelLogs } case panelLogs: var cmd tea.Cmd @@ -594,12 +573,37 @@ func (m model) headerView() string { ) } +// menuView renders the LazyGit-style centered menu overlay. +func (m model) menuView() string { + items := actions() + var rows []string + + for _, a := range items { + key := menuKeyStyle.Render(fmt.Sprintf("%-3s", a.key)) + sep := menuSepStyle.Render(" ") + desc := menuDescStyle.Render(a.title) + rows = append(rows, key+sep+desc) + } + rows = append(rows, "") // blank separator + rows = append(rows, menuKeyStyle.Render("esc")+" "+menuDescStyle.Render("close")) + + inner := lipgloss.JoinVertical(lipgloss.Left, rows...) + box := menuBoxStyle.Render( + lipgloss.JoinVertical(lipgloss.Left, + titleStyle.Render("Menu"), + "", + inner, + ), + ) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) +} + func (m model) View() string { if m.width == 0 { return "loading…" } - // Render overlay if needed + // Menu overlay (highest priority after add-secret) if m.addingSecret { content := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render("Add New Secret"), @@ -633,6 +637,7 @@ func (m model) View() string { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, activePanelStyle.Width(m.width-10).Render(content)) } + // ── Normal layout ───────────────────────────────────────────────────── leftW := 30 if m.width < 80 { leftW = m.width / 3 @@ -643,26 +648,14 @@ func (m model) View() string { footerH := 1 bodyH := m.height - headerH - footerH - destH := bodyH / 2 - actionH := bodyH - destH - - var destPanel, actionPanel, logPanel string - - // Render Destinations + // Render Destinations (full left column height) style := inactivePanelStyle if m.activePanel == panelDestinations { style = activePanelStyle } - destPanel = style.Width(leftW - 2).Height(destH - 2).Render(m.destList.View()) + destPanel := style.Width(leftW - 2).Height(bodyH - 2).Render(m.destList.View()) - // Render Menu (Actions) - style = inactivePanelStyle - if m.activePanel == panelActions { - style = activePanelStyle - } - actionPanel = style.Width(leftW - 2).Height(actionH - 2).Render(m.actionList.View()) - - // Render Logs panel with dynamic title + // Render Logs panel style = inactivePanelStyle if m.activePanel == panelLogs { style = activePanelStyle @@ -671,7 +664,6 @@ func (m model) View() string { // Build log panel title: "{ActionName} logs" or just "logs" logTitle := "logs" if m.selectedAction.title != "" { - // Strip emoji from title for cleanliness clean := strings.TrimSpace(m.selectedAction.title) logTitle = clean + " logs" } @@ -687,14 +679,18 @@ func (m model) View() string { logContent = lipgloss.Place(rightW-4, bodyH-4, lipgloss.Center, lipgloss.Center, activePanelStyle.Render(overlay)) } - // Compose log panel: title on top, viewport below, inside the border style logInner := lipgloss.JoinVertical(lipgloss.Left, logPanelTitle, logContent) - logPanel = style.Width(rightW - 2).Height(bodyH - 2).Render(logInner) + logPanel := style.Width(rightW - 2).Height(bodyH - 2).Render(logInner) - leftCol := lipgloss.JoinVertical(lipgloss.Left, destPanel, actionPanel) - mainView := lipgloss.JoinHorizontal(lipgloss.Top, leftCol, logPanel) + mainView := lipgloss.JoinHorizontal(lipgloss.Top, destPanel, logPanel) + base := lipgloss.JoinVertical(lipgloss.Left, m.headerView(), mainView, m.footerView()) + + // Render menu overlay on top of base layout + if m.showMenu { + return m.menuView() + } - return lipgloss.JoinVertical(lipgloss.Left, m.headerView(), mainView, m.footerView()) + return base } func destLabel(d string) string { @@ -709,13 +705,13 @@ func (m model) footerView() string { actionHint := "" if m.running { - actionHint = m.spinner.View() + " running... " + actionHint = m.spinner.View() + " running... " } if m.statusLine != "" { - actionHint += m.statusLine + " · " + actionHint += m.statusLine + " " } - left = actionHint + "d:deploy r:rollback l:logs s:secrets tab:switch panel q:quit" + left = actionHint + "d:deploy x:menu s:secrets tab:panel q:quit" return statusBarStyle.Width(m.width).Render(left) } diff --git a/styles.go b/styles.go index 506d8ea..b6093a1 100644 --- a/styles.go +++ b/styles.go @@ -4,16 +4,16 @@ import "github.com/charmbracelet/lipgloss" var ( // Tokyo Night-ish / Modern theme - colorBg = lipgloss.Color("#1a1b26") - colorFg = lipgloss.Color("#c0caf5") - colorAccent = lipgloss.Color("#7aa2f7") // Blue - colorActive = lipgloss.Color("#bb9af7") // Purple - colorBorder = lipgloss.Color("#414868") // Dark Gray - colorMuted = lipgloss.Color("#565f89") - colorGood = lipgloss.Color("#9ece6a") // Green - colorBad = lipgloss.Color("#f7768e") // Red - colorWarning = lipgloss.Color("#e0af68") // Yellow - colorHeaderBg = lipgloss.Color("#16161e") // Slightly darker for header strip + colorBg = lipgloss.Color("#1a1b26") + colorFg = lipgloss.Color("#c0caf5") + colorAccent = lipgloss.Color("#7aa2f7") // Blue + colorActive = lipgloss.Color("#bb9af7") // Purple + colorBorder = lipgloss.Color("#414868") // Dark Gray + colorMuted = lipgloss.Color("#565f89") + colorGood = lipgloss.Color("#9ece6a") // Green + colorBad = lipgloss.Color("#f7768e") // Red + colorWarning = lipgloss.Color("#e0af68") // Yellow + colorHeaderBg = lipgloss.Color("#16161e") // Slightly darker for header strip titleStyle = lipgloss.NewStyle(). Bold(true). @@ -30,14 +30,14 @@ var ( Padding(0, 1) inactivePanelStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(colorBorder). - Padding(0, 1) + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(0, 1) activePanelStyle = lipgloss.NewStyle(). - Border(lipgloss.ThickBorder()). // Thick border for active panel - BorderForeground(colorActive). - Padding(0, 1) + Border(lipgloss.ThickBorder()). // Thick border for active panel + BorderForeground(colorActive). + Padding(0, 1) helpStyle = lipgloss.NewStyle(). Foreground(colorMuted). @@ -50,14 +50,31 @@ var ( // Header bar: project :: branch shown top-right headerBranchStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(colorAccent). - Background(colorHeaderBg). - Padding(0, 1) + Bold(true). + Foreground(colorAccent). + Background(colorHeaderBg). + Padding(0, 1) // Log panel inner title: "{Action} logs" logPanelTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorMuted). + Padding(0, 0, 0, 1) + + // Menu overlay styles (LazyGit-style) + menuBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(colorAccent). + Background(colorBg). + Padding(0, 2) + + menuKeyStyle = lipgloss.NewStyle(). Bold(true). - Foreground(colorMuted). - Padding(0, 0, 0, 1) + Foreground(colorAccent) // Blue accent for key column + + menuDescStyle = lipgloss.NewStyle(). + Foreground(colorFg) // White fg for description column + + menuSepStyle = lipgloss.NewStyle(). + Foreground(colorBorder) // Muted separator between key and desc )