diff --git a/internal/config/config.go b/internal/config/config.go index 493e168..eb78790 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 92e997f..dd8289f 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -25,7 +25,8 @@ func DefaultConfig() *Config { YAMLView: "Y", JSONView: "J", SwitchNamespace: "n", - SwitchContext: "c", + SwitchContext: "C", + CopyData: "c", MultiSelect: " ", Enter: "enter", Escape: "esc", diff --git a/internal/tui/app.go b/internal/tui/app.go index 1be3ba7..5b245de 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -34,6 +34,7 @@ const ( ViewInput ViewHelp ViewSearchTab // Search tab command input mode + ViewCopy // Copy column data dialog ) // SearchTabIndex is the index of the special Search tab (always first) @@ -148,9 +149,10 @@ type Model struct { 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 + copySelector *dialog.MultiSelectorModel // Pending action state pendingAction string @@ -326,6 +328,22 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -633,6 +651,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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": @@ -787,6 +809,11 @@ func (m *Model) View() string { 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 @@ -1338,6 +1365,68 @@ func (m *Model) applyTabSort(data *kubectl.TableData) { // 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 diff --git a/internal/tui/components/dialog/multiselector.go b/internal/tui/components/dialog/multiselector.go new file mode 100644 index 0000000..c524120 --- /dev/null +++ b/internal/tui/components/dialog/multiselector.go @@ -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 +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 88dbcb4..1d670ce 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -30,6 +30,7 @@ type KeyMap struct { JSONView key.Binding SwitchNamespace key.Binding SwitchContext key.Binding + CopyData key.Binding MultiSelect key.Binding } @@ -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"), @@ -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}, } }