Sort all columns (#3650)

* feat: enhance table column selection and sorting functionality

* fix: update key binding for sorting selected column to Ctrl+O

* remove unused setSelectedColIdx method and sortIndicator function

* test: update expected hints count in various view tests

* test: update expected hints count in TestServiceNew

* fix: update keyboard handling for column selection to use Shift key

* refactor: streamline column selection logic and initialize selected column based on current sort

* fix: change key binding for sorting selected column from Ctrl+O to Shift+S

* test: update expected hints count in help, namespace, pod, and PVC tests
mine
Ümüt Özalp 2025-11-16 16:49:29 +01:00 committed by GitHub
parent 2bf2f481ef
commit 6bc67e97f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 216 additions and 42 deletions

View File

@ -37,25 +37,26 @@ type (
// Table represents tabular data. // Table represents tabular data.
type Table struct { type Table struct {
*SelectTable *SelectTable
gvr *client.GVR gvr *client.GVR
sortCol model1.SortColumn sortCol model1.SortColumn
manualSort bool selectedColIdx int
Path string manualSort bool
Extras string Path string
actions *KeyActions Extras string
cmdBuff *model.FishBuff actions *KeyActions
styles *config.Styles cmdBuff *model.FishBuff
viewSetting *config.ViewSetting styles *config.Styles
colorerFn model1.ColorerFunc viewSetting *config.ViewSetting
decorateFn DecorateFunc colorerFn model1.ColorerFunc
wide bool decorateFn DecorateFunc
toast bool wide bool
hasMetrics bool toast bool
ctx context.Context hasMetrics bool
mx sync.RWMutex ctx context.Context
readOnly bool mx sync.RWMutex
noIcon bool readOnly bool
fullGVR bool noIcon bool
fullGVR bool
} }
// NewTable returns a new table view. // NewTable returns a new table view.
@ -133,6 +134,137 @@ func (t *Table) getMSort() bool {
return t.manualSort return t.manualSort
} }
func (t *Table) getSelectedColIdx() int {
t.mx.RLock()
defer t.mx.RUnlock()
return t.selectedColIdx
}
// initSelectedColumn initializes the selected column index based on current sort column.
func (t *Table) initSelectedColumn() {
data := t.GetFilteredData()
if data == nil || data.HeaderCount() == 0 {
return
}
sc := t.getSortCol()
if sc.Name == "" {
t.mx.Lock()
t.selectedColIdx = 0
t.mx.Unlock()
return
}
// Find the visual column index for the current sort column
header := data.Header()
visibleCol := 0
for _, h := range header {
if t.shouldExcludeColumn(h) {
continue
}
if h.Name == sc.Name {
t.mx.Lock()
t.selectedColIdx = visibleCol
t.mx.Unlock()
return
}
visibleCol++
}
// If sort column not found in visible columns, default to 0
t.mx.Lock()
t.selectedColIdx = 0
t.mx.Unlock()
}
// moveSelectedColumn moves the column selection by delta (-1 for left, +1 for right).
func (t *Table) moveSelectedColumn(delta int) {
data := t.GetFilteredData()
if data == nil || data.HeaderCount() == 0 {
return
}
// Count visible columns
visibleCount := 0
for _, h := range data.Header() {
if !t.shouldExcludeColumn(h) {
visibleCount++
}
}
if visibleCount == 0 {
return
}
t.mx.Lock()
t.selectedColIdx += delta
// Wrap around
if t.selectedColIdx >= visibleCount {
t.selectedColIdx = 0
} else if t.selectedColIdx < 0 {
t.selectedColIdx = visibleCount - 1
}
t.mx.Unlock()
t.Refresh()
}
// SelectNextColumn moves the column selection to the right.
func (t *Table) SelectNextColumn() {
t.moveSelectedColumn(1)
}
// SelectPrevColumn moves the column selection to the left.
func (t *Table) SelectPrevColumn() {
t.moveSelectedColumn(-1)
}
// SortSelectedColumn sorts by the currently selected column.
func (t *Table) SortSelectedColumn() {
data := t.GetFilteredData()
if data == nil || data.HeaderCount() == 0 {
return
}
idx := t.getSelectedColIdx()
if idx < 0 {
return
}
// Map visual column index to actual header column name
// (accounting for hidden columns)
header := data.Header()
visibleCol := 0
var colName string
for _, h := range header {
if t.shouldExcludeColumn(h) {
continue
}
if visibleCol == idx {
colName = h.Name
break
}
visibleCol++
}
if colName == "" {
return
}
sc := t.getSortCol()
// Toggle direction if same column, otherwise default to ascending
asc := true
if sc.Name == colName {
asc = !sc.ASC
}
t.SetSortCol(colName, asc)
t.setMSort(true)
t.Refresh()
}
// SetViewSetting sets custom view config is present. // SetViewSetting sets custom view config is present.
func (t *Table) SetViewSetting(vs *config.ViewSetting) bool { func (t *Table) SetViewSetting(vs *config.ViewSetting) bool {
t.mx.Lock() t.mx.Lock()
@ -315,8 +447,17 @@ func (t *Table) doUpdate(data *model1.TableData) *model1.TableData {
} else { } else {
t.actions.Delete(KeyShiftP) t.actions.Delete(KeyShiftP)
} }
oldSortCol := t.getSortCol()
t.setSortCol(data.ComputeSortCol(t.GetViewSetting(), t.getSortCol(), t.getMSort())) t.setSortCol(data.ComputeSortCol(t.GetViewSetting(), t.getSortCol(), t.getMSort()))
// Initialize selected column index to match the current sort column
// This ensures the highlight starts at the sorted column
newSortCol := t.getSortCol()
if oldSortCol.Name != newSortCol.Name {
t.initSelectedColumn()
}
return data return data
} }
@ -429,6 +570,10 @@ func (t *Table) SortColCmd(name string, asc bool) func(evt *tcell.EventKey) *tce
sc.Name = name sc.Name = name
t.setSortCol(sc) t.setSortCol(sc)
t.setMSort(true) t.setMSort(true)
// Sync selected column index with the new sort column
t.initSelectedColumn()
t.Refresh() t.Refresh()
return nil return nil
} }
@ -486,8 +631,9 @@ func (t *Table) NameColIndex() int {
func (t *Table) AddHeaderCell(col int, h model1.HeaderColumn) { func (t *Table) AddHeaderCell(col int, h model1.HeaderColumn) {
sc := t.getSortCol() sc := t.getSortCol()
sortCol := h.Name == sc.Name sortCol := h.Name == sc.Name
selectedCol := col == t.getSelectedColIdx()
styles := t.styles.Table() styles := t.styles.Table()
c := tview.NewTableCell(sortIndicator(sortCol, sc.ASC, &styles, h.Name)) c := tview.NewTableCell(columnIndicator(sortCol, selectedCol, sc.ASC, &styles, h.Name))
c.SetExpansion(1) c.SetExpansion(1)
c.SetSelectable(false) c.SetSelectable(false)
c.SetAlign(h.Align) c.SetAlign(h.Align)

View File

@ -84,16 +84,25 @@ func SkinTitle(fmat string, style *config.Frame) string {
return fmat return fmat
} }
func sortIndicator(sort, asc bool, style *config.Table, name string) string { func columnIndicator(sort, selected, asc bool, style *config.Table, name string) string {
if !sort { // Build the column name with selection indicator
return name displayName := name
if selected {
// Make selected column bold
displayName = fmt.Sprintf("[::b]%s[::-]", name)
} }
order := descIndicator // Add sort indicator if this column is sorted
if asc { suffix := ""
order = ascIndicator if sort {
order := descIndicator
if asc {
order = ascIndicator
}
suffix = fmt.Sprintf("[%s::b]%s[::]", style.Header.SorterColor, order)
} }
return fmt.Sprintf("%s[%s::b]%s[::]", name, style.Header.SorterColor, order)
return displayName + suffix
} }
func formatCell(field string, padding int) string { func formatCell(field string, padding int) string {

View File

@ -30,7 +30,7 @@ func TestAliasNew(t *testing.T) {
require.NoError(t, v.Init(makeContext(t))) require.NoError(t, v.Init(makeContext(t)))
assert.Equal(t, "Aliases", v.Name()) assert.Equal(t, "Aliases", v.Name())
assert.Len(t, v.Hints(), 6) assert.Len(t, v.Hints(), 7)
} }
func TestAliasSearch(t *testing.T) { func TestAliasSearch(t *testing.T) {

View File

@ -17,5 +17,5 @@ func TestConfigMapNew(t *testing.T) {
require.NoError(t, s.Init(makeCtx(t))) require.NoError(t, s.Init(makeCtx(t)))
assert.Equal(t, "ConfigMaps", s.Name()) assert.Equal(t, "ConfigMaps", s.Name())
assert.Len(t, s.Hints(), 7) assert.Len(t, s.Hints(), 8)
} }

View File

@ -17,5 +17,5 @@ func TestContainerNew(t *testing.T) {
require.NoError(t, c.Init(makeCtx(t))) require.NoError(t, c.Init(makeCtx(t)))
assert.Equal(t, "Containers", c.Name()) assert.Equal(t, "Containers", c.Name())
assert.Len(t, c.Hints(), 19) assert.Len(t, c.Hints(), 20)
} }

View File

@ -17,5 +17,5 @@ func TestContext(t *testing.T) {
require.NoError(t, ctx.Init(makeCtx(t))) require.NoError(t, ctx.Init(makeCtx(t)))
assert.Equal(t, "Contexts", ctx.Name()) assert.Equal(t, "Contexts", ctx.Name())
assert.Len(t, ctx.Hints(), 6) assert.Len(t, ctx.Hints(), 7)
} }

View File

@ -16,5 +16,5 @@ func TestDir(t *testing.T) {
require.NoError(t, v.Init(makeCtx(t))) require.NoError(t, v.Init(makeCtx(t)))
assert.Equal(t, "Directory", v.Name()) assert.Equal(t, "Directory", v.Name())
assert.Len(t, v.Hints(), 7) assert.Len(t, v.Hints(), 8)
} }

View File

@ -17,5 +17,5 @@ func TestDeploy(t *testing.T) {
require.NoError(t, v.Init(makeCtx(t))) require.NoError(t, v.Init(makeCtx(t)))
assert.Equal(t, "Deployments", v.Name()) assert.Equal(t, "Deployments", v.Name())
assert.Len(t, v.Hints(), 17) assert.Len(t, v.Hints(), 18)
} }

View File

@ -17,5 +17,5 @@ func TestDaemonSet(t *testing.T) {
require.NoError(t, v.Init(makeCtx(t))) require.NoError(t, v.Init(makeCtx(t)))
assert.Equal(t, "DaemonSets", v.Name()) assert.Equal(t, "DaemonSets", v.Name())
assert.Len(t, v.Hints(), 17) assert.Len(t, v.Hints(), 18)
} }

View File

@ -17,5 +17,5 @@ func TestPortForwardNew(t *testing.T) {
require.NoError(t, pf.Init(makeCtx(t))) require.NoError(t, pf.Init(makeCtx(t)))
assert.Equal(t, "PortForwards", pf.Name()) assert.Equal(t, "PortForwards", pf.Name())
assert.Len(t, pf.Hints(), 10) assert.Len(t, pf.Hints(), 11)
} }

View File

@ -17,5 +17,5 @@ func TestPriorityClassNew(t *testing.T) {
require.NoError(t, s.Init(makeCtx(t))) require.NoError(t, s.Init(makeCtx(t)))
assert.Equal(t, "PriorityClass", s.Name()) assert.Equal(t, "PriorityClass", s.Name())
assert.Len(t, s.Hints(), 6) assert.Len(t, s.Hints(), 7)
} }

View File

