feat(app): add history navigation with `[` and `]`, most recent command with `-` (#2799)

* feat(app): add Go Back and Last View

Go Back walks back through the history until at the oldest saved view

Last View switches between the current and previous views like how "cd -" works

* feat(history): add keyboard shortcuts to navigate history

* fix(tests): fix history/app tests

* docs(README): update history and last command navigation info

* fix(internal): add missing parameter

* feat(help): add history keybinds

* fix(help): adjust capitalization for history commands

* docs(readme): fix typo

Co-authored-by: merusso <merusso@gmail.com>

---------

Co-authored-by: merusso <merusso@gmail.com>
mine
tyzbit 2025-02-15 19:40:29 -05:00 committed by GitHub
parent 4acf9384d9
commit cc1c86ccb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 189 additions and 61 deletions

View File

@ -362,6 +362,8 @@ K9s uses aliases to navigate most K8s resources.
| To view and switch to another Kubernetes context (Pod view) | `:`ctx⏎ | |
| To view and switch directly to another Kubernetes context (Last used view) | `:`ctx context-name⏎ | |
| To view and switch to another Kubernetes namespace | `:`ns⏎ | |
| To switch back to the last active command (like how "cd -" works) | `-` | Navigation that adds breadcrumbs to the bottom are not commands |
| To go back and forward through the command history | back: `[`, forward: `]` | Same as above |
| To view all saved resources | `:`screendump or sd⏎ | |
| To delete a resource (TAB and ENTER to confirm) | `ctrl-d` | |
| To kill a resource (no confirmation dialog, equivalent to kubectl delete --now) | `ctrl-k` | |

View File

@ -12,8 +12,10 @@ const MaxHistory = 20
// History represents a command history.
type History struct {
commands []string
limit int
commands []string
limit int
activeCommandIndex int
previousCommandIndex int
}
// NewHistory returns a new instance.
@ -23,12 +25,77 @@ func NewHistory(limit int) *History {
}
}
func (h *History) Pop() string {
// Last switches the current and previous history index positions so the
// new command referenced by the index is the previous command
func (h *History) Last() bool {
if h.Empty() {
return ""
return false
}
return h.commands[0]
h.activeCommandIndex, h.previousCommandIndex = h.previousCommandIndex, h.activeCommandIndex
return true
}
// Back moves the history position index back by one
func (h *History) Back() bool {
if h.Empty() {
return false
}
// Return if there are no more commands left in the backward history
if h.activeCommandIndex == 0 {
return false
}
h.previousCommandIndex = h.activeCommandIndex
h.activeCommandIndex = h.activeCommandIndex - 1
return true
}
// Forward moves the history position index forward by one
func (h *History) Forward() bool {
if h.Empty() {
return false
}
// Return if there are no more commands left in the forward history
if h.activeCommandIndex >= len(h.commands)-1 {
return false
}
h.previousCommandIndex = h.activeCommandIndex
h.activeCommandIndex = h.activeCommandIndex + 1
return true
}
// CurrentIndex returns the current index of the active command in the history
func (h *History) CurrentIndex() int {
return h.activeCommandIndex
}
// PreviousIndex returns the index of the command that was the most recent
// active command in the history
func (h *History) PreviousIndex() int {
return h.previousCommandIndex
}
// Pop removes the single most recent history item
// and returns a bool if the list changed.
func (h *History) Pop() bool {
return h.PopN(1)
}
// PopN removes the N most recent history item
// and returns a bool if the list changed.
// Argument specifies how many to remove from the history
func (h *History) PopN(n int) bool {
cmdLength := len(h.commands)
if cmdLength == 0 {
return false
}
h.commands = h.commands[:cmdLength-n]
return true
}
// List returns the current command history.
@ -43,31 +110,22 @@ func (h *History) Push(c string) {
}
c = strings.ToLower(c)
if i := h.indexOf(c); i != -1 {
return
}
if len(h.commands) < h.limit {
h.commands = append([]string{c}, h.commands...)
h.commands = append(h.commands, c)
h.previousCommandIndex = h.activeCommandIndex
h.activeCommandIndex = len(h.commands) - 1
return
}
h.commands = append([]string{c}, h.commands[:len(h.commands)-1]...)
}
// Clear clears out the stack.
func (h *History) Clear() {
h.commands = nil
h.activeCommandIndex = 0
h.previousCommandIndex = 0
}
// Empty returns true if no history.
func (h *History) Empty() bool {
return len(h.commands) == 0
}
func (h *History) indexOf(s string) int {
for i, c := range h.commands {
if c == s {
return i
}
}
return -1
}

