Compare commits

...

19 Commits
v0.2 ... master

Author SHA1 Message Date
Albert S. e1375f237c Consider return code when checking if command was already printed 2022-09-27 17:01:20 +02:00
Albert S. ac9aab63bd Change 'timestamp' to simple unix epoch
Subjectively, 6d4e7a96dc increases
lag on a years-old production database with ~240k entries when searching
with CTRL+R.

So change timestamp column from datetime type (which is a string)
to integer, storing unix epoch now. As we deal with those primarily,
we also avoid conversions now.

Mentioned database shrinks by 2MB compared to previously (both VACCUMed)...

DROP unused count_by_date VIEW.
2021-09-28 18:27:39 +02:00
Albert S. d0e07640e8 Update README.md 2021-09-25 13:40:34 +02:00
Albert S. 6d4e7a96dc Implement --today
Also assign timestamp to HistoryEntry during search

Closes: #3
2021-09-25 12:49:50 +02:00
Albert S. ebcdfa5ff4 Print failed commands in red.
Optimized for dark terminals :-).

Closes: #8
2021-09-25 12:14:07 +02:00
Albert S. 0949ee422a Implement mode for reverse search in cwd
Add a mode selection mode to liner.

Add modes to search globally or in the cwd.
2021-09-25 11:52:39 +02:00
Albert S. 3a6a1b2aa9 bash-ctrlr: Improve reverse bash search performance
Remove hack that only starts searching when two chars have been entered

Improve performance by adding LIMIT clause instead.
2021-08-19 21:33:01 +02:00
Albert S. 1a6c75cea9 Improve README.md 2021-08-08 13:24:30 +02:00
Albert S. bb2450b8ec history: Only start search when at least 2 chars have been entered
Starting search with the first character leads to a noticable delay.
This hack ensures a more pleasant experience.
2021-08-08 12:38:25 +02:00
lawl 67050d32b9 Implement bash CTRL-R reverse history
Fork customized liner into own repo bash.
Rename -being and -end options to -after and -before.
2021-08-08 11:40:33 +02:00
Albert S. 33183dd7d3 Begin liner integration 2021-08-08 11:40:33 +02:00
lawl a8f35951f1 Replace previous PROMPT_COMMAND in bashrc with more convenient option 2021-06-05 15:42:33 +02:00
lawl b9e3a3629b Fix bug in cwd search and fix go-staticcheck regexp complaint 2021-06-05 15:05:18 +02:00
lawl 16752411e1 Add 'nolog' subcommand
The nolog subcommand instructs the user on how to disable logging
for the current shell. This is not (easily) doable automatically
since we can't affect a parent's environment without insane hacks.

Fixes #4
2021-06-05 14:49:25 +02:00
lawl c480519fca Resolve CWD arguments as absolute path
Previously when calling e.g 'hs9001 search -cwd .' we would search
for a litteral '.' in the database. Now we resolve that argument
relative to the CWD.
2021-06-05 14:28:02 +02:00
lawl 70e66f47ba CLI: Rename -workdir to -cwd so we have to type less 2021-06-05 14:27:25 +02:00
Albert S. 216e59747c Add 'version' command 2021-05-16 18:38:50 +02:00
Albert S. c206b07b2d add/search: Process cmd exit code 2021-05-16 18:01:48 +02:00
Albert S. 3b01d7b898 search: Do not print consecutive equal commands by default 2021-05-16 17:13:29 +02:00
13 changed files with 2226 additions and 59 deletions

6
Makefile Normal file
View File

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

View File