@ -17,5 +17,5 @@ func TestRbacNew(t *testing.T) {
require.NoError(t, v.Init(makeCtx(t))) require.NoError(t, v.Init(makeCtx(t)))
assert.Equal(t, "Rbac", v.Name()) assert.Equal(t, "Rbac", v.Name())
assert.Len(t, v.Hints(), 5) assert.Len(t, v.Hints(), 6)
} }

View File

@ -17,5 +17,5 @@ func TestReferenceNew(t *testing.T) {
require.NoError(t, s.Init(makeCtx(t))) require.NoError(t, s.Init(makeCtx(t)))
assert.Equal(t, "References", s.Name()) assert.Equal(t, "References", s.Name())
assert.Len(t, s.Hints(), 4) assert.Len(t, s.Hints(), 5)
} }

View File

@ -17,5 +17,5 @@ func TestScreenDumpNew(t *testing.T) {
require.NoError(t, po.Init(makeCtx(t))) require.NoError(t, po.Init(makeCtx(t)))
assert.Equal(t, "ScreenDumps", po.Name()) assert.Equal(t, "ScreenDumps", po.Name())
assert.Len(t, po.Hints(), 5) assert.Len(t, po.Hints(), 6)
} }

View File

@ -17,5 +17,5 @@ func TestSecretNew(t *testing.T) {
require.NoError(t, s.Init(makeCtx(t))) require.NoError(t, s.Init(makeCtx(t)))
assert.Equal(t, "Secrets", s.Name()) assert.Equal(t, "Secrets", s.Name())
assert.Len(t, s.Hints(), 8) assert.Len(t, s.Hints(), 9)
} }