View File

@ -17,7 +17,7 @@ func TestHistory(t *testing.T) {
h.Push(fmt.Sprintf("cmd%d", i))
}
assert.Equal(t, []string{"cmd4", "cmd3", "cmd2"}, h.List())
assert.Equal(t, []string{"cmd1", "cmd2", "cmd3"}, h.List())
h.Clear()
assert.True(t, h.Empty())
}
@ -30,5 +30,5 @@ func TestHistoryDups(t *testing.T) {
h.Push("cmd1")
h.Push("")
assert.Equal(t, []string{"cmd3", "cmd2", "cmd1"}, h.List())
assert.Equal(t, []string{"cmd1", "cmd2", "cmd3"}, h.List())
}

View File

@ -76,10 +76,13 @@ const (
KeyX
KeyY
KeyZ
KeyHelp = 63
KeySlash = 47
KeyColon = 58
KeySpace = 32
KeyHelp = 63
KeySlash = 47
KeyColon = 58
KeySpace = 32
KeyDash = 45 //or minus for those searching in the code
KeyLeftBracket = 91
KeyRightBracket = 93
)
// Define Shift Keys.

View File

@ -104,7 +104,7 @@ func hotKeyActions(r Runner, aa *ui.KeyActions) error {
func gotoCmd(r Runner, cmd, path string, clearStack bool) ui.ActionHandler {
return func(evt *tcell.EventKey) *tcell.EventKey {
r.App().gotoResource(cmd, path, clearStack)
r.App().gotoResource(cmd, path, clearStack, true)
return nil
}
}

View File

@ -67,7 +67,7 @@ func (a *Alias) gotoCmd(evt *tcell.EventKey) *tcell.EventKey {
if r != 0 {
s := ui.TrimCell(a.GetTable().SelectTable, r, 1)
tokens := strings.Split(s, ",")
a.App().gotoResource(tokens[0], "", true)
a.App().gotoResource(tokens[0], "", true, true)
return nil
}

View File

@ -240,13 +240,16 @@ func (a *App) keyboard(evt *tcell.EventKey) *tcell.EventKey {
func (a *App) bindKeys() {
a.AddActions(ui.NewKeyActionsFromMap(ui.KeyMap{
ui.KeyShift9: ui.NewSharedKeyAction("DumpGOR", a.dumpGOR, false),
tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false),
tcell.KeyCtrlG: ui.NewSharedKeyAction("toggleCrumbs", a.toggleCrumbsCmd, false),
ui.KeyHelp: ui.NewSharedKeyAction("Help", a.helpCmd, false),
tcell.KeyCtrlA: ui.NewSharedKeyAction("Aliases", a.aliasCmd, false),
tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, false),
tcell.KeyCtrlC: ui.NewKeyAction("Quit", a.quitCmd, false),
ui.KeyShift9: ui.NewSharedKeyAction("DumpGOR", a.dumpGOR, false),
tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false),
tcell.KeyCtrlG: ui.NewSharedKeyAction("toggleCrumbs", a.toggleCrumbsCmd, false),
ui.KeyHelp: ui.NewSharedKeyAction("Help", a.helpCmd, false),
ui.KeyLeftBracket: ui.NewSharedKeyAction("Go Back", a.previousCommand, false),
ui.KeyRightBracket: ui.NewSharedKeyAction("Go Forward", a.nextCommand, false),
ui.KeyDash: ui.NewSharedKeyAction("Last View", a.lastCommand, false),
tcell.KeyCtrlA: ui.NewSharedKeyAction("Aliases", a.aliasCmd, false),
tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, false),
tcell.KeyCtrlC: ui.NewKeyAction("Quit", a.quitCmd, false),
}))
}
@ -482,7 +485,7 @@ func (a *App) switchContext(ci *cmd.Interpreter, force bool) error {
log.Debug().Msgf("--> Switching Context %q -- %q -- %q", name, ns, a.Config.ActiveView())
a.Flash().Infof("Switching context to %q::%q", name, ns)
a.ReloadStyles()
a.gotoResource(a.Config.ActiveView(), "", true)
a.gotoResource(a.Config.ActiveView(), "", true, true)
a.clusterModel.Reset(a.factory)
}
@ -630,7 +633,7 @@ func (a *App) toggleCrumbsCmd(evt *tcell.EventKey) *tcell.EventKey {
func (a *App) gotoCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.CmdBuff().IsActive() && !a.CmdBuff().Empty() {
a.gotoResource(a.GetCmd(), "", true)
a.gotoResource(a.GetCmd(), "", true, true)
a.ResetCmd()
return nil
}
@ -691,6 +694,52 @@ func (a *App) helpCmd(evt *tcell.EventKey) *tcell.EventKey {
return nil
}
// previousCommand returns to the command prior to the current one in the history
func (a *App) previousCommand(evt *tcell.EventKey) *tcell.EventKey {
if evt != nil && evt.Rune() == rune(ui.KeyLeftBracket) && a.Prompt().InCmdMode() {
return evt
}
cmds := a.cmdHistory.List()
if !a.cmdHistory.Back() {
a.App.Flash().Warn("Can't go back any further")
return evt
}
a.gotoResource(cmds[a.cmdHistory.CurrentIndex()], "", true, false)
return nil
}
// nextCommand returns to the command subsequent to the current one in the history
func (a *App) nextCommand(evt *tcell.EventKey) *tcell.EventKey {
if evt != nil && evt.Rune() == rune(ui.KeyRightBracket) && a.Prompt().InCmdMode() {
return evt
}
cmds := a.cmdHistory.List()
if !a.cmdHistory.Forward() {
a.App.Flash().Warn("Can't go forward any further")
return evt
}
// We go to the resource before updating the history so that
// gotoResource doesn't add this command to the history
a.gotoResource(cmds[a.cmdHistory.CurrentIndex()], "", true, false)
return nil
}
// lastCommand switches between the last command and the current one a la `cd -`
func (a *App) lastCommand(evt *tcell.EventKey) *tcell.EventKey {
if evt != nil && evt.Rune() == ui.KeyDash && a.Prompt().InCmdMode() {
return evt
}
cmds := a.cmdHistory.List()
if len(cmds) < 1 {
a.App.Flash().Warn("No previous view to switch to")
return evt
} else {
a.cmdHistory.Last()
a.gotoResource(cmds[a.cmdHistory.CurrentIndex()], "", true, false)
}
return nil
}
func (a *App) aliasCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.Content.Top() != nil && a.Content.Top().Name() == aliasTitle {
a.Content.Pop()
@ -704,8 +753,8 @@ func (a *App) aliasCmd(evt *tcell.EventKey) *tcell.EventKey {
return nil
}
func (a *App) gotoResource(c, path string, clearStack bool) {
err := a.command.run(cmd.NewInterpreter(c), path, clearStack)
func (a *App) gotoResource(c, path string, clearStack bool, pushCmd bool) {
err := a.command.run(cmd.NewInterpreter(c), path, clearStack, pushCmd)
if err != nil {
dialog.ShowError(a.Styles.Dialog(), a.Content.Pages, err.Error())
}

View File

@ -15,5 +15,5 @@ func TestAppNew(t *testing.T) {
a := view.NewApp(mock.NewMockConfig())
_ = a.Init("blee", 10)
assert.Equal(t, 12, a.GetActions().Len())
assert.Equal(t, 15, a.GetActions().Len())
}

View File

@ -98,7 +98,7 @@ func (c *Command) contextCmd(p *cmd.Interpreter) error {
return err
}
return c.exec(p, gvr, c.componentFor(gvr, ct, v), true)
return c.exec(p, gvr, c.componentFor(gvr, ct, v), true, true)
}
func (c *Command) namespaceCmd(p *cmd.Interpreter) bool {
@ -121,7 +121,7 @@ func (c *Command) aliasCmd(p *cmd.Interpreter) error {
v := NewAlias(gvr)
v.SetFilter(filter)
return c.exec(p, gvr, v, false)
return c.exec(p, gvr, v, false, true)
}
func (c *Command) xrayCmd(p *cmd.Interpreter) error {
@ -147,11 +147,11 @@ func (c *Command) xrayCmd(p *cmd.Interpreter) error {
return err
}
return c.exec(p, client.NewGVR("xrays"), NewXray(gvr), true)
return c.exec(p, client.NewGVR("xrays"), NewXray(gvr), true, true)
}
// Run execs the command by showing associated display.
func (c *Command) run(p *cmd.Interpreter, fqn string, clearStack bool) error {
func (c *Command) run(p *cmd.Interpreter, fqn string, clearStack bool, pushCmd bool) error {
if c.specialCmd(p) {
return nil
}
@ -206,12 +206,12 @@ func (c *Command) run(p *cmd.Interpreter, fqn string, clearStack bool) error {
co.SetLabelFilter(ll)
}
return c.exec(p, gvr, co, clearStack)
return c.exec(p, gvr, co, clearStack, pushCmd)
}
func (c *Command) defaultCmd(isRoot bool) error {
if c.app.Conn() == nil || !c.app.Conn().ConnectionOK() {
return c.run(cmd.NewInterpreter("context"), "", true)
return c.run(cmd.NewInterpreter("context"), "", true, true)
}
defCmd := "pod"
@ -220,13 +220,13 @@ func (c *Command) defaultCmd(isRoot bool) error {
}
p := cmd.NewInterpreter(c.app.Config.ActiveView())
if p.IsBlank() {
return c.run(p.Reset(defCmd), "", true)
return c.run(p.Reset(defCmd), "", true, true)
}
if err := c.run(p, "", true); err != nil {
if err := c.run(p, "", true, true); err != nil {
p = p.Reset(defCmd)
log.Error().Err(fmt.Errorf("Command failed. Using default command: %s", p.GetLine()))
return c.run(p, "", true)
return c.run(p, "", true, true)
}
return nil
@ -319,7 +319,7 @@ func (c *Command) componentFor(gvr client.GVR, fqn string, v *MetaViewer) Resour
return view
}
func (c *Command) exec(p *cmd.Interpreter, gvr client.GVR, comp model.Component, clearStack bool) (err error) {
func (c *Command) exec(p *cmd.Interpreter, gvr client.GVR, comp model.Component, clearStack bool, pushCmd bool) (err error) {
defer func() {
if e := recover(); e != nil {
log.Error().Msgf("Something bad happened! %#v", e)
@ -328,10 +328,12 @@ func (c *Command) exec(p *cmd.Interpreter, gvr client.GVR, comp model.Component,
log.Error().Msg(string(debug.Stack()))
p := cmd.NewInterpreter("pod")
if cmd := c.app.cmdHistory.Pop(); cmd != "" {
p = p.Reset(cmd)
cmds := c.app.cmdHistory.List()
currentCommand := cmds[c.app.cmdHistory.CurrentIndex()]
if currentCommand != "pod" {
p = p.Reset(currentCommand)
}
err = c.run(p, "", true)
err = c.run(p, "", true, true)
}
}()
@ -347,7 +349,9 @@ func (c *Command) exec(p *cmd.Interpreter, gvr client.GVR, comp model.Component,
return err
}
c.app.cmdHistory.Push(p.GetLine())
if pushCmd {
c.app.cmdHistory.Push(p.GetLine())
}
return
}

View File

@ -34,5 +34,5 @@ func (s *CRD) bindKeys(aa *ui.KeyActions) {
func (s *CRD) showCRD(app *App, _ ui.Tabular, _ client.GVR, path string) {
_, crd := client.Namespaced(path)
app.gotoResource(crd, "", false)
app.gotoResource(crd, "", false, true)
}

View File

@ -186,6 +186,18 @@ func (h *Help) showNav() model.MenuHints {
Mnemonic: "j",
Description: "Down",
},
{
Mnemonic: "[",
Description: "History Back",
},
{
Mnemonic: "]",
Description: "History Forward",
},
{
Mnemonic: "-",
Description: "Last Used Command",
},
}
}

View File

@ -41,7 +41,7 @@ func (n *Namespace) bindKeys(aa *ui.KeyActions) {
func (n *Namespace) switchNs(app *App, _ ui.Tabular, _ client.GVR, path string) {
n.useNamespace(path)
app.gotoResource("pods", "", false)
app.gotoResource("pods", "", false, true)
}
func (n *Namespace) useNsCmd(evt *tcell.EventKey) *tcell.EventKey {

View File

@ -107,7 +107,7 @@ func (v *OwnerExtender) jumpOwner(ns string, owner metav1.OwnerReference) error
ownerFQN = owner.Name
}
v.App().gotoResource(gvr.String(), ownerFQN, false)
v.App().gotoResource(gvr.String(), ownerFQN, false, true)
return nil
}

View File

@ -320,7 +320,7 @@ func (p *Pulse) enterCmd(evt *tcell.EventKey) *tcell.EventKey {
if res == "cpu" || res == "mem" {
res = "pod"
}
p.App().gotoResource(res+" all", "", false)
p.App().gotoResource(res+" all", "", false, true)
return nil
}

View File

@ -56,7 +56,7 @@ func (r *Reference) gotoCmd(evt *tcell.EventKey) *tcell.EventKey {
path := r.GetTable().GetSelectedItem()
ns, _ := client.Namespaced(path)
gvr := ui.TrimCell(r.GetTable().SelectTable, row, 2)
r.App().gotoResource(client.NewGVR(gvr).R()+" "+ns, path, false)
r.App().gotoResource(client.NewGVR(gvr).R()+" "+ns, path, false, true)
return evt
}

View File

@ -231,7 +231,7 @@ func (s *Sanitizer) gotoCmd(evt *tcell.EventKey) *tcell.EventKey {
if len(strings.Split(path, "/")) == 1 && spec.GVR() != "node" {
path = "-/" + path
}
s.app.gotoResource(client.NewGVR(spec.GVR()).R(), path, false)
s.app.gotoResource(client.NewGVR(spec.GVR()).R(), path, false, true)
return nil
}

View File

@ -82,7 +82,7 @@ func (w *Workload) showRes(app *App, _ ui.Tabular, _ client.GVR, path string) {
app.Flash().Err(fmt.Errorf("unable to parse path: %q", path))
return
}
app.gotoResource(gvr.R(), fqn, false)
app.gotoResource(gvr.R(), fqn, false, true)
}
func (w *Workload) deleteCmd(evt *tcell.EventKey) *tcell.EventKey {

View File

@ -476,7 +476,7 @@ func (x *Xray) gotoCmd(evt *tcell.EventKey) *tcell.EventKey {
if len(strings.Split(spec.Path(), "/")) == 1 {
return nil
}
x.app.gotoResource(client.NewGVR(spec.GVR()).R(), spec.Path(), false)
x.app.gotoResource(client.NewGVR(spec.GVR()).R(), spec.Path(), false, true)
return nil
}