Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type Keybindings struct {
JSONView string `yaml:"json_view"`
SwitchNamespace string `yaml:"switch_namespace"`
SwitchContext string `yaml:"switch_context"`
CopyData string `yaml:"copy_data"`
MultiSelect string `yaml:"multi_select"`
Enter string `yaml:"enter"`
Escape string `yaml:"escape"`
Expand Down
3 changes: 2 additions & 1 deletion internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ func DefaultConfig() *Config {
YAMLView: "Y",
JSONView: "J",
SwitchNamespace: "n",
SwitchContext: "c",
SwitchContext: "C",
CopyData: "c",
MultiSelect: " ",
Enter: "enter",
Escape: "esc",
Expand Down
95 changes: 92 additions & 3 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
ViewInput
ViewHelp
ViewSearchTab // Search tab command input mode
ViewCopy // Copy column data dialog
)

// SearchTabIndex is the index of the special Search tab (always first)
Expand Down Expand Up @@ -148,9 +149,10 @@
list *list.Model
search *search.Model
detail *detail.Model
confirm *dialog.ConfirmModel
selector *dialog.SelectorModel
input *dialog.InputModel
confirm *dialog.ConfirmModel
selector *dialog.SelectorModel
input *dialog.InputModel

Check failure on line 154 in internal/tui/app.go

View workflow job for this annotation

GitHub Actions / Lint

field `input` is unused (unused)
copySelector *dialog.MultiSelectorModel

// Pending action state
pendingAction string
Expand All @@ -162,7 +164,7 @@
currentContext string
currentNamespace string
resources *kubectl.TableData
selectedIndex int

Check failure on line 167 in internal/tui/app.go

View workflow job for this annotation

GitHub Actions / Lint

field `selectedIndex` is unused (unused)

// Search tab state
searchInput *dialog.InputModel
Expand Down Expand Up @@ -326,6 +328,22 @@
return m, nil
}

// Handle copy-data dialog state
if m.viewState == ViewCopy && m.copySelector != nil {
m.copySelector, _ = m.copySelector.Update(msg)
switch m.copySelector.Result() {
case dialog.MultiSelectorConfirmed:
m.viewState = ViewList
values := m.copySelector.SelectedValues()
if len(values) > 0 {
return m, m.copyToClipboard(strings.Join(values, " "))
}
case dialog.MultiSelectorCancelled:
m.viewState = ViewList
}
return m, nil
}

// Handle detail view state
if m.viewState == ViewDetail {
// When the detail search input is active, forward messages to it
Expand Down Expand Up @@ -633,6 +651,10 @@
// Rollout restart
return m, m.startLoading(m.rolloutRestart())
case "c":
// Open copy-data column selector
m.openCopySelector()
return m, nil
case "C":
// Switch context
return m, m.startLoading(m.showContextSelector())
case "n":
Expand Down Expand Up @@ -787,6 +809,11 @@
return m.searchInput.View()
}

// Copy data dialog is a full-screen overlay
if m.viewState == ViewCopy && m.copySelector != nil {
return m.copySelector.View()
}

var b strings.Builder

// Header
Expand Down Expand Up @@ -946,7 +973,7 @@
func (m *Model) loadResourceDetail(format detail.Format) tea.Cmd {
return func() tea.Msg {
selected := m.list.SelectedItem()
if selected == nil || len(selected) == 0 {

Check failure on line 976 in internal/tui/app.go

View workflow job for this annotation

GitHub Actions / Lint

S1009: should omit nil check; len() for nil slices is defined as zero (gosimple)
return ErrorMsg{Err: nil}
}

Expand Down Expand Up @@ -1338,6 +1365,68 @@

// openSortSelector builds sort options from the current tab's headers and opens
// the selector dialog.
func (m *Model) openCopySelector() {
row := m.list.SelectedItem()
headers := m.list.Headers()
if len(row) == 0 || len(headers) == 0 {
return
}

var items []dialog.MultiSelectorItem

// One item per column
for i, h := range headers {
if i < len(row) {
items = append(items, dialog.MultiSelectorItem{
Label: fmt.Sprintf("%-12s %s", h, row[i]),
Value: row[i],
})
}
}

// Special combined NAME --namespace NAMESPACE option
nameIdx, nsIdx := -1, -1
for i, h := range headers {
switch strings.ToUpper(h) {
case "NAME":
nameIdx = i
case "NAMESPACE":
nsIdx = i
}
}
if nameIdx >= 0 && nsIdx >= 0 && nameIdx < len(row) && nsIdx < len(row) {
combined := fmt.Sprintf("%s --namespace %s", row[nameIdx], row[nsIdx])
items = append(items, dialog.MultiSelectorItem{
Label: fmt.Sprintf("%-12s %s", "NAME+NS", combined),
Value: combined,
})
}

sel := dialog.NewMultiSelector("Copy column data", items)
sel.SetSize(m.width, m.height)
m.copySelector = sel
m.viewState = ViewCopy
}

func (m *Model) copyToClipboard(text string) tea.Cmd {
return func() tea.Msg {
// Try xclip first, then xsel, then pbcopy (macOS)
commands := [][]string{
{"xclip", "-selection", "clipboard"},
{"xsel", "--clipboard", "--input"},
{"pbcopy"},
}
for _, args := range commands {
cmd := exec.Command(args[0], args[1:]...) //nolint:gosec
cmd.Stdin = strings.NewReader(text)
if err := cmd.Run(); err == nil {
return nil
}
}
return nil
}
}

func (m *Model) openSortSelector() {
if m.resources == nil || len(m.resources.Headers) == 0 {
return
Expand Down
191 changes: 191 additions & 0 deletions internal/tui/components/dialog/multiselector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package dialog

import (
"fmt"
"strings"

"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)

// MultiSelectorResult represents the result of a multi-selector dialog
type MultiSelectorResult int

const (
MultiSelectorPending MultiSelectorResult = iota
MultiSelectorConfirmed
MultiSelectorCancelled
)

// MultiSelectorItem represents a selectable item with a label and a copyable value
type MultiSelectorItem struct {
Label string
Value string
}

// MultiSelectorModel is a floating dialog that allows toggling multiple items
// with [space] and confirming the selection with [enter].
type MultiSelectorModel struct {
title string
items []MultiSelectorItem
selected []bool
cursor int
result MultiSelectorResult
width int
height int
styles *SelectorStyles
}

// NewMultiSelector creates a new multi-select dialog.
func NewMultiSelector(title string, items []MultiSelectorItem) *MultiSelectorModel {
return &MultiSelectorModel{
title: title,
items: items,
selected: make([]bool, len(items)),
result: MultiSelectorPending,
styles: DefaultSelectorStyles(),
}
}

// SetSize sets the dialog dimensions.
func (m *MultiSelectorModel) SetSize(width, height int) {
m.width = width
m.height = height
}

// Result returns the current result state.
func (m *MultiSelectorModel) Result() MultiSelectorResult {
return m.result
}

// SelectedValues returns the copy values of all toggled items.
func (m *MultiSelectorModel) SelectedValues() []string {
var out []string
for i, sel := range m.selected {
if sel {
out = append(out, m.items[i].Value)
}
}
return out
}

// Update handles keyboard messages.
func (m *MultiSelectorModel) Update(msg tea.Msg) (*MultiSelectorModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("j", "down"))):
if m.cursor < len(m.items)-1 {
m.cursor++
}
case key.Matches(msg, key.NewBinding(key.WithKeys("k", "up"))):
if m.cursor > 0 {
m.cursor--
}
case key.Matches(msg, key.NewBinding(key.WithKeys(" "))):
if len(m.items) > 0 {
m.selected[m.cursor] = !m.selected[m.cursor]
}
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
// Confirm with whatever is currently toggled; if nothing toggled,
// treat the cursor item as the selection.
anySelected := false
for _, s := range m.selected {
if s {
anySelected = true
break
}
}
if !anySelected && len(m.items) > 0 {
m.selected[m.cursor] = true
}
m.result = MultiSelectorConfirmed
case key.Matches(msg, key.NewBinding(key.WithKeys("esc", "q"))):
m.result = MultiSelectorCancelled
}
}
return m, nil
}

// View renders the dialog centered on the terminal.
func (m *MultiSelectorModel) View() string {
var b strings.Builder

b.WriteString(m.styles.Title.Render(m.title))
b.WriteString("\n\n")

maxVisible := 10
if m.height > 0 {
maxVisible = m.height - 10
if maxVisible < 3 {
maxVisible = 3
}
}

start := 0
if m.cursor >= maxVisible {
start = m.cursor - maxVisible + 1
}
end := start + maxVisible
if end > len(m.items) {
end = len(m.items)
}

checkStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("82"))
uncheckedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))

for i := start; i < end; i++ {
item := m.items[i]
var checkbox string
if m.selected[i] {
checkbox = checkStyle.Render("[x]")
} else {
checkbox = uncheckedStyle.Render("[ ]")
}

var line string
if i == m.cursor {
line = fmt.Sprintf("%s %s %s", m.styles.Cursor.Render(">"), checkbox, m.styles.Selected.Render(item.Label))
} else {
line = fmt.Sprintf(" %s %s", checkbox, m.styles.Item.Render(item.Label))
}
b.WriteString(line)
if i < end-1 {
b.WriteString("\n")
}
}

b.WriteString("\n\n")
b.WriteString("[space] toggle [enter] copy [esc] cancel")

content := m.styles.Dialog.Render(b.String())

if m.width > 0 && m.height > 0 {
dialogWidth := lipgloss.Width(content)
dialogHeight := lipgloss.Height(content)

hPad := (m.width - dialogWidth) / 2
vPad := (m.height - dialogHeight) / 2
if hPad < 0 {
hPad = 0
}
if vPad < 0 {
vPad = 0
}

var centered strings.Builder
for i := 0; i < vPad; i++ {
centered.WriteString(strings.Repeat(" ", m.width))
centered.WriteString("\n")
}
for _, line := range strings.Split(content, "\n") {
centered.WriteString(strings.Repeat(" ", hPad))
centered.WriteString(line)
centered.WriteString("\n")
}
return centered.String()
}

return content
}
6 changes: 6 additions & 0 deletions internal/tui/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type KeyMap struct {
JSONView key.Binding
SwitchNamespace key.Binding
SwitchContext key.Binding
CopyData key.Binding
MultiSelect key.Binding
}

Expand Down Expand Up @@ -128,6 +129,10 @@ func NewKeyMap(cfg config.Keybindings) *KeyMap {
key.WithKeys(cfg.SwitchContext),
key.WithHelp(cfg.SwitchContext, "context"),
),
CopyData: key.NewBinding(
key.WithKeys(cfg.CopyData),
key.WithHelp(cfg.CopyData, "copy"),
),
MultiSelect: key.NewBinding(
key.WithKeys(cfg.MultiSelect),
key.WithHelp("space", "select"),
Expand All @@ -153,6 +158,7 @@ func (k KeyMap) FullHelp() [][]key.Binding {
{k.Describe, k.Logs, k.Delete, k.Edit},
{k.Terminal, k.PortForward, k.Scale, k.RolloutRestart},
{k.YAMLView, k.JSONView, k.SwitchNamespace, k.SwitchContext},
{k.CopyData},
{k.Help, k.Quit},
}
}
Loading