View File

@ -17,5 +17,5 @@ func TestStatefulSetNew(t *testing.T) {
require.NoError(t, s.Init(makeCtx(t))) require.NoError(t, s.Init(makeCtx(t)))
assert.Equal(t, "StatefulSets", s.Name()) assert.Equal(t, "StatefulSets", s.Name())
assert.Len(t, s.Hints(), 14) assert.Len(t, s.Hints(), 15)
} }

View File

@ -174,5 +174,5 @@ func TestServiceNew(t *testing.T) {
require.NoError(t, s.Init(makeCtx(t))) require.NoError(t, s.Init(makeCtx(t)))
assert.Equal(t, "Services", s.Name()) assert.Equal(t, "Services", s.Name())
assert.Len(t, s.Hints(), 12) assert.Len(t, s.Hints(), 13)
} }

View File

@ -95,6 +95,19 @@ func (t *Table) SendKey(evt *tcell.EventKey) {
func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey { func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey {
key := evt.Key() key := evt.Key()
// Handle Shift+Left/Right for column selection
if evt.Modifiers()&tcell.ModShift != 0 {
if key == tcell.KeyLeft {
t.Table.SelectPrevColumn()
return nil
}
if key == tcell.KeyRight {
t.Table.SelectNextColumn()
return nil
}
}
if key == tcell.KeyUp || key == tcell.KeyDown { if key == tcell.KeyUp || key == tcell.KeyDown {
return evt return evt
} }
@ -214,6 +227,7 @@ func (t *Table) bindKeys() {
tcell.KeyCtrlW: ui.NewKeyAction("Toggle Wide", t.toggleWideCmd, false), tcell.KeyCtrlW: ui.NewKeyAction("Toggle Wide", t.toggleWideCmd, false),
ui.KeyShiftN: ui.NewKeyAction("Sort Name", t.SortColCmd(nameCol, true), false), ui.KeyShiftN: ui.NewKeyAction("Sort Name", t.SortColCmd(nameCol, true), false),
ui.KeyShiftA: ui.NewKeyAction("Sort Age", t.SortColCmd(ageCol, true), false), ui.KeyShiftA: ui.NewKeyAction("Sort Age", t.SortColCmd(ageCol, true), false),
ui.KeyShiftS: ui.NewKeyAction("Sort Selected Column", t.sortSelectedColumnCmd, false),
}) })
} }
@ -227,6 +241,11 @@ func (t *Table) toggleWideCmd(*tcell.EventKey) *tcell.EventKey {
return nil return nil
} }
func (t *Table) sortSelectedColumnCmd(*tcell.EventKey) *tcell.EventKey {
t.Table.SortSelectedColumn()
return nil
}
func (t *Table) cpCmd(evt *tcell.EventKey) *tcell.EventKey { func (t *Table) cpCmd(evt *tcell.EventKey) *tcell.EventKey {
path := t.GetSelectedItem() path := t.GetSelectedItem()
if path == "" { if path == "" {