diff --git a/tools/cmd/at/shell.go b/tools/cmd/at/shell.go index 61f3873e4..c894085dd 100644 --- a/tools/cmd/at/shell.go +++ b/tools/cmd/at/shell.go @@ -8,7 +8,9 @@ import ( "io" "os" "os/exec" + "path/filepath" "strings" + "time" "github.com/google/shlex" @@ -16,6 +18,7 @@ import ( "kitty/tools/cli/markup" "kitty/tools/tui/loop" "kitty/tools/tui/readline" + "kitty/tools/utils" ) var _ = fmt.Print @@ -111,7 +114,7 @@ func print_basic_help() { fmt.Println(" ", "Exit this shell") } -func exec_command(cmdline string) bool { +func exec_command(rl *readline.Readline, cmdline string) bool { parsed_cmdline, err := shlex.Split(cmdline) if err != nil { fmt.Fprintln(os.Stderr, "Could not parse cmdline:", err) @@ -120,10 +123,15 @@ func exec_command(cmdline string) bool { if len(parsed_cmdline) == 0 { return true } + hi := readline.HistoryItem{Timestamp: time.Now(), Cmd: cmdline, ExitCode: -1} switch parsed_cmdline[0] { case "exit": + hi.ExitCode = 0 + rl.AddHistoryItem(hi) return false case "help": + hi.ExitCode = 0 + defer rl.AddHistoryItem(hi) if len(parsed_cmdline) == 1 { print_basic_help() return true @@ -137,6 +145,7 @@ func exec_command(cmdline string) bool { r := EntryPoint(cli.NewRootCommand()) sc := r.FindSubCommand(parsed_cmdline[1]) if sc == nil { + hi.ExitCode = 1 fmt.Fprintln(os.Stderr, "No command named", formatter.BrightRed(parsed_cmdline[1]), ". Type help for a list of commands") } else { sc.ShowHelpWithCommandString(sc.Name) @@ -156,9 +165,15 @@ func exec_command(cmdline string) bool { cmdline = append(cmdline, parsed_cmdline...) cmd := exec.Cmd{Path: exe, Args: cmdline, Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr} err = cmd.Run() + hi.Duration = time.Now().Sub(hi.Timestamp) + hi.ExitCode = 0 if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + hi.ExitCode = exitError.ExitCode() + } fmt.Fprintln(os.Stderr, err) } + rl.AddHistoryItem(hi) } return true } @@ -167,14 +182,17 @@ func shell_main(cmd *cli.Command, args []string) (int, error) { formatter = markup.New(true) fmt.Println("Welcome to the kitty shell!") fmt.Println("Use", formatter.Green("help"), "for assistance or", formatter.Green("exit"), "to quit.") - rl := readline.New(nil, readline.RlInit{Prompt: prompt}) + rl := readline.New(nil, readline.RlInit{Prompt: prompt, HistoryPath: filepath.Join(utils.CacheDir(), "shell.history.json")}) + defer func() { + rl.Shutdown() + }() for { rc, err := shell_loop(rl, true) if err != nil { if err == ErrExec { cmdline := rl.AllText() cmdline = strings.ReplaceAll(cmdline, "\\\n", "") - if !exec_command(cmdline) { + if !exec_command(rl, cmdline) { return 0, nil } continue diff --git a/tools/tui/readline/api.go b/tools/tui/readline/api.go index e3bb5db91..f2e900493 100644 --- a/tools/tui/readline/api.go +++ b/tools/tui/readline/api.go @@ -17,6 +17,8 @@ const PROMPT_MARK = "\x1b]133;" type RlInit struct { Prompt string + HistoryPath string + HistoryCount int ContinuationPrompt string EmptyContinuationPrompt bool DontMarkPrompts bool @@ -54,6 +56,7 @@ type Readline struct { continuation_prompt_len int mark_prompts bool loop *loop.Loop + history *History // The number of lines after the initial line on the screen cursor_y int @@ -65,9 +68,13 @@ type Readline struct { } func New(loop *loop.Loop, r RlInit) *Readline { + hc := r.HistoryCount + if hc == 0 { + hc = 8192 + } ans := &Readline{ prompt: r.Prompt, prompt_len: wcswidth.Stringwidth(r.Prompt), mark_prompts: !r.DontMarkPrompts, - loop: loop, lines: []string{""}, + loop: loop, lines: []string{""}, history: NewHistory(r.HistoryPath, hc), } if r.ContinuationPrompt != "" || !r.EmptyContinuationPrompt { ans.continuation_prompt = r.ContinuationPrompt @@ -83,6 +90,14 @@ func New(loop *loop.Loop, r RlInit) *Readline { return ans } +func (self *Readline) Shutdown() { + self.history.Shutdown() +} + +func (self *Readline) AddHistoryItem(hi HistoryItem) { + self.history.add_item(hi) +} + func (self *Readline) ChangeLoopAndResetText(lp *loop.Loop) { self.loop = lp self.lines = []string{""} diff --git a/tools/tui/readline/history.go b/tools/tui/readline/history.go new file mode 100644 index 000000000..61ddc58ba --- /dev/null +++ b/tools/tui/readline/history.go @@ -0,0 +1,149 @@ +// License: GPLv3 Copyright: 2022, Kovid Goyal, + +package readline + +import ( + "encoding/json" + "fmt" + "io" + "os" + "time" + + "kitty/tools/utils" +) + +var _ = fmt.Print + +type HistoryItem struct { + Cmd string `json:"cmd"` + Timestamp time.Time `json:"timestamp"` + Duration time.Duration `json:"duration"` + ExitCode int `json:"exit_code"` +} + +type History struct { + file_path string + file *os.File + max_items int + items []HistoryItem + cmd_map map[string]int +} + +func map_from_items(items []HistoryItem) map[string]int { + pmap := make(map[string]int, len(items)) + for i, hi := range items { + pmap[hi.Cmd] = i + } + return pmap +} + +func (self *History) add_item(x HistoryItem) bool { + existing, found := self.cmd_map[x.Cmd] + if found { + if self.items[existing].Timestamp.Before(x.Timestamp) { + self.items[existing] = x + return true + } + return false + } + self.cmd_map[x.Cmd] = len(self.items) + self.items = append(self.items, x) + return true +} + +func (self *History) merge_items(items ...HistoryItem) { + if len(self.items) == 0 { + self.items = items + self.cmd_map = map_from_items(self.items) + return + } + if len(items) == 0 { + return + } + changed := false + for _, x := range items { + if self.add_item(x) { + changed = true + } + } + if !changed { + return + } + self.items = utils.StableSort(self.items, func(a, b HistoryItem) bool { + return a.Timestamp.Before(b.Timestamp) + }) + if len(self.items) > self.max_items { + self.items = self.items[len(self.items)-self.max_items:] + } + self.cmd_map = map_from_items(self.items) +} + +func (self *History) Write() { + if self.file == nil { + return + } + self.file.Seek(0, 0) + utils.LockFileExclusive(self.file) + defer utils.UnlockFile(self.file) + data, err := io.ReadAll(self.file) + if err != nil { + return + } + var items []HistoryItem + err = json.Unmarshal(data, &items) + if err != nil { + self.merge_items(items...) + } + ndata, err := json.MarshalIndent(self.items, "", " ") + if err != nil { + return + } + self.file.Truncate(int64(len(ndata))) + self.file.Seek(0, 0) + self.file.Write(ndata) +} + +func (self *History) Read() { + if self.file == nil { + return + } + self.file.Seek(0, 0) + utils.LockFileShared(self.file) + data, err := io.ReadAll(self.file) + utils.UnlockFile(self.file) + if err != nil { + return + } + var items []HistoryItem + err = json.Unmarshal(data, &items) + if err != nil { + self.merge_items(items...) + } +} + +func (self *History) AddItem(cmd string, duration time.Duration) { + self.merge_items(HistoryItem{Cmd: cmd, Duration: duration, Timestamp: time.Now()}) +} + +func (self *History) Shutdown() { + if self.file != nil { + self.Write() + self.file.Close() + self.file = nil + } +} + +func NewHistory(path string, max_items int) *History { + ans := History{items: []HistoryItem{}, cmd_map: map[string]int{}, max_items: max_items} + if path != "" { + ans.file_path = path + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if err == nil { + ans.file = f + } else { + fmt.Fprintln(os.Stderr, "Failed to open history file at:", path, "with error:", err) + } + } + ans.Read() + return &ans +} diff --git a/tools/utils/paths.go b/tools/utils/paths.go index f5318d1ba..02f464199 100644 --- a/tools/utils/paths.go +++ b/tools/utils/paths.go @@ -102,6 +102,28 @@ func ConfigDir() string { return config_dir } +var cache_dir string + +func CacheDir() string { + if cache_dir != "" { + return cache_dir + } + candidate := "" + if edir := os.Getenv("KITTY_CACHE_DIRECTORY"); edir != "" { + candidate = Abspath(Expanduser(edir)) + } else if runtime.GOOS == "darwin" { + candidate = Expanduser("~/Library/Caches/kitty") + } else { + candidate = os.Getenv("XDG_CACHE_HOME") + if candidate == "" { + candidate = "~/.cache" + } + candidate = filepath.Join(Expanduser(candidate), "kitty") + } + os.MkdirAll(candidate, 0o755) + return candidate +} + type Walk_callback func(path, abspath string, d fs.DirEntry, err error) error func transform_symlink(path string) string {