@ -2,15 +2,34 @@
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.
## 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
### From source
```
go build
#move hs9001 to a PATH location
```
### Debian / Ubuntu
Latest release can be installed using apt
```
@ -28,33 +47,22 @@ apk update
apk add hs9001
```
### Setup / Config
### From source
```
go build
#move hs9001 to a PATH location
```
Add this to .bashrc
```
if [ -n "$PS1" ] ; then
PROMPT_COMMAND='hs9001 add -ret $? "$(history 1)"'
fi
eval "$(hs9001 bash-enable)"
```
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.
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 [search terms]
```
It is recommended to create an alias for search to make life easier, e. g.:
```
alias searchh='hs9001 search'
```

1
go.mod
View File

@ -3,6 +3,7 @@ module hs9001
go 1.16
require (
github.com/mattn/go-runewidth v0.0.13
github.com/tj/go-naturaldate v1.3.0
modernc.org/sqlite v1.10.0
)

5
go.sum
View File

@ -8,12 +8,16 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU
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=
@ -50,7 +54,6 @@ 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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=

86
history.go Normal file
View File

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

20
liner/LICENSE Normal file
View File

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

196
liner/common.go Normal file
View File

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

391
liner/input.go Normal file
View File

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

1110
liner/line.go Normal file

File diff suppressed because it is too large Load Diff

74
liner/output.go Normal file
View File

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

35
liner/unixmode.go Normal file
View File

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

90
liner/width.go Normal file
View File

@ -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:]
}

211
main.go
View File

@ -10,9 +10,12 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"hs9001/liner"
"github.com/tj/go-naturaldate"
_ "modernc.org/sqlite"
)
@ -23,9 +26,13 @@ type HistoryEntry struct {
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 != "" {
@ -57,7 +64,13 @@ 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 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) {
@ -103,7 +116,7 @@ func setDBVersion(conn *sql.DB, ver int) {
}
}
func NewHistoryEntry(cmd string) HistoryEntry {
func NewHistoryEntry(cmd string, retval int) HistoryEntry {
wd, err := os.Getwd()
if err != nil {
log.Panic(err)
@ -118,6 +131,7 @@ func NewHistoryEntry(cmd string) HistoryEntry {
cmd: cmd,
cwd: wd,
timestamp: time.Now(),
retval: retval,
}
}
@ -130,7 +144,7 @@ func importFromStdin(conn *sql.DB) {
}
for scanner.Scan() {
entry := NewHistoryEntry(scanner.Text())
entry := NewHistoryEntry(scanner.Text(), -9001)
entry.cwd = ""
entry.timestamp = time.Unix(0, 0)
add(conn, entry)
@ -142,10 +156,60 @@ func importFromStdin(conn *sql.DB) {
}
}
func search(conn *sql.DB, q string, workdir string, beginTime time.Time, endTime time.Time) list.List {
queryStmt := "SELECT id, command, workdir, user, hostname FROM history WHERE timestamp BETWEEN datetime(?, 'unixepoch') AND datetime(?, 'unixepoch') AND command LIKE ? AND workdir 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, beginTime.Unix(), endTime.Unix(), q, workdir)
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)
}
@ -154,10 +218,12 @@ func search(conn *sql.DB, q string, workdir string, beginTime time.Time, endTime
defer rows.Close()
for rows.Next() {
var entry HistoryEntry
err = rows.Scan(&entry.id, &entry.cmd, &entry.cwd, &entry.user, &entry.hostname)
var timestamp int64
err = rows.Scan(&entry.id, &entry.cmd, &entry.cwd, &entry.user, &entry.hostname, &entry.retval, &timestamp)
if err != nil {
log.Panic(err)
}
entry.timestamp = time.Unix(timestamp, 0)
result.PushBack(&entry)
}
return result
@ -173,12 +239,12 @@ func delete(conn *sql.DB, entryId uint32) {
}
func add(conn *sql.DB, entry HistoryEntry) {
stmt, err := conn.Prepare("INSERT INTO history (user, command, hostname, workdir, timestamp) VALUES (?, ?, ?, ?, datetime(?, 'unixepoch'))")
stmt, err := conn.Prepare("INSERT INTO history (user, command, hostname, workdir, timestamp, retval) VALUES (?, ?, ?, ?, ?,?)")
if err != nil {
log.Panic(err)
}
_, err = stmt.Exec(entry.user, entry.cmd, entry.hostname, entry.cwd, entry.timestamp.Unix())
_, err = stmt.Exec(entry.user, entry.cmd, entry.hostname, entry.cwd, entry.timestamp.Unix(), entry.retval)
if err != nil {
log.Panic(err)
}
@ -209,7 +275,7 @@ func exists(path string) (bool, error) {
}
func printUsage() {
fmt.Fprintf(os.Stderr, "Usage: ./hs9001 <add/search/import>\n")
fmt.Fprintf(os.Stderr, "Usage: ./hs9001 <add/search/import/nolog/bash-enable>\n")
}
func main() {
@ -241,6 +307,31 @@ func main() {
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")
@ -255,46 +346,100 @@ func main() {
}
historycmd := args[0]
var rgx = regexp.MustCompile("\\s+\\d+\\s+(.*)")
var rgx = regexp.MustCompile(`\s+\d+\s+(.*)`)
rs := rgx.FindStringSubmatch(historycmd)
if len(rs) == 2 {
add(conn, NewHistoryEntry(rs[1]))
add(conn, NewHistoryEntry(rs[1], ret))
}
case "search":
fallthrough
case "delete":
var workDir string
var beginTime string
var endTime string
searchCmd.StringVar(&workDir, "workdir", "%", "Search only within this workdir")
searchCmd.StringVar(&beginTime, "begin", "50 years ago", "Start searching from this timeframe")
searchCmd.StringVar(&endTime, "end", "now", "End searching from this timeframe")
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()
beginTimestamp, err := naturaldate.Parse(beginTime, time.Now())
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to convert time string: %s\n", err.Error())
}
endTimeStamp, err := naturaldate.Parse(endTime, time.Now())
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to convert time string: %s\n", err.Error())
}
q := strings.Join(args, " ")
results := search(conn, "%"+q+"%", workDir, beginTimestamp, endTimeStamp)
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")
}
fmt.Printf("%s\n", entry.cmd)
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" {
@ -326,6 +471,8 @@ func main() {
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()