Start work on history support for readline

This commit is contained in:
Kovid Goyal
2022-10-14 21:34:52 +05:30
parent fe91af5e09
commit 88567f69b2
4 changed files with 208 additions and 4 deletions

View File

@@ -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

View File

@@ -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{""}

View File

@@ -0,0 +1,149 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
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
}

View File

@@ -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 {