diff --git a/README.md b/README.md index 0252981a75..10afcf236e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/model/stack.go b/internal/model/stack.go index b48862ced3..12167555be 100644 --- a/internal/model/stack.go +++ b/internal/model/stack.go @@ -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... diff --git a/internal/ui/crumbs.go b/internal/ui/crumbs.go index d9f5cac335..dccf0733fa 100644 --- a/internal/ui/crumbs.go +++ b/internal/ui/crumbs.go @@ -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() diff --git a/internal/ui/tab_bar.go b/internal/ui/tab_bar.go new file mode 100644 index 0000000000..2cfda1341d --- /dev/null +++ b/internal/ui/tab_bar.go @@ -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()) +} diff --git a/internal/view/app.go b/internal/view/app.go index a03aaf8cbf..26a56618e1 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -52,6 +52,7 @@ type App struct { clusterModel *model.ClusterInfo cmdHistory *model.History filterHistory *model.History + tabManager *TabManager conRetry int32 showHeader bool showLogo bool @@ -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() @@ -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) } @@ -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), })) } @@ -453,6 +470,8 @@ func (a *App) switchNS(ns string) error { return err } + a.tabManager.switchNS(ns) + return a.factory.SetActiveNS(ns) } @@ -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 { @@ -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 } diff --git a/internal/view/app_test.go b/internal/view/app_test.go index 8f78114cc9..9b513eb3c4 100644 --- a/internal/view/app_test.go +++ b/internal/view/app_test.go @@ -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()) } diff --git a/internal/view/cronjob.go b/internal/view/cronjob.go index bc532c5881..c5b91cd34f 100644 --- a/internal/view/cronjob.go +++ b/internal/view/cronjob.go @@ -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))) diff --git a/internal/view/helpers.go b/internal/view/helpers.go index 0d51af0c7f..76b465f541 100644 --- a/internal/view/helpers.go +++ b/internal/view/helpers.go @@ -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) @@ -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) diff --git a/internal/view/scale_extender.go b/internal/view/scale_extender.go index f9e4d00b06..35ecc375b6 100644 --- a/internal/view/scale_extender.go +++ b/internal/view/scale_extender.go @@ -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 } @@ -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 } } @@ -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) diff --git a/internal/view/tab_manager.go b/internal/view/tab_manager.go new file mode 100644 index 0000000000..df6b99bed8 --- /dev/null +++ b/internal/view/tab_manager.go @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package view + +import ( + "context" + "fmt" + "sync" + + "github.com/derailed/k9s/internal/dao" + "github.com/derailed/k9s/internal/model" + "github.com/derailed/k9s/internal/ui" + "github.com/derailed/tview" +) + +const maxTabs = 9 + +// TabManager orchestrates multiple tab sessions within a shared tview layout. +// +// Layout hierarchy (replaces the bare Content slot in the main Flex): +// +// contentArea *tview.Flex (FlexRow) +// ├── tabBar *ui.TabBar (1 line, visible only when len(sessions) > 1) +// └── container *tview.Pages (one page per TabSession) +// +// The K8s factory, cluster model, and application styles are shared across all +// sessions. Each session owns its view stack, command interpreter and +// navigation histories. +type TabManager struct { + sessions []*TabSession + activeIdx int + nextID int + container *tview.Pages + tabBar *ui.TabBar + contentArea *tview.Flex + tabBarVisible bool + app *App + mu sync.RWMutex +} + +// newTabManager constructs a TabManager without any sessions. +// Call initWithSession to seed the first session. +func newTabManager(app *App) *TabManager { + tm := &TabManager{ + app: app, + container: tview.NewPages(), + tabBar: ui.NewTabBar(app.Styles), + } + tm.contentArea = tview.NewFlex().SetDirection(tview.FlexRow) + tm.contentArea.AddItem(tm.container, 0, 1, true) + return tm +} + +// initWithSession registers sess as the first tab. The session's Content, +// command and histories must already be initialised by the caller (app.Init +// does this before calling initWithSession). Crumbs and Menu listeners have +// already been attached to sess.Content by app.Init as well. +func (tm *TabManager) initWithSession(sess *TabSession) { + sess.id = tm.nextID + tm.nextID++ + tm.sessions = []*TabSession{sess} + tm.activeIdx = 0 + tm.container.AddPage(tm.pageKey(sess.id), sess.Content, true, true) +} + +// Active returns the currently active session. +func (tm *TabManager) Active() *TabSession { + tm.mu.RLock() + defer tm.mu.RUnlock() + return tm.sessions[tm.activeIdx] +} + +// Count returns the number of open tabs. +func (tm *TabManager) Count() int { + tm.mu.RLock() + defer tm.mu.RUnlock() + return len(tm.sessions) +} + +// newTab creates a new session, activates it, and registers its page in the +// container. The caller is responsible for navigating the new session to the +// desired resource after newTab returns. +// Must be called on the tview main goroutine. +func (tm *TabManager) newTab(ctx context.Context) error { + tm.mu.Lock() + if len(tm.sessions) >= maxTabs { + tm.mu.Unlock() + return fmt.Errorf("maximum %d tabs allowed", maxTabs) + } + id := tm.nextID + tm.nextID++ + tm.mu.Unlock() + + sess := &TabSession{ + id: id, + Content: NewPageStack(), + cmdHistory: model.NewHistory(model.MaxHistory), + filterHistory: model.NewHistory(model.MaxHistory), + label: "", + } + + if err := sess.Content.Init(ctx); err != nil { + return fmt.Errorf("init tab content: %w", err) + } + + cmd := NewCommand(tm.app) + if err := cmd.Init(tm.app.Config.ContextAliasesPath()); err != nil { + return fmt.Errorf("init tab command: %w", err) + } + sess.command = cmd + + tm.mu.Lock() + tm.sessions = append(tm.sessions, sess) + newIdx := len(tm.sessions) - 1 + tm.mu.Unlock() + + tm.container.AddPage(tm.pageKey(sess.id), sess.Content, true, false) + tm.activateSession(newIdx) + tm.refreshTabBar() + + return nil +} + +// closeActive closes the active tab, switches to an adjacent one, and stops +// all components belonging to the closed session. +// Returns an error when the last tab is requested to be closed. +// Must be called on the tview main goroutine. +func (tm *TabManager) closeActive() error { + tm.mu.Lock() + if len(tm.sessions) <= 1 { + tm.mu.Unlock() + return fmt.Errorf("cannot close the last tab") + } + + idx := tm.activeIdx + sess := tm.sessions[idx] + + // Prefer the right neighbour; fall back to the left one. + nextIdx := idx + if idx >= len(tm.sessions)-1 { + nextIdx = idx - 1 + } + // After removal, an element originally to the right of idx shifts left by + // one, landing at idx — so nextIdx stays correct when nextIdx == idx. + + tm.sessions = append(tm.sessions[:idx], tm.sessions[idx+1:]...) + tm.mu.Unlock() + + // Switch app state to the target session (rewires listeners). + tm.activateSession(nextIdx) + + // Stop all view components belonging to the closed session. + // Clear() internally calls StackTop() which may redirect focus to + // intermediate components of the now-closed session. Restore focus + // explicitly afterwards. + sess.Content.Clear() + tm.container.RemovePage(tm.pageKey(sess.id)) + + // Re-establish focus on the new session's top component, which may have + // been overwritten by the PageStack.StackTop callbacks fired during Clear(). + if top := tm.app.Content.Top(); top != nil { + tm.app.SetFocus(top) + } + + tm.refreshTabBar() + return nil +} + +// CloseOtherTabs closes all tabs except the currently active one. +// Must be called on the tview main goroutine. +func (tm *TabManager) CloseOtherTabs() { + tm.mu.Lock() + if len(tm.sessions) <= 1 { + tm.mu.Unlock() + return + } + + activeSess := tm.sessions[tm.activeIdx] + var toClose []*TabSession + for i, sess := range tm.sessions { + if i != tm.activeIdx { + toClose = append(toClose, sess) + } + } + + tm.sessions = []*TabSession{activeSess} + tm.activeIdx = 0 + tm.mu.Unlock() + + for _, sess := range toClose { + sess.Content.Clear() + tm.container.RemovePage(tm.pageKey(sess.id)) + } + + // Re-establish focus on the new session's top component, which may have + // been overwritten by the PageStack.StackTop callbacks fired during Clear(). + if top := tm.app.Content.Top(); top != nil { + tm.app.SetFocus(top) + } + + tm.refreshTabBar() +} + +// SwitchTo activates the tab at the given zero-based slice index. +// Must be called on the tview main goroutine. +func (tm *TabManager) SwitchTo(idx int) { + tm.mu.RLock() + count := len(tm.sessions) + tm.mu.RUnlock() + + if idx < 0 || idx >= count { + return + } + tm.activateSession(idx) + tm.refreshTabBar() +} + +// NextTab activates the tab to the right, wrapping around. +func (tm *TabManager) NextTab() { + tm.mu.RLock() + count := len(tm.sessions) + cur := tm.activeIdx + tm.mu.RUnlock() + + if count <= 1 { + return + } + tm.SwitchTo((cur + 1) % count) +} + +// PrevTab activates the tab to the left, wrapping around. +func (tm *TabManager) PrevTab() { + tm.mu.RLock() + count := len(tm.sessions) + cur := tm.activeIdx + tm.mu.RUnlock() + + if count <= 1 { + return + } + tm.SwitchTo((cur - 1 + count) % count) +} + +// updateActiveLabel updates the label shown for the active tab. +// Must be called on the tview main goroutine. +func (tm *TabManager) updateActiveLabel(label string) { + tm.mu.Lock() + tm.sessions[tm.activeIdx].label = label + tm.mu.Unlock() + tm.refreshTabBar() +} + +// activateSession rewires the app's mutable state pointers (Content, command, +// histories) to the session at idx and brings its page to the front. +// Must be called on the tview main goroutine. +func (tm *TabManager) activateSession(idx int) { + app := tm.app + + tm.mu.Lock() + if idx < 0 || idx >= len(tm.sessions) { + tm.mu.Unlock() + return + } + newSess := tm.sessions[idx] + oldContent := app.Content + // Guard: if the target session is already active there is nothing to do. + if oldContent == newSess.Content { + tm.activeIdx = idx + tm.mu.Unlock() + return + } + tm.activeIdx = idx + tm.mu.Unlock() + + // Detach breadcrumbs and menu from the outgoing content so they no longer + // receive push/pop events from the tab we are leaving. + if oldContent != nil { + oldContent.RemoveListener(app.Crumbs()) + oldContent.RemoveListener(app.Menu()) + } + + // Swap the active-session pointers in App. All existing code paths that + // reference app.Content / app.command / app.cmdHistory / app.filterHistory + // automatically operate on the new tab from this point on. + app.Content = newSess.Content + app.command = newSess.command + app.cmdHistory = newSess.cmdHistory + app.filterHistory = newSess.filterHistory + + // Rebuild breadcrumbs to reflect the incoming tab's navigation history. + app.Crumbs().Reset(newSess.Content.Peek()) + + // Attach breadcrumbs and menu to the incoming content. + newSess.Content.AddListener(app.Crumbs()) + newSess.Content.AddListener(app.Menu()) + + // Bring the new session's page to the front of the container. + tm.container.SwitchToPage(tm.pageKey(newSess.id)) + + // Restart and focus the top-most component of the incoming session. + if top := newSess.Content.Top(); top != nil { + top.Start() + app.SetFocus(top) + } +} + +// refreshTabBar shows or hides the tab bar strip and re-renders its labels. +// Must be called on the tview main goroutine. +func (tm *TabManager) refreshTabBar() { + tm.mu.RLock() + count := len(tm.sessions) + active := tm.activeIdx + labels := make([]string, count) + for i, s := range tm.sessions { + labels[i] = s.label + } + tm.mu.RUnlock() + + showBar := count > 1 + switch { + case showBar && !tm.tabBarVisible: + tm.contentArea.AddItemAtIndex(0, tm.tabBar, 1, 1, false) + tm.tabBarVisible = true + case !showBar && tm.tabBarVisible: + tm.contentArea.RemoveItemAtIndex(0) + tm.tabBarVisible = false + } + if showBar { + tm.tabBar.Render(labels, active) + } +} + +func (tm *TabManager) pageKey(id int) string { + return fmt.Sprintf("tab-%d", id) +} + +// switchNS switches the namespace for all open sessions. +func (tm *TabManager) switchNS(ns string) { + tm.mu.RLock() + defer tm.mu.RUnlock() + + for _, sess := range tm.sessions { + sess.cmdHistory.SwitchNS(ns) + for _, c := range sess.Content.Peek() { + if rv, ok := c.(ResourceViewer); ok { + if namespaced, err := dao.MetaAccess.IsNamespaced(rv.GVR()); err == nil && !namespaced { + continue + } + if b, ok := rv.(*Browser); ok { + b.setNamespace(ns) + } else { + rv.GetTable().GetModel().SetNamespace(ns) + } + } + } + } +} diff --git a/internal/view/tab_session.go b/internal/view/tab_session.go new file mode 100644 index 0000000000..ec0e7edd8b --- /dev/null +++ b/internal/view/tab_session.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of K9s + +package view + +import "github.com/derailed/k9s/internal/model" + +// TabSession holds the isolated state for a single browser tab. +// Multiple sessions share the same factory, clusterModel and ui.App. +type TabSession struct { + id int + Content *PageStack + command *Command + cmdHistory *model.History + filterHistory *model.History + label string +}