From daa903e6c840d8b96f0d0b3642fd93f4eb4b12c8 Mon Sep 17 00:00:00 2001 From: Tyler Hawkins <3319104+tyzbit@users.noreply.github.com> Date: Tue, 24 Sep 2024 17:36:00 -0400 Subject: [PATCH] feat(history): add keyboard shortcuts to navigate history --- internal/model/history.go | 98 +++++++++++++++++++++++---------- internal/ui/key.go | 12 ++-- internal/view/actions.go | 2 +- internal/view/alias.go | 2 +- internal/view/app.go | 83 ++++++++++++++-------------- internal/view/command.go | 32 ++++++----- internal/view/crd.go | 2 +- internal/view/ns.go | 2 +- internal/view/owner_extender.go | 3 +- internal/view/pulse.go | 2 +- internal/view/reference.go | 2 +- internal/view/sanitizer.go | 2 +- internal/view/workload.go | 2 +- internal/view/xray.go | 2 +- 14 files changed, 145 insertions(+), 101 deletions(-) diff --git a/internal/model/history.go b/internal/model/history.go index adb4df6c5a..37c4c5de12 100644 --- a/internal/model/history.go +++ b/internal/model/history.go @@ -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,31 +25,76 @@ func NewHistory(limit int) *History { } } -// Last returns the most recent history item -func (h *History) Last() 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 } -// Pop removes the most recent history item and returns a bool if the list changed. -// Optional argument specifies how many to remove from the history -func (h *History) Pop(n ...int) bool { - if len(h.commands) == 0 { +// 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 } - count := 1 - if len(n) > 1 { - // only one argument is expected + 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 - } else if len(n) == 1 { - count = n[0] } - h.commands = h.commands[count:] + h.commands = h.commands[:cmdLength-n] return true } @@ -63,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 -} diff --git a/internal/ui/key.go b/internal/ui/key.go index 1f36b9d45a..d6a506d099 100644 --- a/internal/ui/key.go +++ b/internal/ui/key.go @@ -76,11 +76,13 @@ const ( KeyX KeyY KeyZ - KeyHelp = 63 - KeySlash = 47 - KeyColon = 58 - KeySpace = 32 - KeyDash = 45 //or minus for those searching in the code + 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. diff --git a/internal/view/actions.go b/internal/view/actions.go index d86d0098ad..fa4cf6ad0f 100644 --- a/internal/view/actions.go +++ b/internal/view/actions.go @@ -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 } } diff --git a/internal/view/alias.go b/internal/view/alias.go index 496f2e5c73..9ca8a79e61 100644 --- a/internal/view/alias.go +++ b/internal/view/alias.go @@ -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 } diff --git a/internal/view/app.go b/internal/view/app.go index 23d6248b45..80ffe4a454 100644 --- a/internal/view/app.go +++ b/internal/view/app.go @@ -240,15 +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), - ui.KeyB: ui.NewSharedKeyAction("Go Back", a.previousView, false), - ui.KeyDash: ui.NewSharedKeyAction("Last View", a.lastView, 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), })) } @@ -484,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) } @@ -632,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 } @@ -693,51 +694,49 @@ func (a *App) helpCmd(evt *tcell.EventKey) *tcell.EventKey { return nil } -// previousView returns to the view prior to the current one in the history -func (a *App) previousView(evt *tcell.EventKey) *tcell.EventKey { - if evt != nil && evt.Rune() == rune(ui.KeyB) && a.Prompt().InCmdMode() { +// 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 !(len(cmds) > 1) { - dialog.ShowError( - a.Styles.Dialog(), - a.Content.Pages, - "Can't go back any further") + if !a.cmdHistory.Back() { + a.App.Flash().Warn("Can't go back any further") return evt - } else { - previousCmd := cmds[1] - a.cmdHistory.Pop() - a.gotoResource(previousCmd, "", true) - a.ResetCmd() } + 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 } -// lastView switches between the last view and this one a la `cd -` -func (a *App) lastView(evt *tcell.EventKey) *tcell.EventKey { +// 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) { - dialog.ShowError( - a.Styles.Dialog(), - a.Content.Pages, - "No previous view to switch to") + if len(cmds) < 1 { + a.App.Flash().Warn("No previous view to switch to") return evt } else { - current := cmds[0] - last := cmds[1] - a.gotoResource(last, "", true) - // remove current page and last page - a.cmdHistory.Pop(2) - // re add in opposite order - a.cmdHistory.Push(current) - a.cmdHistory.Push(last) + a.cmdHistory.Last() + a.gotoResource(cmds[a.cmdHistory.CurrentIndex()], "", true, false) } - return nil } @@ -754,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()) } diff --git a/internal/view/command.go b/internal/view/command.go index 22415a38bb..d92a7cb995 100644 --- a/internal/view/command.go +++ b/internal/view/command.go @@ -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,22 +206,22 @@ 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() 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) } p := cmd.NewInterpreter(c.app.Config.ActiveView()) if p.IsBlank() { - return c.run(p.Reset("pod"), "", true) + return c.run(p.Reset("pod"), "", true, true) } - if err := c.run(p, "", true); err != nil { + if err := c.run(p, "", true, true); err != nil { log.Error().Err(err).Msgf("Default run command failed %q", p.GetLine()) - return c.run(p.Reset("pod"), "", true) + return c.run(p.Reset("pod"), "", true, true) } return nil @@ -310,7 +310,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) @@ -319,10 +319,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.Last(); 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) } }() @@ -338,7 +340,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 } diff --git a/internal/view/crd.go b/internal/view/crd.go index 7ff1a1f969..1a70f5b650 100644 --- a/internal/view/crd.go +++ b/internal/view/crd.go @@ -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) } diff --git a/internal/view/ns.go b/internal/view/ns.go index 1eac09dbef..ffde8a42bf 100644 --- a/internal/view/ns.go +++ b/internal/view/ns.go @@ -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 { diff --git a/internal/view/owner_extender.go b/internal/view/owner_extender.go index 89b42449ec..0095c52d0e 100644 --- a/internal/view/owner_extender.go +++ b/internal/view/owner_extender.go @@ -3,6 +3,7 @@ package view import ( "context" "fmt" + "github.com/derailed/k9s/internal/ui/dialog" "github.com/rs/zerolog/log" @@ -105,7 +106,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 } diff --git a/internal/view/pulse.go b/internal/view/pulse.go index 6d36e7a5fd..ac75714820 100644 --- a/internal/view/pulse.go +++ b/internal/view/pulse.go @@ -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 } diff --git a/internal/view/reference.go b/internal/view/reference.go index 2f08cc7bba..4da57c6cf7 100644 --- a/internal/view/reference.go +++ b/internal/view/reference.go @@ -55,7 +55,7 @@ func (r *Reference) gotoCmd(evt *tcell.EventKey) *tcell.EventKey { path := r.GetTable().GetSelectedItem() gvr := ui.TrimCell(r.GetTable().SelectTable, row, 2) - r.App().gotoResource(client.NewGVR(gvr).R(), path, false) + r.App().gotoResource(client.NewGVR(gvr).R(), path, false, true) return evt } diff --git a/internal/view/sanitizer.go b/internal/view/sanitizer.go index 9ec116d73e..100e3f8262 100644 --- a/internal/view/sanitizer.go +++ b/internal/view/sanitizer.go @@ -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 } diff --git a/internal/view/workload.go b/internal/view/workload.go index 4f0ba02fcd..b238b53d9b 100644 --- a/internal/view/workload.go +++ b/internal/view/workload.go @@ -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 { diff --git a/internal/view/xray.go b/internal/view/xray.go index 34e9e0ccad..1c5429dd00 100644 --- a/internal/view/xray.go +++ b/internal/view/xray.go @@ -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 }