diff --git a/example/main.go b/example/main.go index 827fb19..4754729 100644 --- a/example/main.go +++ b/example/main.go @@ -2,6 +2,7 @@ package main import ( "errors" + "flag" "fmt" "io" "os" @@ -11,7 +12,6 @@ import ( "fortio.org/cli" "fortio.org/log" "fortio.org/terminal" - "golang.org/x/term" ) func main() { @@ -30,13 +30,13 @@ var commands = []string{promptCmd, afterCmd, exitCmd, helpCmd, testMLCmd} // func(line string, pos int, key rune) (newLine string, newPos int, ok bool) -func autoCompleteCallback(line string, pos int, key rune) (newLine string, newPos int, ok bool) { +func autoCompleteCallback(t *terminal.Terminal, line string, pos int, key rune) (newLine string, newPos int, ok bool) { log.LogVf("AutoCompleteCallback: %q %d %q", line, pos, key) if key != '\t' { return // only tab for now } if len(line) == 0 { - log.Infof("Available commands: %v", commands) + fmt.Fprintf(t.Out, "Available commands: %v\n", commands) return } if pos != len(line) { @@ -56,7 +56,7 @@ func autoCompleteCallback(line string, pos int, key rune) (newLine string, newPo func Main() int { // Pending https://github.com/golang/go/issues/68780 - // flagHistory := flag.String("history", "", "History `file` to use") + flagHistory := flag.String("history", "/tmp/terminal_history", "History `file` to use") cli.Main() t, err := terminal.Open() if err != nil { @@ -65,6 +65,7 @@ func Main() int { defer t.Close() t.SetPrompt("Terminal demo> ") t.LoggerSetup() + t.SetHistoryFile(*flagHistory) fmt.Fprintf(t.Out, "Terminal is open\nis valid %t\nuse exit or ^D or ^C to exit\n", t.IsTerminal()) fmt.Fprintf(t.Out, "Use 'prompt ' to change the prompt\n") fmt.Fprintf(t.Out, "Try 'after duration text...' to see text showing in the middle of edits after said duration\n") @@ -78,8 +79,6 @@ func Main() int { case errors.Is(err, io.EOF): log.Infof("EOF received, exiting.") return 0 - case errors.Is(err, term.ErrPasteIndicator): - log.Infof("Paste indicator received, which is fine.") default: return log.FErrf("Error reading line: %v", err) } diff --git a/go.mod b/go.mod index 49251d6..efcb4bf 100644 --- a/go.mod +++ b/go.mod @@ -5,15 +5,13 @@ go 1.22.6 require ( fortio.org/cli v1.8.0 fortio.org/log v1.16.0 - golang.org/x/term v0.23.0 + fortio.org/term v0.23.0-fortio-1 ) -// replace fortio.org/cli => ../cli - require ( fortio.org/struct2env v0.4.1 // indirect fortio.org/version v1.0.4 // indirect github.com/kortschak/goroutine v1.1.2 // indirect - golang.org/x/crypto/x509roots/fallback v0.0.0-20240626151235-a6a393ffd658 // indirect - golang.org/x/sys v0.23.0 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20240806160748-b2d3a6a4b4d3 // indirect + golang.org/x/sys v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index 057d009..d9044ab 100644 --- a/go.sum +++ b/go.sum @@ -4,13 +4,17 @@ fortio.org/log v1.16.0 h1:GhU8/9NkYZmEIzvTN/DTMedDAStLJraWUUVUA2EbNDc= fortio.org/log v1.16.0/go.mod h1:t58Spg9njjymvRioh5F6qKGSupEsnMjXLGWIS1i3khE= fortio.org/struct2env v0.4.1 h1:rJludAMO5eBvpWplWEQNqoVDFZr4RWMQX7RUapgZyc0= fortio.org/struct2env v0.4.1/go.mod h1:lENUe70UwA1zDUCX+8AsO663QCFqYaprk5lnPhjD410= +fortio.org/term v0.23.0-fortio-1 h1:bBfsi9yGE5aI5FDnBxluIc7+7Gc2vWdgdckJeDYRGsc= +fortio.org/term v0.23.0-fortio-1/go.mod h1:7buBfn81wEJUGWiVjFNiUE/vxWs5FdM9c7PyZpZRS30= fortio.org/version v1.0.4 h1:FWUMpJ+hVTNc4RhvvOJzb0xesrlRmG/a+D6bjbQ4+5U= fortio.org/version v1.0.4/go.mod h1:2JQp9Ax+tm6QKiGuzR5nJY63kFeANcgrZ0osoQFDVm0= github.com/kortschak/goroutine v1.1.2 h1:lhllcCuERxMIK5cYr8yohZZScL1na+JM5JYPRclWjck= github.com/kortschak/goroutine v1.1.2/go.mod h1:zKpXs1FWN/6mXasDQzfl7g0LrGFIOiA6cLs9eXKyaMY= golang.org/x/crypto/x509roots/fallback v0.0.0-20240626151235-a6a393ffd658 h1:i7K6wQLN/0oxF7FT3tKkfMCstxoT4VGG36YIB9ZKLzI= golang.org/x/crypto/x509roots/fallback v0.0.0-20240626151235-a6a393ffd658/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8= +golang.org/x/crypto/x509roots/fallback v0.0.0-20240806160748-b2d3a6a4b4d3 h1:oWb21rU9Q9XrRwXLB7jHc1rbp6EiiimZZv5MLxpu4T0= +golang.org/x/crypto/x509roots/fallback v0.0.0-20240806160748-b2d3a6a4b4d3/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8= golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= -golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/terminal.go b/terminal.go index e9ed945..f31a5f3 100644 --- a/terminal.go +++ b/terminal.go @@ -2,18 +2,21 @@ package terminal // import "fortio.org/terminal" import ( + "bufio" + "errors" "io" "os" "fortio.org/log" - "golang.org/x/term" + "fortio.org/term" ) type Terminal struct { - fd int - oldState *term.State - term *term.Terminal - Out io.Writer + fd int + oldState *term.State + term *term.Terminal + Out io.Writer + historyFile string } // CRWriter is a writer that adds \r before each \n. @@ -100,6 +103,66 @@ func (t *Terminal) LoggerSetup() { log.SetColorMode() } +func (t *Terminal) SetHistoryFile(f string) { + if !t.IsTerminal() { + log.Infof("Not a terminal, not setting history file") + return + } + t.historyFile = f + entries := readOrCreateHistory(f) + for _, e := range entries { + t.term.AddToHistory(e) + } + log.Infof("Loaded %d history entries from %s", len(entries), f) +} + +func readOrCreateHistory(f string) []string { + if f == "" { + log.Infof("No history file specified") + return nil + } + // open file or create it + h, err := os.OpenFile(f, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + log.Errf("Error opening history file %s: %v", f, err) + return nil + } + defer h.Close() + // read lines separated by \n + var lines []string + scanner := bufio.NewScanner(h) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + log.Errf("Error reading history file %s: %v", f, err) + return nil + } + return lines +} + +func saveHistory(f string, h []string) { + if f == "" { + log.Infof("No history file specified") + return + } + // open file or create it + hf, err := os.OpenFile(f, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + log.Errf("Error opening history file %s: %v", f, err) + return + } + defer hf.Close() + // write lines separated by \n + for _, l := range h { + _, err := hf.WriteString(l + "\n") + if err != nil { + log.Errf("Error writing history file %s: %v", f, err) + return + } + } +} + func (t *Terminal) Close() error { if t.oldState == nil { return nil @@ -107,19 +170,35 @@ func (t *Terminal) Close() error { err := term.Restore(t.fd, t.oldState) t.oldState = nil t.Out = os.Stderr + // saving history if any + if t.historyFile != "" { + h := t.term.History() + log.Infof("Saving history (%d commands) to %s", len(h), t.historyFile) + saveHistory(t.historyFile, h) + } return err } func (t *Terminal) ReadLine() (string, error) { - return t.term.ReadLine() + c, err := t.term.ReadLine() + // That error isn't an error that needs to be propagated, + // it's just to allow copy/paste without autocomplete. + if errors.Is(err, term.ErrPasteIndicator) { + return c, nil + } + return c, err } func (t *Terminal) SetPrompt(s string) { t.term.SetPrompt(s) } -type AutoCompleteCallback func(line string, pos int, key rune) (newLine string, newPos int, ok bool) +// Pass "this" back so AutoCompleteCallback can use t.Out etc. +// (compared to the original x/term callback). +type AutoCompleteCallback func(t *Terminal, line string, pos int, key rune) (newLine string, newPos int, ok bool) func (t *Terminal) SetAutoCompleteCallback(f AutoCompleteCallback) { - t.term.AutoCompleteCallback = f + t.term.AutoCompleteCallback = func(line string, pos int, key rune) (newLine string, newPos int, ok bool) { + return f(t, line, pos, key) + } }