diff --git a/pkg/charm/models/composite_help.go b/pkg/charm/models/composite_help.go new file mode 100644 index 0000000..12a6e6c --- /dev/null +++ b/pkg/charm/models/composite_help.go @@ -0,0 +1,34 @@ +package models + +import ( + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" +) + +type CompositeHelpKeyMap struct { + helps []help.KeyMap +} + +func NewCompositeHelpKeyMap(helps ...help.KeyMap) *CompositeHelpKeyMap { + return &CompositeHelpKeyMap{helps: helps} +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (ch CompositeHelpKeyMap) ShortHelp() []key.Binding { + bindings := []key.Binding{} + for _, h := range ch.helps { + bindings = append(bindings, h.ShortHelp()...) + } + return bindings +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (ch CompositeHelpKeyMap) FullHelp() [][]key.Binding { + bindings := [][]key.Binding{} + for _, h := range ch.helps { + bindings = append(bindings, h.FullHelp()...) + } + return bindings +} diff --git a/pkg/charm/models/dashboard.go b/pkg/charm/models/dashboard.go index fa7a256..04fbcfc 100644 --- a/pkg/charm/models/dashboard.go +++ b/pkg/charm/models/dashboard.go @@ -12,9 +12,8 @@ import ( ) type DashboardKeyMap struct { - Help key.Binding - Quit key.Binding - TabberKeys TabberKeyMap + Help key.Binding + Quit key.Binding } // ShortHelp returns keybindings to be shown in the mini help view. It's part @@ -27,8 +26,7 @@ func (k DashboardKeyMap) ShortHelp() []key.Binding { // key.Map interface. func (k DashboardKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.TabberKeys.TabLeft, k.TabberKeys.TabRight}, // first column - {k.Help, k.Quit}, // second column + {k.Help, k.Quit}, } } @@ -41,7 +39,6 @@ var DefaultDashboardKeys = DashboardKeyMap{ key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q, esc, ctrl+c", "quit"), ), - TabberKeys: DefaultTabberKeys, } type Namer interface { @@ -100,5 +97,14 @@ func (d *Dashboard) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (d *Dashboard) View() string { div := styles.TabGap().Render(strings.Repeat(" ", max(0, d.width-2))) - return lipgloss.JoinVertical(0, d.tabber.View(), div, d.help.View(d.keys)) + return lipgloss.JoinVertical(0, d.tabber.View(), div, d.help.View(d.Help())) +} + +func (d *Dashboard) Help() help.KeyMap { + return CompositeHelpKeyMap{ + helps: []help.KeyMap{ + d.tabber.Help(), + d.keys, + }, + } } diff --git a/pkg/charm/models/panels/table.go b/pkg/charm/models/panels/table.go index d418500..0989abc 100644 --- a/pkg/charm/models/panels/table.go +++ b/pkg/charm/models/panels/table.go @@ -1,43 +1,134 @@ package panels import ( + "bytes" + "encoding/json" + "fmt" + "io" "sync" + "github.com/alecthomas/chroma/quick" tbl "github.com/calyptia/go-bubble-table" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/everettraven/buoy/pkg/charm/styles" buoytypes "github.com/everettraven/buoy/pkg/types" + "github.com/tidwall/gjson" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/cache" + "sigs.k8s.io/yaml" ) +type TableKeyMap struct { + ViewModeToggle key.Binding +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k TableKeyMap) ShortHelp() []key.Binding { + return []key.Binding{} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k TableKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.ViewModeToggle}, + } +} + +var DefaultTableKeys = TableKeyMap{ + ViewModeToggle: key.NewBinding( + key.WithKeys("v"), + key.WithHelp("v", "toggle viewing contents of selected resource"), + ), +} + +const modeView = "view" +const modeTable = "table" + +type RowInfo struct { + Row tbl.Row + Identifier *types.NamespacedName + // Is this necessary? Can the index change on different iterations? + Index int +} + // Table is a tea.Model implementation // that represents a table panel type Table struct { table tbl.Model + lister cache.GenericLister + scope meta.RESTScopeName + viewport viewport.Model + mode string name string mutex *sync.Mutex - rows map[types.UID]tbl.Row + rows map[types.UID]*RowInfo columns []buoytypes.Column err error tempRows []tbl.Row + keys TableKeyMap } -func NewTable(name string, table tbl.Model, columns []buoytypes.Column) *Table { +func NewTable(keys TableKeyMap, table *buoytypes.Table, lister cache.GenericLister, scope meta.RESTScopeName) *Table { + tblColumns := []string{} + width := 0 + for _, column := range table.Columns { + tblColumns = append(tblColumns, column.Header) + width += column.Width + } + + tab := tbl.New(tblColumns, 100, 10) + tab.Styles.SelectedRow = styles.TableSelectedRowStyle() + return &Table{ - table: table, - name: name, - mutex: &sync.Mutex{}, - rows: map[types.UID]tbl.Row{}, - columns: columns, + table: tab, + viewport: viewport.New(0, 0), + scope: scope, + lister: lister, + mode: modeTable, + name: table.Name, + mutex: &sync.Mutex{}, + rows: map[types.UID]*RowInfo{}, + columns: table.Columns, + keys: keys, } } -func (m *Table) Init() tea.Cmd { return nil } +func (m *Table) Init() tea.Cmd { + return nil +} func (m *Table) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: m.table.SetSize(msg.Width, msg.Height/2) + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height / 2 + case tea.KeyMsg: + switch { + case key.Matches(msg, DefaultTableKeys.ViewModeToggle): + switch m.mode { + case modeTable: + m.mode = modeView + vpContent, err := m.FetchContentForIndex(m.table.Cursor()) + if err != nil { + m.viewport.SetContent(err.Error()) + } else { + m.viewport.SetContent(vpContent) + } + case modeView: + m.mode = modeTable + m.viewport.SetContent("") + } + } } if len(m.tempRows) > 0 { @@ -45,7 +136,12 @@ func (m *Table) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tempRows = []tbl.Row{} } - m.table, cmd = m.table.Update(msg) + switch m.mode { + case modeTable: + m.table, cmd = m.table.Update(msg) + case modeView: + m.viewport, cmd = m.viewport.Update(msg) + } return m, cmd } @@ -53,13 +149,35 @@ func (m *Table) View() string { if m.err != nil { return m.err.Error() } - return m.table.View() + switch m.mode { + case modeTable: + return m.table.View() + case modeView: + return m.viewport.View() + default: + return "?" + } } -func (m *Table) AddOrUpdateRow(uid types.UID, row tbl.Row) { +func (m *Table) AddOrUpdate(u *unstructured.Unstructured) { m.mutex.Lock() defer m.mutex.Unlock() - m.rows[uid] = row + uid := u.GetUID() + row := tbl.SimpleRow{} + for _, column := range m.Columns() { + val, err := getDotNotationValue(u.Object, column.Path) + if err != nil { + m.SetError(err) + break + } + + row = append(row, fmt.Sprint(val)) + } + + m.rows[uid] = &RowInfo{ + Row: row, + Identifier: &types.NamespacedName{Namespace: u.GetNamespace(), Name: u.GetName()}, + } m.updateRows() } @@ -72,8 +190,11 @@ func (m *Table) DeleteRow(uid types.UID) { func (m *Table) updateRows() { rows := []tbl.Row{} - for _, row := range m.rows { - rows = append(rows, row) + indice := 0 + for _, rowInfo := range m.rows { + rows = append(rows, rowInfo.Row) + rowInfo.Index = indice + indice++ } m.tempRows = rows } @@ -89,3 +210,66 @@ func (m *Table) Name() string { func (m *Table) SetError(err error) { m.err = err } + +func (m *Table) FetchContentForIndex(index int) (string, error) { + m.mutex.Lock() + defer m.mutex.Unlock() + var rowInfo *RowInfo + for _, row := range m.rows { + if row.Index == index { + rowInfo = row + break + } + } + + name := rowInfo.Identifier.String() + if m.scope == meta.RESTScopeNameRoot { + name = rowInfo.Identifier.Name + } + + obj, err := m.lister.Get(name) + if err != nil { + return "", fmt.Errorf("fetching definition for %q: %w", name, err) + } + + itemJSON, err := obj.(*unstructured.Unstructured).MarshalJSON() + if err != nil { + return "", fmt.Errorf("error marshalling item %q: %w", name, err) + } + + itemYAML, err := yaml.JSONToYAML(itemJSON) + if err != nil { + return "", fmt.Errorf("converting JSON to YAML for item %q: %w", name, err) + } + + theme := "nord" + if !lipgloss.HasDarkBackground() { + theme = "monokailight" + } + rw := &bytes.Buffer{} + err = quick.Highlight(rw, string(itemYAML), "yaml", "terminal16m", theme) + if err != nil { + return "", fmt.Errorf("highlighting YAML for item %q: %w", name, err) + } + highlighted, err := io.ReadAll(rw) + if err != nil { + return "", fmt.Errorf("reading highlighted YAML for item %q: %w", name, err) + } + return string(highlighted), nil +} + +func (m *Table) Help() help.KeyMap { + return m.keys +} + +func getDotNotationValue(item map[string]interface{}, dotPath string) (interface{}, error) { + jsonBytes, err := json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("error marshalling item to json: %w", err) + } + res := gjson.Get(string(jsonBytes), dotPath) + if !res.Exists() { + return "n/a", nil + } + return res.Value(), nil +} diff --git a/pkg/charm/models/tabber.go b/pkg/charm/models/tabber.go index 0a9d157..4bf6301 100644 --- a/pkg/charm/models/tabber.go +++ b/pkg/charm/models/tabber.go @@ -3,12 +3,17 @@ package models import ( "strings" + "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/everettraven/buoy/pkg/charm/styles" ) +type Helper interface { + Help() help.KeyMap +} + type Tab struct { Name string Model tea.Model @@ -50,6 +55,12 @@ func (t *Tabber) Update(msg tea.Msg) (*Tabber, tea.Cmd) { } case tea.WindowSizeMsg: t.width = msg.Width + for i := range t.tabs { + var tempCmd tea.Cmd + t.tabs[i].Model, tempCmd = t.tabs[i].Model.Update(msg) + cmd = tea.Batch(cmd, tempCmd) + } + return t, cmd } t.tabs[t.selected].Model, cmd = t.tabs[t.selected].Model.Update(msg) @@ -76,11 +87,36 @@ func (t *Tabber) View() string { return lipgloss.JoinVertical(0, tabsWithBorder, content) } +func (t *Tabber) Help() help.KeyMap { + helps := []help.KeyMap{} + if helper, ok := t.tabs[t.selected].Model.(Helper); ok { + helps = append(helps, helper.Help()) + } + + return CompositeHelpKeyMap{ + helps: append(helps, t.keyMap), + } +} + type TabberKeyMap struct { TabRight key.Binding TabLeft key.Binding } +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k TabberKeyMap) ShortHelp() []key.Binding { + return []key.Binding{} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k TabberKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.TabLeft, k.TabRight}, + } +} + var DefaultTabberKeys = TabberKeyMap{ TabRight: key.NewBinding( key.WithKeys("tab"), diff --git a/pkg/paneler/helpers.go b/pkg/paneler/helpers.go deleted file mode 100644 index 762c0de..0000000 --- a/pkg/paneler/helpers.go +++ /dev/null @@ -1,20 +0,0 @@ -package paneler - -import ( - "encoding/json" - "fmt" - - "github.com/tidwall/gjson" -) - -func getDotNotationValue(item map[string]interface{}, dotPath string) (interface{}, error) { - jsonBytes, err := json.Marshal(item) - if err != nil { - return nil, fmt.Errorf("error marshalling item to json: %w", err) - } - res := gjson.Get(string(jsonBytes), dotPath) - if !res.Exists() { - return nil, fmt.Errorf("nested field %q not found", dotPath) - } - return res.Value(), nil -} diff --git a/pkg/paneler/table.go b/pkg/paneler/table.go index 018805e..f1731ae 100644 --- a/pkg/paneler/table.go +++ b/pkg/paneler/table.go @@ -5,10 +5,8 @@ import ( "fmt" "time" - tbl "github.com/calyptia/go-bubble-table" tea "github.com/charmbracelet/bubbletea" "github.com/everettraven/buoy/pkg/charm/models/panels" - "github.com/everettraven/buoy/pkg/charm/styles" buoytypes "github.com/everettraven/buoy/pkg/types" "k8s.io/apimachinery/pkg/api/meta" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -18,6 +16,7 @@ import ( "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/informers" "k8s.io/client-go/tools/cache" ) @@ -38,29 +37,34 @@ func NewTable(dynamicClient dynamic.Interface, discoveryClient *discovery.Discov } func (t *Table) Model(panel buoytypes.Panel) (tea.Model, error) { - tab := buoytypes.Table{} - err := json.Unmarshal(panel.Blob, &tab) + tab := &buoytypes.Table{} + err := json.Unmarshal(panel.Blob, tab) if err != nil { return nil, fmt.Errorf("unmarshalling panel to table type: %s", err) } - tw := t.modelWrapperForTablePanel(tab) - return tw, t.runInformerForTable(tab, tw) + model, informer, err := t.modelForTablePanel(tab) + if err != nil { + return nil, fmt.Errorf("creating model wrapper for table panel: %w", err) + } + go informer.Informer().Run(make(chan struct{})) + return model, nil } -func (t *Table) modelWrapperForTablePanel(tablePanel buoytypes.Table) *panels.Table { - columns := []string{} - width := 0 - for _, column := range tablePanel.Columns { - columns = append(columns, column.Header) - width += column.Width +func (t *Table) modelForTablePanel(tablePanel *buoytypes.Table) (*panels.Table, informers.GenericInformer, error) { + inf, scope, err := t.informerForTable(tablePanel, nil) + if err != nil { + return nil, nil, fmt.Errorf("creating informer for table: %w", err) } + table := panels.NewTable(panels.DefaultTableKeys, tablePanel, inf.Lister(), scope) + _, err = setEventHandlerForTableInformer(inf, table) + if err != nil { + return nil, nil, fmt.Errorf("setting event handler for table informer: %w", err) + } + return table, inf, nil - tab := tbl.New(columns, 100, 10) - tab.Styles.SelectedRow = styles.TableSelectedRowStyle() - return panels.NewTable(tablePanel.Name, tab, tablePanel.Columns) } -func (t *Table) runInformerForTable(tablePanel buoytypes.Table, tw *panels.Table) error { +func (t *Table) informerForTable(tablePanel *buoytypes.Table, tw *panels.Table) (informers.GenericInformer, meta.RESTScopeName, error) { // create informer and event handler gvk := schema.GroupVersionKind{ Group: tablePanel.Group, @@ -69,7 +73,7 @@ func (t *Table) runInformerForTable(tablePanel buoytypes.Table, tw *panels.Table } mapping, err := t.restMapper.RESTMapping(gvk.GroupKind(), gvk.Version) if err != nil { - return fmt.Errorf("error creating resource mapping: %w", err) + return nil, "", fmt.Errorf("error creating resource mapping: %w", err) } ns := tablePanel.Namespace if mapping.Scope.Name() == meta.RESTScopeNameRoot { @@ -86,45 +90,23 @@ func (t *Table) runInformerForTable(tablePanel buoytypes.Table, tw *panels.Table ) inf := infFact.ForResource(mapping.Resource) - _, err = inf.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - u := obj.(*unstructured.Unstructured) - row := tbl.SimpleRow{} - for _, column := range tw.Columns() { - val, err := getDotNotationValue(u.Object, column.Path) - if err != nil { - tw.SetError(err) - break - } - row = append(row, fmt.Sprint(val)) - } + return inf, mapping.Scope.Name(), nil +} - tw.AddOrUpdateRow(u.GetUID(), row) +func setEventHandlerForTableInformer(inf informers.GenericInformer, tw *panels.Table) (cache.ResourceEventHandlerRegistration, error) { + return inf.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + u := obj.(*unstructured.Unstructured) + tw.AddOrUpdate(u) }, UpdateFunc: func(oldObj, newObj interface{}) { u := newObj.(*unstructured.Unstructured) - row := tbl.SimpleRow{} - for _, column := range tw.Columns() { - val, err := getDotNotationValue(u.Object, column.Path) - if err != nil { - tw.SetError(err) - break - } - row = append(row, fmt.Sprint(val)) - } - - tw.AddOrUpdateRow(u.GetUID(), row) + tw.AddOrUpdate(u) }, DeleteFunc: func(obj interface{}) { u := obj.(*unstructured.Unstructured) tw.DeleteRow(u.GetUID()) }, }) - if err != nil { - return fmt.Errorf("adding event handler to informer: %w", err) - } - - go inf.Informer().Run(make(<-chan struct{})) - return nil }