Compare commits
33 Commits
Author | SHA1 | Date |
---|---|---|
Albert S. | e1375f237c | |
Albert S. | ac9aab63bd | |
Albert S. | d0e07640e8 | |
Albert S. | 6d4e7a96dc | |
Albert S. | ebcdfa5ff4 | |
Albert S. | 0949ee422a | |
Albert S. | 3a6a1b2aa9 | |
Albert S. | 1a6c75cea9 | |
Albert S. | bb2450b8ec | |
lawl | 67050d32b9 | |
Albert S. | 33183dd7d3 | |
lawl | a8f35951f1 | |
lawl | b9e3a3629b | |
lawl | 16752411e1 | |
lawl | c480519fca | |
lawl | 70e66f47ba | |
Albert S. | 216e59747c | |
Albert S. | c206b07b2d | |
Albert S. | 3b01d7b898 | |
lawl | baa0d58a47 | |
Albert S. | 163429138c | |
Albert S. | 2af0c1d551 | |
Albert S. | 595595c4cb | |
Albert S. | 250af52750 | |
Albert S. | 88362e99a9 | |
lawl | 776dcebb04 | |
lawl | 1809905992 | |
lawl | 305e4300cc | |
lawl | b02911c9b4 | |
lawl | 8477ba5bfe | |
lawl | 54697be895 | |
Albert S. | da945dce2d | |
Albert S. | ee7a0868a8 |
|
@ -0,0 +1,6 @@
|
|||
Copyright 2021 lawl (github.com/lawl)
|
||||
Copyright 2021 Albert S. <hs9001 at quitesimple org>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
@ -0,0 +1,6 @@
|
|||
GIT_COMMIT=$(shell git rev-list -1 HEAD)
|
||||
GIT_TAG=$(shell git tag --sort="-version:refname" | head -n 1)
|
||||
all:
|
||||
go build -ldflags "-X main.GitCommit=${GIT_COMMIT} -X main.GitTag=${GIT_TAG}"
|
||||
|
||||
|
74
README.md
74
README.md
|
@ -1,40 +1,68 @@
|
|||
# hs9001
|
||||
hs90001 (history search 9001) is an easy, quite simple bash history enhancement. It simply writes all
|
||||
hs9001 (history search 9001) is an easy, quite simple bash history enhancement. It simply writes all
|
||||
your bash commands into an sqlite database. You can then search this database.
|
||||
|
||||
It improves over bash's built-in history mechanism as it'll aggregate the shell history of all open shells,
|
||||
timestamp them and also record additional information such as the directory a command was executed in.
|
||||
|
||||
## Setup
|
||||
## Usage / Examples
|
||||
### Search
|
||||
|
||||
```
|
||||
hs [search terms]
|
||||
```
|
||||
You can further filter with options like `-cwd`, `-after` and so on...
|
||||
For a full list, see `-help`.
|
||||
|
||||
```
|
||||
hs -cwd .
|
||||
```
|
||||
Lists all commands ever entered in this directory
|
||||
|
||||
```
|
||||
hs -today -cwd . git
|
||||
```
|
||||
Lists all git commands in the current directory which have been entered today.
|
||||
|
||||
Also, it (by default) replaces bash's built-in CTRL-R mechanism, so hs9001's database will be used instead of bash's limited history files.
|
||||
|
||||
When in reverse-search mode, you can only search the history of the current directory by pressing CTRL+A and then "w".
|
||||
|
||||
## Install
|
||||
|
||||
### Debian / Ubuntu
|
||||
Latest release can be installed using apt
|
||||
```
|
||||
curl -s https://repo.quitesimple.org/repo.quitesimple.org.asc | sudo apt-key add -
|
||||
echo "deb https://repo.quitesimple.org/debian/ default main" | sudo tee /etc/apt/sources.list.d/quitesimple.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install hs9001
|
||||
```
|
||||
|
||||
### Alpine
|
||||
```
|
||||
wget https://repo.quitesimple.org/repo%40quitesimple.org-5f3d101.rsa.pub -O /etc/apk/repo@quitesimple.org-5f3d101.rsa.pub
|
||||
echo "https://repo.quitesimple.org/alpine/quitesimple/" >> /etc/apk/repositories
|
||||
apk update
|
||||
apk add hs9001
|
||||
```
|
||||
|
||||
### From source
|
||||
```
|
||||
go build
|
||||
#move hs9001 to a PATH location
|
||||
# Initialize database
|
||||
hs9001 init
|
||||
````
|
||||
|
||||
```
|
||||
Add this to .bashrc
|
||||
|
||||
```
|
||||
if [ -n "$PS1" ] ; then
|
||||
PROMPT_COMMAND='hs9001 -ret $? add "$(history 1)"'
|
||||
fi
|
||||
eval "$(hs9001 bash-enable)"
|
||||
```
|
||||
By default, every system user gets his own database. You can override this by overriding the environment variable
|
||||
for all users that should write to your unified database.
|
||||
|
||||
This will also create a `hs`alias so you have to type less in everyday usage.
|
||||
|
||||
By default, every system user gets his own database. You can override this by setting the environment variable for all users that should write to your unified database.
|
||||
```
|
||||
export HS9001_DB_PATH="/home/db/history.sqlite"
|
||||
```
|
||||
|
||||
## Usage
|
||||
### Search
|
||||
|
||||
```
|
||||
hs9001 search "term"
|
||||
```
|
||||
|
||||
It is recommended to create an alias for search to make life easier, e. g.:
|
||||
|
||||
```
|
||||
alias searchh='hs9001 search'
|
||||
```
|
||||
|
||||
|
|
6
go.mod
6
go.mod
|
@ -2,4 +2,8 @@ module hs9001
|
|||
|
||||
go 1.16
|
||||
|
||||
require modernc.org/sqlite v1.10.0 // indirect
|
||||
require (
|
||||
github.com/mattn/go-runewidth v0.0.13
|
||||
github.com/tj/go-naturaldate v1.3.0
|
||||
modernc.org/sqlite v1.10.0
|
||||
)
|
||||
|
|
24
go.sum
24
go.sum
|
@ -1,12 +1,30 @@
|
|||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/google/go-cmp v0.5.3 h1:x95R7cp+rSeeqAMI2knLtQ0DKlaBhv2NrtrOvafPHRo=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0PhZE7qpvbZl5ljd8r6U0bI=
|
||||
github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
|
||||
github.com/tj/go-naturaldate v1.3.0 h1:OgJIPkR/Jk4bFMBLbxZ8w+QUxwjqSvzd9x+yXocY4RI=
|
||||
github.com/tj/go-naturaldate v1.3.0/go.mod h1:rpUbjivDKiS1BlfMGc2qUKNZ/yxgthOfmytQs8d8hKk=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
|
@ -36,10 +54,14 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
|
|||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
modernc.org/cc/v3 v3.31.5-0.20210308123301-7a3e9dab9009 h1:u0oCo5b9wyLr++HF3AN9JicGhkUxJhMz51+8TIZH9N0=
|
||||
modernc.org/cc/v3 v3.31.5-0.20210308123301-7a3e9dab9009/go.mod h1:0R6jl1aZlIl2avnYfbfHBS1QB6/f+16mihBObaBC878=
|
||||
modernc.org/ccgo/v3 v3.9.0 h1:JbcEIqjw4Agf+0g3Tc85YvfYqkkFOv6xBwS4zkfqSoA=
|
||||
modernc.org/ccgo/v3 v3.9.0/go.mod h1:nQbgkn8mwzPdp4mm6BT6+p85ugQ7FrGgIcYaE7nSrpY=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/libc v1.7.13-0.20210308123627-12f642a52bb8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w=
|
||||
modernc.org/libc v1.8.0 h1:Pp4uv9g0csgBMpGPABKtkieF6O5MGhfGo6ZiOdlYfR8=
|
||||
|
@ -55,8 +77,10 @@ modernc.org/sqlite v1.10.0 h1:0QNqx4EzfZzNEG13sFbS/L+egh0X5WXSckHrxHkySX8=
|
|||
modernc.org/sqlite v1.10.0/go.mod h1:PGzq6qlhyYjL6uVbSgS6WoF7ZopTW/sI7+7p+mb4ZVU=
|
||||
modernc.org/strutil v1.1.0 h1:+1/yCzZxY2pZwwrsbH+4T7BQMoLQ9QiBshRC9eicYsc=
|
||||
modernc.org/strutil v1.1.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs=
|
||||
modernc.org/tcl v1.5.0 h1:euZSUNfE0Fd4W8VqXI1Ly1v7fqDJoBuAV88Ea+SnaSs=
|
||||
modernc.org/tcl v1.5.0/go.mod h1:gb57hj4pO8fRrK54zveIfFXBaMHK3SKJNWcmRw1cRzc=
|
||||
modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
|
||||
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.0.1-0.20210308123920-1f282aa71362/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA=
|
||||
modernc.org/z v1.0.1 h1:WyIDpEpAIx4Hel6q/Pcgj/VhaQV5XPJ2I6ryIYbjnpc=
|
||||
modernc.org/z v1.0.1/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA=
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"hs9001/liner"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type history struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
func createSearchOpts(query string, mode int) searchopts {
|
||||
opts := searchopts{}
|
||||
o := "DESC"
|
||||
opts.order = &o
|
||||
lim := 100
|
||||
opts.limit = &lim
|
||||
opts.command = &query
|
||||
|
||||
switch mode {
|
||||
case liner.ModeGlobal:
|
||||
break
|
||||
case liner.ModeWorkdir:
|
||||
workdir, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
opts.workdir = &workdir
|
||||
default:
|
||||
panic("Invalid mode supplied")
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func (h *history) GetHistoryByPrefix(prefix string, mode int) (ph []string) {
|
||||
cmdquery := prefix + "%"
|
||||
opts := createSearchOpts(cmdquery, mode)
|
||||
results := search(h.conn, opts)
|
||||
for e := results.Back(); e != nil; e = e.Prev() {
|
||||
entry, ok := e.Value.(*HistoryEntry)
|
||||
if !ok {
|
||||
log.Panic("Failed to retrieve entries")
|
||||
}
|
||||
ph = append(ph, entry.cmd)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (h *history) GetHistoryByPattern(pattern string, mode int) (ph []string, pos []int) {
|
||||
cmdquery := "%" + pattern + "%"
|
||||
opts := createSearchOpts(cmdquery, mode)
|
||||
|
||||
results := search(h.conn, opts)
|
||||
for e := results.Back(); e != nil; e = e.Prev() {
|
||||
entry, ok := e.Value.(*HistoryEntry)
|
||||
if !ok {
|
||||
log.Panic("Failed to retrieve entries")
|
||||
}
|
||||
ph = append(ph, entry.cmd)
|
||||
pos = append(pos, strings.Index(strings.ToLower(entry.cmd), strings.ToLower(pattern)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (h *history) ReadHistory(r io.Reader) (num int, err error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (h *history) WriteHistory(w io.Writer) (num int, err error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (h *history) AppendHistory(item string) {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (h *history) ClearHistory() {
|
||||
panic("not implemented")
|
||||
}
|
||||
func (h *history) RLock() {
|
||||
//noop
|
||||
}
|
||||
func (h *history) RUnlock() {
|
||||
//noop
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
Copyright © 2012 Peter Harris
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
|
@ -0,0 +1,196 @@
|
|||
/*
|
||||
Package liner implements a simple command line editor, inspired by linenoise
|
||||
(https://github.com/antirez/linenoise/). This package supports WIN32 in
|
||||
addition to the xterm codes supported by everything else.
|
||||
*/
|
||||
package liner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"container/ring"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type commonState struct {
|
||||
terminalSupported bool
|
||||
outputRedirected bool
|
||||
inputRedirected bool
|
||||
historyProvider HistoryProvider
|
||||
completer WordCompleter
|
||||
columns int
|
||||
killRing *ring.Ring
|
||||
ctrlCAborts bool
|
||||
r *bufio.Reader
|
||||
tabStyle TabStyle
|
||||
multiLineMode bool
|
||||
cursorRows int
|
||||
maxRows int
|
||||
shouldRestart ShouldRestart
|
||||
noBeep bool
|
||||
needRefresh bool
|
||||
}
|
||||
|
||||
type HistoryProvider interface {
|
||||
ReadHistory(r io.Reader) (num int, err error)
|
||||
WriteHistory(w io.Writer) (num int, err error)
|
||||
AppendHistory(item string)
|
||||
ClearHistory()
|
||||
GetHistoryByPrefix(prefix string, mode int) (ph []string)
|
||||
GetHistoryByPattern(pattern string, mode int) (ph []string, pos []int)
|
||||
RLock()
|
||||
RUnlock()
|
||||
}
|
||||
|
||||
// TabStyle is used to select how tab completions are displayed.
|
||||
type TabStyle int
|
||||
|
||||
// Two tab styles are currently available:
|
||||
//
|
||||
// TabCircular cycles through each completion item and displays it directly on
|
||||
// the prompt
|
||||
//
|
||||
// TabPrints prints the list of completion items to the screen after a second
|
||||
// tab key is pressed. This behaves similar to GNU readline and BASH (which
|
||||
// uses readline)
|
||||
const (
|
||||
TabCircular TabStyle = iota
|
||||
TabPrints
|
||||
)
|
||||
|
||||
// ErrPromptAborted is returned from Prompt or PasswordPrompt when the user presses Ctrl-C
|
||||
// if SetCtrlCAborts(true) has been called on the State
|
||||
var ErrPromptAborted = errors.New("prompt aborted")
|
||||
|
||||
// ErrNotTerminalOutput is returned from Prompt or PasswordPrompt if the
|
||||
// platform is normally supported, but stdout has been redirected
|
||||
var ErrNotTerminalOutput = errors.New("standard output is not a terminal")
|
||||
|
||||
// ErrInvalidPrompt is returned from Prompt or PasswordPrompt if the
|
||||
// prompt contains any unprintable runes (including substrings that could
|
||||
// be colour codes on some platforms).
|
||||
var ErrInvalidPrompt = errors.New("invalid prompt")
|
||||
|
||||
// ErrInternal is returned when liner experiences an error that it cannot
|
||||
// handle. For example, if the number of colums becomes zero during an
|
||||
// active call to Prompt
|
||||
var ErrInternal = errors.New("liner: internal error")
|
||||
|
||||
// KillRingMax is the max number of elements to save on the killring.
|
||||
const KillRingMax = 60
|
||||
|
||||
// HistoryLimit is the maximum number of entries saved in the scrollback history.
|
||||
const HistoryLimit = 1000
|
||||
|
||||
func (s *State) ReadHistory(r io.Reader) (num int, err error) {
|
||||
return s.historyProvider.ReadHistory(r)
|
||||
}
|
||||
func (s *State) WriteHistory(w io.Writer) (num int, err error) {
|
||||
return s.historyProvider.WriteHistory(w)
|
||||
}
|
||||
func (s *State) AppendHistory(item string) {
|
||||
s.historyProvider.AppendHistory(item)
|
||||
}
|
||||
func (s *State) ClearHistory() {
|
||||
s.historyProvider.ClearHistory()
|
||||
}
|
||||
func (s *State) getHistoryByPrefix(prefix string, mode int) (ph []string) {
|
||||
return s.historyProvider.GetHistoryByPrefix(prefix, mode)
|
||||
}
|
||||
func (s *State) getHistoryByPattern(pattern string, mode int) (ph []string, pos []int) {
|
||||
return s.historyProvider.GetHistoryByPattern(pattern, mode)
|
||||
}
|
||||
|
||||
// SetHistoryProvider allows you to set a custom provider
|
||||
// for reading, writing and searching history.
|
||||
// By implementing the HistoryProvider interface
|
||||
// and injecting your custom implementation here.
|
||||
func (s *State) SetHistoryProvider(f HistoryProvider) {
|
||||
s.historyProvider = f
|
||||
}
|
||||
|
||||
// Completer takes the currently edited line content at the left of the cursor
|
||||
// and returns a list of completion candidates.
|
||||
// If the line is "Hello, wo!!!" and the cursor is before the first '!', "Hello, wo" is passed
|
||||
// to the completer which may return {"Hello, world", "Hello, Word"} to have "Hello, world!!!".
|
||||
type Completer func(line string) []string
|
||||
|
||||
// WordCompleter takes the currently edited line with the cursor position and
|
||||
// returns the completion candidates for the partial word to be completed.
|
||||
// If the line is "Hello, wo!!!" and the cursor is before the first '!', ("Hello, wo!!!", 9) is passed
|
||||
// to the completer which may returns ("Hello, ", {"world", "Word"}, "!!!") to have "Hello, world!!!".
|
||||
type WordCompleter func(line string, pos int) (head string, completions []string, tail string)
|
||||
|
||||
// SetCompleter sets the completion function that Liner will call to
|
||||
// fetch completion candidates when the user presses tab.
|
||||
func (s *State) SetCompleter(f Completer) {
|
||||
if f == nil {
|
||||
s.completer = nil
|
||||
return
|
||||
}
|
||||
s.completer = func(line string, pos int) (string, []string, string) {
|
||||
return "", f(string([]rune(line)[:pos])), string([]rune(line)[pos:])
|
||||
}
|
||||
}
|
||||
|
||||
// SetWordCompleter sets the completion function that Liner will call to
|
||||
// fetch completion candidates when the user presses tab.
|
||||
func (s *State) SetWordCompleter(f WordCompleter) {
|
||||
s.completer = f
|
||||
}
|
||||
|
||||
// SetTabCompletionStyle sets the behvavior when the Tab key is pressed
|
||||
// for auto-completion. TabCircular is the default behavior and cycles
|
||||
// through the list of candidates at the prompt. TabPrints will print
|
||||
// the available completion candidates to the screen similar to BASH
|
||||
// and GNU Readline
|
||||
func (s *State) SetTabCompletionStyle(tabStyle TabStyle) {
|
||||
s.tabStyle = tabStyle
|
||||
}
|
||||
|
||||
// ModeApplier is the interface that wraps a representation of the terminal
|
||||
// mode. ApplyMode sets the terminal to this mode.
|
||||
type ModeApplier interface {
|
||||
ApplyMode() error
|
||||
}
|
||||
|
||||
// SetCtrlCAborts sets whether Prompt on a supported terminal will return an
|
||||
// ErrPromptAborted when Ctrl-C is pressed. The default is false (will not
|
||||
// return when Ctrl-C is pressed). Unsupported terminals typically raise SIGINT
|
||||
// (and Prompt does not return) regardless of the value passed to SetCtrlCAborts.
|
||||
func (s *State) SetCtrlCAborts(aborts bool) {
|
||||
s.ctrlCAborts = aborts
|
||||
}
|
||||
|
||||
// SetMultiLineMode sets whether line is auto-wrapped. The default is false (single line).
|
||||
func (s *State) SetMultiLineMode(mlmode bool) {
|
||||
s.multiLineMode = mlmode
|
||||
}
|
||||
|
||||
// ShouldRestart is passed the error generated by readNext and returns true if
|
||||
// the the read should be restarted or false if the error should be returned.
|
||||
type ShouldRestart func(err error) bool
|
||||
|
||||
// SetShouldRestart sets the restart function that Liner will call to determine
|
||||
// whether to retry the call to, or return the error returned by, readNext.
|
||||
func (s *State) SetShouldRestart(f ShouldRestart) {
|
||||
s.shouldRestart = f
|
||||
}
|
||||
|
||||
// SetBeep sets whether liner should beep the terminal at various times (output
|
||||
// ASCII BEL, 0x07). Default is true (will beep).
|
||||
func (s *State) SetBeep(beep bool) {
|
||||
s.noBeep = !beep
|
||||
}
|
||||
|
||||
func (s *State) promptUnsupported(p string) (string, error) {
|
||||
if !s.inputRedirected || !s.terminalSupported {
|
||||
fmt.Print(p)
|
||||
}
|
||||
linebuf, _, err := s.r.ReadLine()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(linebuf), nil
|
||||
}
|
|
@ -0,0 +1,391 @@
|
|||
package liner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type nexter struct {
|
||||
r rune
|
||||
err error
|
||||
}
|
||||
|
||||
// State represents an open terminal
|
||||
type State struct {
|
||||
commonState
|
||||
origMode termios
|
||||
defaultMode termios
|
||||
next <-chan nexter
|
||||
winch chan os.Signal
|
||||
pending []rune
|
||||
useCHA bool
|
||||
}
|
||||
|
||||
// NewLiner initializes a new *State, and sets the terminal into raw mode. To
|
||||
// restore the terminal to its previous state, call State.Close().
|
||||
func NewLiner() *State {
|
||||
var s State
|
||||
s.r = bufio.NewReader(os.Stdin)
|
||||
|
||||
s.terminalSupported = TerminalSupported()
|
||||
if m, err := TerminalMode(); err == nil {
|
||||
s.origMode = *m.(*termios)
|
||||
} else {
|
||||
s.inputRedirected = true
|
||||
}
|
||||
if _, err := getMode(syscall.Stdout); err != 0 {
|
||||
s.outputRedirected = true
|
||||
}
|
||||
if s.inputRedirected && s.outputRedirected {
|
||||
s.terminalSupported = false
|
||||
}
|
||||
if s.terminalSupported && !s.inputRedirected && !s.outputRedirected {
|
||||
mode := s.origMode
|
||||
mode.Iflag &^= icrnl | inpck | istrip | ixon
|
||||
mode.Cflag |= cs8
|
||||
mode.Lflag &^= syscall.ECHO | icanon | iexten
|
||||
mode.ApplyMode()
|
||||
|
||||
winch := make(chan os.Signal, 1)
|
||||
signal.Notify(winch, syscall.SIGWINCH)
|
||||
s.winch = winch
|
||||
|
||||
s.checkOutput()
|
||||
}
|
||||
|
||||
if !s.outputRedirected {
|
||||
s.outputRedirected = !s.getColumns()
|
||||
}
|
||||
|
||||
return &s
|
||||
}
|
||||
|
||||
var errTimedOut = errors.New("timeout")
|
||||
|
||||
func (s *State) startPrompt() {
|
||||
if s.terminalSupported {
|
||||
if m, err := TerminalMode(); err == nil {
|
||||
s.defaultMode = *m.(*termios)
|
||||
mode := s.defaultMode
|
||||
mode.Lflag &^= isig
|
||||
mode.ApplyMode()
|
||||
}
|
||||
}
|
||||
s.restartPrompt()
|
||||
}
|
||||
|
||||
func (s *State) inputWaiting() bool {
|
||||
return len(s.next) > 0
|
||||
}
|
||||
|
||||
func (s *State) restartPrompt() {
|
||||
next := make(chan nexter, 200)
|
||||
go func() {
|
||||
for {
|
||||
var n nexter
|
||||
n.r, _, n.err = s.r.ReadRune()
|
||||
next <- n
|
||||
// Shut down nexter loop when an end condition has been reached
|
||||
if n.err != nil || n.r == '\n' || n.r == '\r' || n.r == ctrlC || n.r == ctrlD {
|
||||
close(next)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
s.next = next
|
||||
}
|
||||
|
||||
func (s *State) stopPrompt() {
|
||||
if s.terminalSupported {
|
||||
s.defaultMode.ApplyMode()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) nextPending(timeout <-chan time.Time) (rune, error) {
|
||||
select {
|
||||
case thing, ok := <-s.next:
|
||||
if !ok {
|
||||
return 0, ErrInternal
|
||||
}
|
||||
if thing.err != nil {
|
||||
return 0, thing.err
|
||||
}
|
||||
s.pending = append(s.pending, thing.r)
|
||||
return thing.r, nil
|
||||
case <-timeout:
|
||||
rv := s.pending[0]
|
||||
s.pending = s.pending[1:]
|
||||
return rv, errTimedOut
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) readNext() (interface{}, error) {
|
||||
if len(s.pending) > 0 {
|
||||
rv := s.pending[0]
|
||||
s.pending = s.pending[1:]
|
||||
return rv, nil
|
||||
}
|
||||
var r rune
|
||||
select {
|
||||
case thing, ok := <-s.next:
|
||||
if !ok {
|
||||
return 0, ErrInternal
|
||||
}
|
||||
if thing.err != nil {
|
||||
return nil, thing.err
|
||||
}
|
||||
r = thing.r
|
||||
case <-s.winch:
|
||||
s.getColumns()
|
||||
return winch, nil
|
||||
}
|
||||
if r != esc {
|
||||
return r, nil
|
||||
}
|
||||
s.pending = append(s.pending, r)
|
||||
|
||||
// Wait at most 50 ms for the rest of the escape sequence
|
||||
// If nothing else arrives, it was an actual press of the esc key
|
||||
timeout := time.After(50 * time.Millisecond)
|
||||
flag, err := s.nextPending(timeout)
|
||||
if err != nil {
|
||||
if err == errTimedOut {
|
||||
return flag, nil
|
||||
}
|
||||
return unknown, err
|
||||
}
|
||||
|
||||
switch flag {
|
||||
case '[':
|
||||
code, err := s.nextPending(timeout)
|
||||
if err != nil {
|
||||
if err == errTimedOut {
|
||||
return code, nil
|
||||
}
|
||||
return unknown, err
|
||||
}
|
||||
switch code {
|
||||
case 'A':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return up, nil
|
||||
case 'B':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return down, nil
|
||||
case 'C':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return right, nil
|
||||
case 'D':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return left, nil
|
||||
case 'F':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return end, nil
|
||||
case 'H':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return home, nil
|
||||
case 'Z':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return shiftTab, nil
|
||||
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
||||
num := []rune{code}
|
||||
for {
|
||||
code, err := s.nextPending(timeout)
|
||||
if err != nil {
|
||||
if err == errTimedOut {
|
||||
return code, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
switch code {
|
||||
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
||||
num = append(num, code)
|
||||
case ';':
|
||||
// Modifier code to follow
|
||||
// This only supports Ctrl-left and Ctrl-right for now
|
||||
x, _ := strconv.ParseInt(string(num), 10, 32)
|
||||
if x != 1 {
|
||||
// Can't be left or right
|
||||
rv := s.pending[0]
|
||||
s.pending = s.pending[1:]
|
||||
return rv, nil
|
||||
}
|
||||
num = num[:0]
|
||||
for {
|
||||
code, err = s.nextPending(timeout)
|
||||
if err != nil {
|
||||
if err == errTimedOut {
|
||||
rv := s.pending[0]
|
||||
s.pending = s.pending[1:]
|
||||
return rv, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
switch code {
|
||||
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
||||
num = append(num, code)
|
||||
case 'C', 'D':
|
||||
// right, left
|
||||
mod, _ := strconv.ParseInt(string(num), 10, 32)
|
||||
if mod != 5 {
|
||||
// Not bare Ctrl
|
||||
rv := s.pending[0]
|
||||
s.pending = s.pending[1:]
|
||||
return rv, nil
|
||||
}
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
if code == 'C' {
|
||||
return wordRight, nil
|
||||
}
|
||||
return wordLeft, nil
|
||||
default:
|
||||
// Not left or right
|
||||
rv := s.pending[0]
|
||||
s.pending = s.pending[1:]
|
||||
return rv, nil
|
||||
}
|
||||
}
|
||||
case '~':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
x, _ := strconv.ParseInt(string(num), 10, 32)
|
||||
switch x {
|
||||
case 2:
|
||||
return insert, nil
|
||||
case 3:
|
||||
return del, nil
|
||||
case 5:
|
||||
return pageUp, nil
|
||||
case 6:
|
||||
return pageDown, nil
|
||||
case 1, 7:
|
||||
return home, nil
|
||||
case 4, 8:
|
||||
return end, nil
|
||||
case 15:
|
||||
return f5, nil
|
||||
case 17:
|
||||
return f6, nil
|
||||
case 18:
|
||||
return f7, nil
|
||||
case 19:
|
||||
return f8, nil
|
||||
case 20:
|
||||
return f9, nil
|
||||
case 21:
|
||||
return f10, nil
|
||||
case 23:
|
||||
return f11, nil
|
||||
case 24:
|
||||
return f12, nil
|
||||
default:
|
||||
return unknown, nil
|
||||
}
|
||||
default:
|
||||
// unrecognized escape code
|
||||
rv := s.pending[0]
|
||||
s.pending = s.pending[1:]
|
||||
return rv, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 'O':
|
||||
code, err := s.nextPending(timeout)
|
||||
if err != nil {
|
||||
if err == errTimedOut {
|
||||
return code, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
switch code {
|
||||
case 'c':
|
||||
return wordRight, nil
|
||||
case 'd':
|
||||
return wordLeft, nil
|
||||
case 'H':
|
||||
return home, nil
|
||||
case 'F':
|
||||
return end, nil
|
||||
case 'P':
|
||||
return f1, nil
|
||||
case 'Q':
|
||||
return f2, nil
|
||||
case 'R':
|
||||
return f3, nil
|
||||
case 'S':
|
||||
return f4, nil
|
||||
default:
|
||||
return unknown, nil
|
||||
}
|
||||
case 'b':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return altB, nil
|
||||
case 'd':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return altD, nil
|
||||
case bs:
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return altBs, nil
|
||||
case 'f':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return altF, nil
|
||||
case 'y':
|
||||
s.pending = s.pending[:0] // escape code complete
|
||||
return altY, nil
|
||||
default:
|
||||
rv := s.pending[0]
|
||||
s.pending = s.pending[1:]
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
// not reached
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Close returns the terminal to its previous mode
|
||||
func (s *State) Close() error {
|
||||
signal.Stop(s.winch)
|
||||
if !s.inputRedirected {
|
||||
s.origMode.ApplyMode()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TerminalSupported returns true if the current terminal supports
|
||||
// line editing features, and false if liner will use the 'dumb'
|
||||
// fallback for input.
|
||||
// Note that TerminalSupported does not check all factors that may
|
||||
// cause liner to not fully support the terminal (such as stdin redirection)
|
||||
func TerminalSupported() bool {
|
||||
bad := map[string]bool{"": true, "dumb": true, "cons25": true}
|
||||
return !bad[strings.ToLower(os.Getenv("TERM"))]
|
||||
}
|
||||
|
||||
const (
|
||||
getTermios = syscall.TCGETS
|
||||
setTermios = syscall.TCSETS
|
||||
)
|
||||
|
||||
const (
|
||||
icrnl = syscall.ICRNL
|
||||
inpck = syscall.INPCK
|
||||
istrip = syscall.ISTRIP
|
||||
ixon = syscall.IXON
|
||||
opost = syscall.OPOST
|
||||
cs8 = syscall.CS8
|
||||
isig = syscall.ISIG
|
||||
icanon = syscall.ICANON
|
||||
iexten = syscall.IEXTEN
|
||||
)
|
||||
|
||||
type termios struct {
|
||||
syscall.Termios
|
||||
}
|
||||
|
||||
const cursorColumn = false
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,74 @@
|
|||
package liner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func (s *State) cursorPos(x int) {
|
||||
if s.useCHA {
|
||||
// 'G' is "Cursor Character Absolute (CHA)"
|
||||
fmt.Printf("\x1b[%dG", x+1)
|
||||
} else {
|
||||
// 'C' is "Cursor Forward (CUF)"
|
||||
fmt.Print("\r")
|
||||
if x > 0 {
|
||||
fmt.Printf("\x1b[%dC", x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) eraseLine() {
|
||||
fmt.Print("\x1b[0K")
|
||||
}
|
||||
|
||||
func (s *State) eraseScreen() {
|
||||
fmt.Print("\x1b[H\x1b[2J")
|
||||
}
|
||||
|
||||
func (s *State) moveUp(lines int) {
|
||||
fmt.Printf("\x1b[%dA", lines)
|
||||
}
|
||||
|
||||
func (s *State) moveDown(lines int) {
|
||||
fmt.Printf("\x1b[%dB", lines)
|
||||
}
|
||||
|
||||
func (s *State) emitNewLine() {
|
||||
fmt.Print("\n")
|
||||
}
|
||||
|
||||
type winSize struct {
|
||||
row, col uint16
|
||||
xpixel, ypixel uint16
|
||||
}
|
||||
|
||||
func (s *State) getColumns() bool {
|
||||
var ws winSize
|
||||
ok, _, _ := syscall.Syscall(syscall.SYS_IOCTL, uintptr(syscall.Stdout),
|
||||
syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&ws)))
|
||||
if int(ok) < 0 {
|
||||
return false
|
||||
}
|
||||
s.columns = int(ws.col)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *State) checkOutput() {
|
||||
// xterm is known to support CHA
|
||||
if strings.Contains(strings.ToLower(os.Getenv("TERM")), "xterm") {
|
||||
s.useCHA = true
|
||||
return
|
||||
}
|
||||
|
||||
// The test for functional ANSI CHA is unreliable (eg the Windows
|
||||
// telnet command does not support reading the cursor position with
|
||||
// an ANSI DSR request, despite setting TERM=ansi)
|
||||
|
||||
// Assume CHA isn't supported (which should be safe, although it
|
||||
// does result in occasional visible cursor jitter)
|
||||
s.useCHA = false
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package liner
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func (mode *termios) ApplyMode() error {
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(syscall.Stdin), setTermios, uintptr(unsafe.Pointer(mode)))
|
||||
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TerminalMode returns the current terminal input mode as an InputModeSetter.
|
||||
//
|
||||
// This function is provided for convenience, and should
|
||||
// not be necessary for most users of liner.
|
||||
func TerminalMode() (ModeApplier, error) {
|
||||
mode, errno := getMode(syscall.Stdin)
|
||||
|
||||
if errno != 0 {
|
||||
return nil, errno
|
||||
}
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
func getMode(handle int) (*termios, syscall.Errno) {
|
||||
var mode termios
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(handle), getTermios, uintptr(unsafe.Pointer(&mode)))
|
||||
|
||||
return &mode, errno
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
package liner
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
// These character classes are mostly zero width (when combined).
|
||||
// A few might not be, depending on the user's font. Fixing this
|
||||
// is non-trivial, given that some terminals don't support
|
||||
// ANSI DSR/CPR
|
||||
var zeroWidth = []*unicode.RangeTable{
|
||||
unicode.Mn,
|
||||
unicode.Me,
|
||||
unicode.Cc,
|
||||
unicode.Cf,
|
||||
}
|
||||
|
||||
// countGlyphs considers zero-width characters to be zero glyphs wide,
|
||||
// and members of Chinese, Japanese, and Korean scripts to be 2 glyphs wide.
|
||||
func countGlyphs(s []rune) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
// speed up the common case
|
||||
if r < 127 {
|
||||
n++
|
||||
continue
|
||||
}
|
||||
|
||||
n += runewidth.RuneWidth(r)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func countMultiLineGlyphs(s []rune, columns int, start int) int {
|
||||
n := start
|
||||
for _, r := range s {
|
||||
if r < 127 {
|
||||
n++
|
||||
continue
|
||||
}
|
||||
switch runewidth.RuneWidth(r) {
|
||||
case 0:
|
||||
case 1:
|
||||
n++
|
||||
case 2:
|
||||
n += 2
|
||||
// no room for a 2-glyphs-wide char in the ending
|
||||
// so skip a column and display it at the beginning
|
||||
if n%columns == 1 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func getPrefixGlyphs(s []rune, num int) []rune {
|
||||
p := 0
|
||||
for n := 0; n < num && p < len(s); p++ {
|
||||
// speed up the common case
|
||||
if s[p] < 127 {
|
||||
n++
|
||||
continue
|
||||
}
|
||||
if !unicode.IsOneOf(zeroWidth, s[p]) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
for p < len(s) && unicode.IsOneOf(zeroWidth, s[p]) {
|
||||
p++
|
||||
}
|
||||
return s[:p]
|
||||
}
|
||||
|
||||
func getSuffixGlyphs(s []rune, num int) []rune {
|
||||
p := len(s)
|
||||
for n := 0; n < num && p > 0; p-- {
|
||||
// speed up the common case
|
||||
if s[p-1] < 127 {
|
||||
n++
|
||||
continue
|
||||
}
|
||||
if !unicode.IsOneOf(zeroWidth, s[p-1]) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return s[p:]
|
||||
}
|
406
main.go
406
main.go
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"container/list"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
@ -9,11 +10,29 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hs9001/liner"
|
||||
|
||||
"github.com/tj/go-naturaldate"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type HistoryEntry struct {
|
||||
id uint32
|
||||
cmd string
|
||||
cwd string
|
||||
hostname string
|
||||
user string
|
||||
retval int
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
var GitTag string
|
||||
var GitCommit string
|
||||
|
||||
func databaseLocation() string {
|
||||
envOverride := os.Getenv("HS9001_DB_PATH")
|
||||
if envOverride != "" {
|
||||
|
@ -28,6 +47,7 @@ func createConnection() *sql.DB {
|
|||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
|
@ -41,6 +61,80 @@ func initDatabase(conn *sql.DB) {
|
|||
}
|
||||
}
|
||||
|
||||
func migrateDatabase(conn *sql.DB, currentVersion int) {
|
||||
|
||||
migrations := []string{
|
||||
"ALTER TABLE history ADD COLUMN workdir varchar(4096) DEFAULT ''",
|
||||
"ALTER TABLE history ADD COLUMN retval integer DEFAULT -9001",
|
||||
"ALTER TABLE history ADD COLUMN unix_tmp integer",
|
||||
"UPDATE history SET unix_tmp = strftime('%s', timestamp)",
|
||||
"DROP VIEW count_by_date",
|
||||
"ALTER TABLE history DROP COLUMN timestamp",
|
||||
"ALTER TABLE history RENAME COLUMN unix_tmp TO timestamp",
|
||||
}
|
||||
|
||||
if !(len(migrations) > currentVersion) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := conn.Exec("BEGIN;")
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
for _, m := range migrations[currentVersion:] {
|
||||
_, err := conn.Exec(m)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
setDBVersion(conn, len(migrations))
|
||||
|
||||
_, err = conn.Exec("END;")
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchDBVersion(conn *sql.DB) int {
|
||||
rows, err := conn.Query("PRAGMA user_version;")
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
rows.Next()
|
||||
var res int
|
||||
rows.Scan(&res)
|
||||
return res
|
||||
}
|
||||
|
||||
func setDBVersion(conn *sql.DB, ver int) {
|
||||
_, err := conn.Exec(fmt.Sprintf("PRAGMA user_version=%d", ver))
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func NewHistoryEntry(cmd string, retval int) HistoryEntry {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
return HistoryEntry{
|
||||
user: os.Getenv("USER"),
|
||||
hostname: hostname,
|
||||
cmd: cmd,
|
||||
cwd: wd,
|
||||
timestamp: time.Now(),
|
||||
retval: retval,
|
||||
}
|
||||
}
|
||||
|
||||
func importFromStdin(conn *sql.DB) {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
|
||||
|
@ -50,7 +144,10 @@ func importFromStdin(conn *sql.DB) {
|
|||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
add(conn, scanner.Text())
|
||||
entry := NewHistoryEntry(scanner.Text(), -9001)
|
||||
entry.cwd = ""
|
||||
entry.timestamp = time.Unix(0, 0)
|
||||
add(conn, entry)
|
||||
}
|
||||
|
||||
_, err = conn.Exec("END;")
|
||||
|
@ -59,37 +156,95 @@ func importFromStdin(conn *sql.DB) {
|
|||
}
|
||||
}
|
||||
|
||||
func search(conn *sql.DB, q string) {
|
||||
queryStmt := "SELECT command FROM history WHERE command LIKE ? ORDER BY timestamp ASC"
|
||||
type searchopts struct {
|
||||
command *string
|
||||
workdir *string
|
||||
after *time.Time
|
||||
before *time.Time
|
||||
retval *int
|
||||
order *string
|
||||
limit *int
|
||||
}
|
||||
|
||||
rows, err := conn.Query(queryStmt, "%"+q+"%")
|
||||
func search(conn *sql.DB, opts searchopts) list.List {
|
||||
args := make([]interface{}, 0)
|
||||
var sb strings.Builder
|
||||
sb.WriteString("SELECT id, command, workdir, user, hostname, retval, timestamp ")
|
||||
sb.WriteString("FROM history ")
|
||||
sb.WriteString("WHERE 1=1 ") //1=1 so we can append as many AND foo as we want, or none
|
||||
|
||||
if opts.command != nil {
|
||||
sb.WriteString("AND command LIKE ? ")
|
||||
args = append(args, opts.command)
|
||||
}
|
||||
if opts.workdir != nil {
|
||||
sb.WriteString("AND workdir LIKE ? ")
|
||||
args = append(args, opts.workdir)
|
||||
}
|
||||
if opts.after != nil {
|
||||
sb.WriteString("AND timestamp > ? ")
|
||||
args = append(args, opts.after.Unix())
|
||||
}
|
||||
if opts.before != nil {
|
||||
sb.WriteString("AND timestamp < ? ")
|
||||
args = append(args, opts.before.Unix())
|
||||
}
|
||||
if opts.retval != nil {
|
||||
sb.WriteString("AND retval = ? ")
|
||||
args = append(args, opts.retval)
|
||||
}
|
||||
sb.WriteString("ORDER BY timestamp ")
|
||||
if opts.order != nil {
|
||||
sb.WriteString(*opts.order)
|
||||
sb.WriteRune(' ')
|
||||
} else {
|
||||
sb.WriteString("ASC ")
|
||||
}
|
||||
|
||||
if opts.limit != nil {
|
||||
sb.WriteString("LIMIT ")
|
||||
sb.WriteString(strconv.Itoa(*opts.limit))
|
||||
sb.WriteRune(' ')
|
||||
}
|
||||
|
||||
queryStmt := sb.String()
|
||||
|
||||
rows, err := conn.Query(queryStmt, args...)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
var result list.List
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var command string
|
||||
err = rows.Scan(&command)
|
||||
var entry HistoryEntry
|
||||
var timestamp int64
|
||||
err = rows.Scan(&entry.id, &entry.cmd, &entry.cwd, &entry.user, &entry.hostname, &entry.retval, ×tamp)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
fmt.Printf("%s\n", command)
|
||||
entry.timestamp = time.Unix(timestamp, 0)
|
||||
result.PushBack(&entry)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func delete(conn *sql.DB, entryId uint32) {
|
||||
queryStmt := "DELETE FROM history WHERE id = ?"
|
||||
|
||||
_, err := conn.Exec(queryStmt, entryId)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func add(conn *sql.DB, cmd string) {
|
||||
user := os.Getenv("USER")
|
||||
hostname, err := os.Hostname()
|
||||
func add(conn *sql.DB, entry HistoryEntry) {
|
||||
stmt, err := conn.Prepare("INSERT INTO history (user, command, hostname, workdir, timestamp, retval) VALUES (?, ?, ?, ?, ?,?)")
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
stmt, err := conn.Prepare("INSERT INTO history (user, command, hostname) VALUES (?, ?, ?)")
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
_, err = stmt.Exec(user, cmd, hostname)
|
||||
_, err = stmt.Exec(entry.user, entry.cmd, entry.hostname, entry.cwd, entry.timestamp.Unix(), entry.retval)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
@ -120,57 +275,208 @@ func exists(path string) (bool, error) {
|
|||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: ./hs9001 <add/search/init/import>\n")
|
||||
fmt.Fprintf(os.Stderr, "Usage: ./hs9001 <add/search/import/nolog/bash-enable>\n")
|
||||
}
|
||||
|
||||
func main() {
|
||||
var ret int
|
||||
flag.IntVar(&ret, "ret", 0, "Return value of the command to add")
|
||||
flag.Parse()
|
||||
args := flag.Args()
|
||||
argslen := len(args)
|
||||
addCmd := flag.NewFlagSet("add", flag.ExitOnError)
|
||||
searchCmd := flag.NewFlagSet("search", flag.ExitOnError)
|
||||
|
||||
if argslen < 1 {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
cmd := args[0]
|
||||
cmd := os.Args[1]
|
||||
globalargs := os.Args[2:]
|
||||
|
||||
conn := createConnection()
|
||||
var conn *sql.DB
|
||||
ok, _ := exists(databaseLocation())
|
||||
|
||||
if cmd == "add" {
|
||||
if ret == 23 { // 23 is our secret do not log status code
|
||||
return
|
||||
}
|
||||
if argslen < 2 {
|
||||
fmt.Fprint(os.Stderr, "Error: You need to provide the command to be added")
|
||||
|
||||
}
|
||||
historycmd := args[1]
|
||||
var rgx = regexp.MustCompile("\\s+\\d+\\s+(.*)")
|
||||
rs := rgx.FindStringSubmatch(historycmd)
|
||||
if len(rs) == 2 {
|
||||
add(conn, rs[1])
|
||||
}
|
||||
} else if cmd == "search" {
|
||||
if argslen < 2 {
|
||||
fmt.Fprint(os.Stderr, "Please provide the search query\n")
|
||||
}
|
||||
q := strings.Join(args[1:], " ")
|
||||
search(conn, q)
|
||||
os.Exit(23)
|
||||
} else if cmd == "init" {
|
||||
if !ok {
|
||||
err := os.MkdirAll(filepath.Dir(databaseLocation()), 0755)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
conn = createConnection()
|
||||
initDatabase(conn)
|
||||
} else if cmd == "import" {
|
||||
importFromStdin(conn)
|
||||
} else {
|
||||
fmt.Fprint(os.Stderr, "Error: Unknown command supplied\n\n")
|
||||
conn = createConnection()
|
||||
}
|
||||
|
||||
migrateDatabase(conn, fetchDBVersion(conn))
|
||||
|
||||
switch cmd {
|
||||
case "bash-ctrlr":
|
||||
line := liner.NewLiner()
|
||||
defer line.Close()
|
||||
line.SetCtrlCAborts(true)
|
||||
line.SetHistoryProvider(&history{conn: conn})
|
||||
line.SetMultiLineMode(true)
|
||||
|
||||
rdlineline := os.Getenv("READLINE_LINE")
|
||||
rdlinepos := os.Getenv("READLINE_POS")
|
||||
rdlineposint, _ := strconv.Atoi(rdlinepos)
|
||||
|
||||
if name, err := line.PromptWithSuggestionReverse("", rdlineline, rdlineposint); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", name)
|
||||
}
|
||||
|
||||
case "bash-enable":
|
||||
fmt.Printf(`
|
||||
if [ -n "$PS1" ] ; then
|
||||
PROMPT_COMMAND='hs9001 add -ret $? "$(history 1)"'
|
||||
bind -x '"\C-r": " READLINE_LINE=$(hs9001 bash-ctrlr 3>&1 1>&2 2>&3) READLINE_POINT=0"'
|
||||
fi
|
||||
alias hs='hs9001 search'
|
||||
`)
|
||||
case "bash-disable":
|
||||
fmt.Printf("unset PROMPT_COMMAND\n")
|
||||
case "add":
|
||||
var ret int
|
||||
addCmd.IntVar(&ret, "ret", 0, "Return value of the command to add")
|
||||
addCmd.Parse(globalargs)
|
||||
args := addCmd.Args()
|
||||
|
||||
if ret == 23 { // 23 is our secret do not log status code
|
||||
return
|
||||
}
|
||||
if len(args) < 1 {
|
||||
fmt.Fprint(os.Stderr, "Error: You need to provide the command to be added")
|
||||
|
||||
}
|
||||
historycmd := args[0]
|
||||
var rgx = regexp.MustCompile(`\s+\d+\s+(.*)`)
|
||||
rs := rgx.FindStringSubmatch(historycmd)
|
||||
if len(rs) == 2 {
|
||||
add(conn, NewHistoryEntry(rs[1], ret))
|
||||
}
|
||||
case "search":
|
||||
fallthrough
|
||||
case "delete":
|
||||
var workDir string
|
||||
var afterTime string
|
||||
var beforeTime string
|
||||
var distinct bool = true
|
||||
var today bool = false
|
||||
var retVal int
|
||||
searchCmd.StringVar(&workDir, "cwd", "", "Search only within this workdir")
|
||||
searchCmd.StringVar(&afterTime, "after", "", "Start searching from this timeframe")
|
||||
searchCmd.StringVar(&beforeTime, "before", "", "End searching from this timeframe")
|
||||
searchCmd.BoolVar(&distinct, "distinct", true, "Remove consecutive duplicate commands from output")
|
||||
searchCmd.BoolVar(&today, "today", false, "Search only today's entries. Overrides --after")
|
||||
searchCmd.IntVar(&retVal, "ret", -9001, "Only query commands that returned with this exit code. -9001=all (default)")
|
||||
searchCmd.Parse(globalargs)
|
||||
|
||||
args := searchCmd.Args()
|
||||
|
||||
q := strings.Join(args, " ")
|
||||
|
||||
opts := searchopts{}
|
||||
o := "ASC"
|
||||
opts.order = &o
|
||||
if q != "" {
|
||||
cmd := "%" + q + "%"
|
||||
opts.command = &cmd
|
||||
}
|
||||
if workDir != "" {
|
||||
wd, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed parse working directory path: %s\n", err.Error())
|
||||
}
|
||||
opts.workdir = &wd
|
||||
}
|
||||
|
||||
if today {
|
||||
afterTime = "today"
|
||||
}
|
||||
|
||||
if afterTime != "" {
|
||||
afterTimestamp, err := naturaldate.Parse(afterTime, time.Now())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to convert time string: %s\n", err.Error())
|
||||
}
|
||||
opts.after = &afterTimestamp
|
||||
}
|
||||
if beforeTime != "" {
|
||||
beforeTimestamp, err := naturaldate.Parse(beforeTime, time.Now())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to convert time string: %s\n", err.Error())
|
||||
}
|
||||
opts.before = &beforeTimestamp
|
||||
}
|
||||
if retVal != -9001 {
|
||||
opts.retval = &retVal
|
||||
}
|
||||
results := search(conn, opts)
|
||||
|
||||
previousCmd := ""
|
||||
previousReturn := -1
|
||||
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
//Don't print colors if output is piped
|
||||
printColors := true
|
||||
if (fi.Mode() & os.ModeCharDevice) == 0 {
|
||||
printColors = false
|
||||
}
|
||||
|
||||
for e := results.Front(); e != nil; e = e.Next() {
|
||||
entry, ok := e.Value.(*HistoryEntry)
|
||||
if !ok {
|
||||
log.Panic("Failed to retrieve entries")
|
||||
}
|
||||
if !distinct || !(previousCmd == entry.cmd && previousReturn == entry.retval) {
|
||||
prefix := ""
|
||||
postfix := ""
|
||||
if printColors && entry.retval != 0 {
|
||||
prefix = "\033[38;5;88m"
|
||||
postfix = "\033[0m"
|
||||
}
|
||||
fmt.Printf("%s%s%s\n", prefix, entry.cmd, postfix)
|
||||
}
|
||||
previousCmd = entry.cmd
|
||||
previousReturn = entry.retval
|
||||
}
|
||||
|
||||
if cmd == "delete" {
|
||||
|
||||
_, err := conn.Exec("BEGIN;")
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
for e := results.Front(); e != nil; e = e.Next() {
|
||||
entry, ok := e.Value.(*HistoryEntry)
|
||||
if !ok {
|
||||
log.Panic("Failed to retrieve entries")
|
||||
}
|
||||
delete(conn, entry.id)
|
||||
}
|
||||
|
||||
_, err = conn.Exec("END;")
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
_, err = conn.Exec("VACUUM")
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
}
|
||||
os.Exit(23)
|
||||
case "import":
|
||||
importFromStdin(conn)
|
||||
case "version":
|
||||
fmt.Fprintf(os.Stdout, "Git Tag: %s\nGit Commit: %s\n", GitTag, GitCommit)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Error: Unknown subcommand '%s' supplied\n\n", cmd)
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue