Skip to content
Open
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,22 @@ K9S_LOGS_DIR=/var/log k9s
less /var/log/k9s.log
```

## Tabs

K9s supports multiple tabs to allow you to quickly switch between different views and resources!
You can open up to 9 tabs simultaneously. Each tab maintains its own isolated view stack, command interpreter, and navigation/filter histories, while sharing the same underlying cluster connection.

A tab bar appears at the top of the content area whenever more than one tab is open, displaying the tab index and the active resource label.

You can manage tabs using the following key bindings:

| Action | Command | Comment |
|----------------------------|----------|------------------------------------------------------------------|
| Open a new tab | `ctrl-t` | Opens a new tab pre-loaded with the resource currently on screen |
| Close the active tab | `ctrl-x` | Closing the last tab is a no-op |
| Switch to the next tab | `ctrl-n` | Wraps around to the first tab |
| Switch to the previous tab | `ctrl-b` | Wraps around to the last tab |

## Key Bindings

K9s uses aliases to navigate most K8s resources.
Expand Down
10 changes: 10 additions & 0 deletions internal/model/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,16 @@ func (s *Stack) notify(a StackAction, c Component) {
}
}

// Repopulate replaces the stack contents with the given components without
// triggering any lifecycle (Stop/Start) callbacks or notifying listeners.
// Use this to mirror an external stack snapshot (e.g. when switching tabs).
func (s *Stack) Repopulate(components []Component) {
s.mx.Lock()
defer s.mx.Unlock()
s.components = make([]Component, len(components))
copy(s.components, components)
}

// ----------------------------------------------------------------------------
// Helpers...

Expand Down
7 changes: 7 additions & 0 deletions internal/ui/crumbs.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ func (c *Crumbs) StackPopped(_, _ model.Component) {
// StackTop indicates the top of the stack.
func (*Crumbs) StackTop(model.Component) {}

// Reset rebuilds the crumbs display from a snapshot of the component stack.
// Call this when switching tabs to replace the previous tab's breadcrumb trail.
func (c *Crumbs) Reset(components []model.Component) {
c.stack.Repopulate(components)
c.refresh(c.stack.Flatten())
}

// Refresh updates view with new crumbs.
func (c *Crumbs) refresh(crumbs []string) {
c.Clear()
Expand Down
53 changes: 53 additions & 0 deletions internal/ui/tab_bar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s

package ui

import (
"fmt"
"strings"

"github.com/derailed/k9s/internal/config"
"github.com/derailed/tview"
)

// TabBar renders the one-line horizontal strip of tab labels shown above the
// content area whenever more than one tab is open.
type TabBar struct {
*tview.TextView
styles *config.Styles
}

// NewTabBar returns an initialised TabBar attached to the given style set.
func NewTabBar(styles *config.Styles) *TabBar {
t := &TabBar{
styles: styles,
TextView: tview.NewTextView(),
}
t.SetDynamicColors(true)
t.SetBorderPadding(0, 0, 1, 1)
t.SetBackgroundColor(styles.BgColor())
styles.AddListener(t)
return t
}

// StylesChanged implements config.StyleListener.
func (t *TabBar) StylesChanged(s *config.Styles) {
t.styles = s
t.SetBackgroundColor(s.BgColor())
}

// Render redraws the tab strip. activeIdx is the zero-based index of the
// currently active tab; labels contains one entry per open tab.
func (t *TabBar) Render(labels []string, activeIdx int) {
t.Clear()
var sb strings.Builder
for i, label := range labels {
if i == activeIdx {
fmt.Fprintf(&sb, "[black:white:b] %d:%s [-:-:-] ", i+1, label)
} else {
fmt.Fprintf(&sb, "[gray:-:-] %d:%s [-:-:-] ", i+1, label)
}
}
fmt.Fprint(t, sb.String())
}
73 changes: 72 additions & 1 deletion internal/view/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type App struct {
clusterModel *model.ClusterInfo
cmdHistory *model.History
filterHistory *model.History
tabManager *TabManager
conRetry int32
showHeader bool
showLogo bool
Expand Down Expand Up @@ -132,6 +133,18 @@ func (a *App) Init(version string, _ int) error {
}
a.CmdBuff().SetSuggestionFn(a.suggestCommand())

// Wrap the initial page stack, command and histories into the first
// TabSession, then hand control to the TabManager. From here on layout()
// must reference tabManager.contentArea instead of a.Content directly.
initialSess := &TabSession{
Content: a.Content,
command: a.command,
cmdHistory: a.cmdHistory,
filterHistory: a.filterHistory,
}
a.tabManager = newTabManager(a)
a.tabManager.initWithSession(initialSess)

a.layout(ctx)
a.initSignals()

Expand Down Expand Up @@ -169,7 +182,7 @@ func (a *App) layout(ctx context.Context) {

main := tview.NewFlex().SetDirection(tview.FlexRow)
main.AddItem(a.statusIndicator(), 1, 1, false)
main.AddItem(a.Content, 0, 10, true)
main.AddItem(a.tabManager.contentArea, 0, 10, true)
if !a.Config.K9s.IsCrumbsless() {
main.AddItem(a.Crumbs(), 1, 1, false)
}
Expand Down Expand Up @@ -262,6 +275,10 @@ func (a *App) bindKeys() {
tcell.KeyCtrlA: ui.NewSharedKeyAction("Aliases", a.aliasCmd, false),
tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, false),
tcell.KeyCtrlC: ui.NewKeyAction("Quit", a.quitCmd, false),
tcell.KeyCtrlT: ui.NewSharedKeyAction("NewTab", a.newTabCmd, false),
tcell.KeyCtrlX: ui.NewSharedKeyAction("CloseTab", a.closeTabCmd, false),
tcell.KeyCtrlN: ui.NewSharedKeyAction("NextTab", a.nextTabCmd, false),
tcell.KeyCtrlB: ui.NewSharedKeyAction("PrevTab", a.prevTabCmd, false),
}))
}

Expand Down Expand Up @@ -453,6 +470,8 @@ func (a *App) switchNS(ns string) error {
return err
}

a.tabManager.switchNS(ns)

return a.factory.SetActiveNS(ns)
}

Expand Down Expand Up @@ -511,6 +530,9 @@ func (a *App) switchContext(ci *cmd.Interpreter, force bool) error {
slogs.View, a.Config.ActiveView(),
)
a.Flash().Infof("Switching context to %q::%q", contextName, ns)
if a.tabManager != nil {
a.tabManager.CloseOtherTabs()
}
a.ReloadStyles()
a.gotoResource(a.Config.ActiveView(), "", true, true)
if a.clusterModel != nil {
Expand Down Expand Up @@ -807,6 +829,55 @@ func (a *App) inject(c model.Component, clearStack bool) error {
}
a.Content.Push(c)

// Keep the tab label in sync with the top-level resource being browsed.
if clearStack && a.tabManager != nil {
a.tabManager.updateActiveLabel(c.Name())
}

return nil
}

// newTabCmd opens a new tab pre-loaded with the resource currently on screen.
func (a *App) newTabCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.InCmdMode() {
return evt
}
ctx := context.WithValue(context.Background(), internal.KeyApp, a)
if err := a.tabManager.newTab(ctx); err != nil {
a.Flash().Err(err)
return nil
}
// Navigate the new tab to the resource that was active in the source tab.
a.gotoResource(a.Config.ActiveView(), "", true, false)
return nil
}

// closeTabCmd closes the active tab. Closing the last tab is a no-op.
func (a *App) closeTabCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.InCmdMode() {
return evt
}
if err := a.tabManager.closeActive(); err != nil {
a.Flash().Warn(err.Error())
}
return nil
}

// nextTabCmd switches focus to the next tab (wraps around).
func (a *App) nextTabCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.InCmdMode() {
return evt
}
a.tabManager.NextTab()
return nil
}

// prevTabCmd switches focus to the previous tab (wraps around).
func (a *App) prevTabCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.InCmdMode() {
return evt
}
a.tabManager.PrevTab()
return nil
}

Expand Down
2 changes: 1 addition & 1 deletion internal/view/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ func TestAppNew(t *testing.T) {
a := view.NewApp(mock.NewMockConfig(t))
_ = a.Init("blee", 10)

assert.Equal(t, 14, a.GetActions().Len())
assert.Equal(t, 18, a.GetActions().Len())
}
4 changes: 2 additions & 2 deletions internal/view/cronjob.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ func (*CronJob) showJobs(app *App, _ ui.Tabular, gvr *client.GVR, fqn string) {
}

ns, _ := client.Namespaced(fqn)
if err := app.Config.SetActiveNamespace(ns); err != nil {
slog.Error("Unable to set active namespace during show pods", slogs.Error, err)
if err := app.switchNS(ns); err != nil {
slog.Error("Unable to switch namespace during show pods", slogs.Error, err)
}
v := NewJob(client.JobGVR)
v.SetContextFn(jobCtx(fqn, string(cj.UID)))
Expand Down
8 changes: 4 additions & 4 deletions internal/view/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ func showReplicasets(app *App, path string, labelSel labels.Selector, fieldSel s
v.SetLabelSelector(labelSel, true)

ns, _ := client.Namespaced(path)
if err := app.Config.SetActiveNamespace(ns); err != nil {
slog.Error("Unable to set active namespace during show replicasets", slogs.Error, err)
if err := app.switchNS(ns); err != nil {
slog.Error("Unable to switch namespace during show replicasets", slogs.Error, err)
}
if err := app.inject(v, false); err != nil {
app.Flash().Err(err)
Expand All @@ -153,8 +153,8 @@ func showPods(app *App, path string, labelSel labels.Selector, fieldSel string)
v.SetLabelSelector(labelSel, true)

ns, _ := client.Namespaced(path)
if err := app.Config.SetActiveNamespace(ns); err != nil {
slog.Error("Unable to set active namespace during show pods", slogs.Error, err)
if err := app.switchNS(ns); err != nil {
slog.Error("Unable to switch namespace during show pods", slogs.Error, err)
}
if err := app.inject(v, false); err != nil {
app.Flash().Err(err)
Expand Down
28 changes: 11 additions & 17 deletions internal/view/scale_extender.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,20 +109,13 @@ func (s *ScaleExtender) replicasFromReady(_ string) (string, error) {
}

func (s *ScaleExtender) replicasFromScaleSubresource(sel string) (string, error) {
res, err := dao.AccessorFor(s.App().factory, s.GVR())
if err != nil {
return "", err
}

replicasGetter, ok := res.(dao.ReplicasGetter)
if !ok {
return "", fmt.Errorf("expecting a replicasGetter resource for %q", s.GVR())
}

var scaler dao.Scaler
scaler.Init(s.App().factory, s.GVR())

ctx, cancel := context.WithTimeout(context.Background(), s.App().Conn().Config().CallTimeout())
defer cancel()

replicas, err := replicasGetter.Replicas(ctx, sel)
replicas, err := scaler.Replicas(ctx, sel)
if err != nil {
return "", err
}
Expand All @@ -145,12 +138,11 @@ func (s *ScaleExtender) makeScaleForm(fqns []string) (*tview.Form, error) {
// For built-in resources or cases where we can't get the replicas from the CRD, we can
// only try to get the number of copies from the READY field.
if factor == "0" {
replicas, err := s.replicasFromReady(fqns[0])
if err != nil {
return nil, err
if replicas, err := s.replicasFromReady(fqns[0]); err == nil {
factor = replicas
} else {
slog.Warn("Unable to read replicas from ready column", slogs.Error, err)
}

factor = replicas
}
}

Expand Down Expand Up @@ -222,7 +214,9 @@ func (s *ScaleExtender) scale(ctx context.Context, path string, replicas int32)
}
scaler, ok := res.(dao.Scalable)
if !ok {
return fmt.Errorf("expecting a scalable resource for %q", s.GVR())
var genericScaler dao.Scaler
genericScaler.Init(s.App().factory, s.GVR())
return genericScaler.Scale(ctx, path, replicas)
}

return scaler.Scale(ctx, path, replicas)
Expand Down
Loading