Quote initial commands to make it possible to easily use hishtory to find matching entries for already typed commands that contain flags (#251)

* Quote initial commands to make it possible to easily use hishtory to find matching entries for already typed commands that contain flags

* Add test for quoting dashes

* Fix test failures

* More test fixes

* Update goldens

* Update goldens

* Update goldens

* Fix race condition

* Fix test harness bug by swapping to splitn

* Update goldens

* Update golden

* Update test
This commit is contained in:
David Dworken 2024-10-20 12:22:29 -07:00 committed by GitHub
parent 905afd91c3
commit 0023c72636
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 148 additions and 44 deletions

View File

@ -2165,6 +2165,14 @@ func testTui_search(t *testing.T, onlineStatus OnlineStatus) {
"'\"'foo:bar'\"'",
}))
testutils.CompareGoldens(t, out, "TestTui-SearchColonDoubleQuoted")
// And check that we can quote dashes
require.NoError(t, db.Create(testutils.MakeFakeHistoryEntry("foo --bar")).Error)
out = stripTuiCommandPrefix(t, captureTerminalOutput(t, tester, []string{
"hishtory SPACE tquery ENTER",
"'\"'--bar'\"'",
}))
testutils.CompareGoldens(t, out, "TestTui-SearchQuoteDash")
}
func testTui_general(t *testing.T, onlineStatus OnlineStatus) {
@ -2520,8 +2528,8 @@ echo bar`)
tester.RunInteractiveShell(t, `hishtory config-set displayed-columns 'Exit Code' git_remote Command`)
out = tester.RunInteractiveShell(t, `hishtory query -pipefail`)
testutils.CompareGoldens(t, out, fmt.Sprintf("testCustomColumns-query-isAction=%v", testutils.IsGithubAction()))
out = captureTerminalOutput(t, tester, []string{"hishtory SPACE tquery SPACE -pipefail ENTER"})
out = stripRequiredPrefix(t, out, "hishtory tquery -pipefail")
out = captureTerminalOutput(t, tester, []string{"hishtory SPACE tquery SPACE ENTER", "-pipefail"})
out = stripRequiredPrefix(t, out, "hishtory tquery")
testName := "testCustomColumns-tquery-" + tester.ShellName()
if testutils.IsGithubAction() {
testName += "-isAction"
@ -2777,8 +2785,8 @@ func TestTimestampFormat(t *testing.T) {
// And check that it is displayed in both the tui and the classic view
out := hishtoryQuery(t, tester, "-pipefail -tablesizing")
testutils.CompareGoldens(t, out, "TestTimestampFormat-query")
out = captureTerminalOutput(t, tester, []string{"hishtory SPACE tquery SPACE -pipefail SPACE -tablesizing ENTER"})
out = stripRequiredPrefix(t, out, "hishtory tquery -pipefail -tablesizing")
out = captureTerminalOutput(t, tester, []string{"hishtory SPACE tquery ENTER", "table_cmd SPACE -tquery"})
out = stripRequiredPrefix(t, out, "hishtory tquery")
testutils.CompareGoldens(t, out, "TestTimestampFormat-tquery")
}
@ -2817,7 +2825,7 @@ func TestSortByConsistentTimezone(t *testing.T) {
testutils.CompareGoldens(t, out, "TestSortByConsistentTimezone-query")
out = tester.RunInteractiveShell(t, `hishtory export -pipefail -tablesizing`)
testutils.CompareGoldens(t, out, "TestSortByConsistentTimezone-export")
out = captureTerminalOutput(t, tester, []string{"hishtory SPACE tquery SPACE -pipefail SPACE -tablesizing ENTER"})
out = captureTerminalOutput(t, tester, []string{"hishtory SPACE tquery ENTER", "-pipefail SPACE -tablesizing"})
out = stripTuiCommandPrefix(t, out)
require.Regexp(t, regexp.MustCompile(`Timestamp[\s\S]*Command[\s\S]*Apr 16 2022 01:36:26 PDT[\s\S]*third_entry[\s\S]*Apr 16 2022 01:19:46 PDT[\s\S]*second_entry[\s\S]*Apr 16 2022 01:03:06 PDT[\s\S]*first_entry`), out)
}
@ -2878,8 +2886,8 @@ echo foo`)
tester.RunInteractiveShell(t, `hishtory config-set displayed-columns 'Exit Code' Command`)
out = tester.RunInteractiveShell(t, `hishtory query -pipefail`)
testutils.CompareGoldens(t, out, "testRemoveDuplicateRows-query")
out = captureTerminalOutput(t, tester, []string{"hishtory SPACE tquery SPACE -pipefail ENTER"})
out = stripRequiredPrefix(t, out, "hishtory tquery -pipefail")
out = captureTerminalOutput(t, tester, []string{"hishtory SPACE tquery ENTER", "-pipefail"})
out = stripRequiredPrefix(t, out, "hishtory tquery")
testutils.CompareGoldens(t, out, "testRemoveDuplicateRows-tquery")
// And change the config to filter out duplicate rows
@ -2894,18 +2902,20 @@ echo foo`)
testutils.CompareGoldens(t, out, "testRemoveDuplicateRows-enabled-query")
// Check tquery
out = captureTerminalOutput(t, tester, []string{"hishtory SPACE tquery SPACE -pipefail ENTER"})
out = stripRequiredPrefix(t, out, "hishtory tquery -pipefail")
out = captureTerminalOutput(t, tester, []string{"hishtory SPACE tquery ENTER", "-pipefail"})
out = stripRequiredPrefix(t, out, "hishtory tquery")
testutils.CompareGoldens(t, out, "testRemoveDuplicateRows-enabled-tquery")
// Check actually selecting it with query
out = captureTerminalOutputWithComplexCommands(t, tester, []TmuxCommand{
{Keys: "hishtory SPACE tquery SPACE -pipefail ENTER", ExtraDelay: 1.0},
{Keys: "Down Down"},
{Keys: "hishtory SPACE tquery ENTER", ExtraDelay: 1.0},
{Keys: "-pipefail", ExtraDelay: 1.0},
{Keys: "Down Down Down"},
{Keys: "ENTER", ExtraDelay: 1.0},
})
out = stripTuiCommandPrefix(t, out)
require.Contains(t, out, "\necho foo\n")
require.Contains(t, out, "echo foo\n")
require.NotContains(t, out, "hishtory tquery")
require.NotContains(t, out, "echo baz")
require.NotContains(t, out, "config-set")
}

View File

@ -55,7 +55,7 @@ var tqueryCmd = &cobra.Command{
if os.Getenv("HISHTORY_SHELL_NAME") != "" {
shellName = os.Getenv("HISHTORY_SHELL_NAME")
}
lib.CheckFatalError(tui.TuiQuery(ctx, shellName, strings.Join(args, " ")))
lib.CheckFatalError(tui.TuiQuery(ctx, shellName, args))
},
}

View File

@ -965,7 +965,9 @@ func splitEscaped(query string, separator rune, maxSplit int) []string {
isInSingleQuotedString := false
for i := 0; i < len(runeQuery); i++ {
if (maxSplit < 0 || splits < maxSplit) && runeQuery[i] == separator && !isInSingleQuotedString && !isInDoubleQuotedString {
if string(token) != "" {
tokens = append(tokens, string(token))
}
token = token[:0]
splits++
} else if runeQuery[i] == '\\' && i+1 < len(runeQuery) {
@ -982,9 +984,14 @@ func splitEscaped(query string, separator rune, maxSplit int) []string {
} else if runeQuery[i] == '\'' && !isInDoubleQuotedString && !heuristicIgnoreUnclosedQuote(isInSingleQuotedString, '\'', runeQuery, i) {
isInSingleQuotedString = !isInSingleQuotedString
} else {
if (isInSingleQuotedString || isInDoubleQuotedString) && separator == ' ' && runeQuery[i] == ':' {
if (isInSingleQuotedString || isInDoubleQuotedString) && separator == ' ' {
if runeQuery[i] == ':' {
token = append(token, '\\')
}
if runeQuery[i] == '-' && len(token) == 0 {
token = append(token, '\\')
}
}
token = append(token, runeQuery[i])
}
}

View File

@ -324,6 +324,11 @@ func TestSplitEscaped(t *testing.T) {
{"foo:bar", ' ', -1, []string{"foo:bar"}},
{"'foo:bar'", ' ', -1, []string{"foo\\:bar"}},
{"\"foo:bar\"", ' ', -1, []string{"foo\\:bar"}},
// Tests for quoting dashes
{"'-foo'", ' ', -1, []string{"\\-foo"}},
{"'--foo'", ' ', -1, []string{"\\--foo"}},
{"bar '--foo'", ' ', -1, []string{"bar", "\\--foo"}},
{"bar 'foo-baz'", ' ', -1, []string{"bar", "foo-baz"}},
}
for _, tc := range testcases {
actual := splitEscaped(tc.input, tc.char, tc.limit)

View File

@ -1,10 +1,10 @@
Search Query: > -pipefail -tablesizing
Search Query: > table_cmd -tquery
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Hostname CWD Timestamp Runtime Exit Code Command │
│──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ localhost ~/foo/ 2022/Apr/16 01:03 24s 3 table_cmd2 │
│ localhost /tmp/ 2022/Apr/16 01:03 4s 2 table_cmd1 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… │
│ localhost /tmp/ 2022/Apr/16 01:03 4s 2 table_cmd1 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… │
│ │
│ │
│ │

27
client/testdata/TestTui-SearchQuoteDash vendored Normal file
View File

@ -0,0 +1,27 @@
Search Query: > "--bar"
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Hostname CWD Timestamp Runtime Exit Code Command │
│────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ localhost /tmp/ Oct 17 2022 21:43:46 PDT 3s 2 foo --bar │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘
hiSHtory: Search your shell history • ctrl+h help

View File

@ -3,6 +3,7 @@ Search Query: > -pipefail
┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Exit Code git_remote Command │
│────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ 0 https://github.com/ddworken/hishtory hishtory tquery │
│ 0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command │
│ 0 echo bar │
│ 0 cd / │
@ -22,6 +23,5 @@ Search Query: > -pipefail
│ │
│ │
│ │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
hiSHtory: Search your shell history • ctrl+h help

View File

@ -3,6 +3,7 @@ Search Query: > -pipefail
┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Exit Code git_remote Command │
│────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ 0 https://github.com/ddworken/hishtory hishtory tquery │
│ 0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command │
│ 0 echo bar │
│ 0 cd / │
@ -22,6 +23,5 @@ Search Query: > -pipefail
│ │
│ │
│ │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
hiSHtory: Search your shell history • ctrl+h help

View File

@ -4,4 +4,5 @@ echo baz
echo baz
echo foo
hishtory config-set displayed-columns 'Exit Code' Command
hishtory tquery
hishtory config-set filter-duplicate-commands true

View File

@ -1,5 +1,6 @@
Exit Code Command
0 hishtory config-set filter-duplicate-commands true
0 hishtory tquery
0 hishtory config-set displayed-columns 'Exit Code' Command
0 echo foo
0 echo baz

View File

@ -3,6 +3,7 @@ Search Query: > -pipefail
┌───────────────────────────────────────────────────────────────────────────┐
│ Exit Code Command │
│───────────────────────────────────────────────────────────────────────────│
│ 0 hishtory tquery │
│ 0 hishtory config-set filter-duplicate-commands true │
│ 0 hishtory config-set displayed-columns 'Exit Code' Command │
│ 0 echo foo │
@ -22,6 +23,5 @@ Search Query: > -pipefail
│ │
│ │
│ │
│ │
└───────────────────────────────────────────────────────────────────────────┘
hiSHtory: Search your shell history • ctrl+h help

View File

@ -3,6 +3,7 @@ Search Query: > -pipefail
┌───────────────────────────────────────────────────────────────────────────┐
│ Exit Code Command │
│───────────────────────────────────────────────────────────────────────────│
│ 0 hishtory tquery │
│ 0 hishtory config-set displayed-columns 'Exit Code' Command │
│ 0 echo foo │
│ 0 echo baz │
@ -22,6 +23,5 @@ Search Query: > -pipefail
│ │
│ │
│ │
│ │
└───────────────────────────────────────────────────────────────────────────┘
hiSHtory: Search your shell history • ctrl+h help

View File

@ -361,7 +361,7 @@ func stripShellPrefix(out string) string {
func stripRequiredPrefix(t *testing.T, out, prefix string) string {
require.Contains(t, out, prefix)
return strings.TrimSpace(strings.Split(out, prefix)[1])
return strings.TrimSpace(strings.SplitN(out, prefix, 2)[1])
}
func stripTuiCommandPrefix(t *testing.T, out string) string {

View File

@ -3,6 +3,7 @@ package tui
import (
"context"
_ "embed" // for embedding config.sh
"encoding/json"
"fmt"
"os"
"path/filepath"
@ -95,6 +96,9 @@ type model struct {
// The currently executing shell. Defaults to bash if not specified. Used for more precise AI suggestions.
shellName string
// Whether we've finished the first load of results. If we haven't, we refuse to run additional queries to avoid race conditions with how we handle invalid initial queries.
hasFinishedFirstLoad bool
}
type (
@ -119,6 +123,8 @@ type asyncQueryFinishedMsg struct {
maintainCursor bool
// An updated search query. May be used for initial queries when they're invalid.
overriddenSearchQuery *string
isFirstQuery bool
}
func initialModel(ctx context.Context, shellName, initialQuery string) model {
@ -148,7 +154,7 @@ func initialModel(ctx context.Context, shellName, initialQuery string) model {
queryInput.SetValue(initialQuery)
}
CURRENT_QUERY_FOR_HIGHLIGHTING = initialQuery
return model{ctx: ctx, spinner: s, isLoading: true, table: nil, tableEntries: []*data.HistoryEntry{}, runQuery: &initialQuery, queryInput: queryInput, help: help.New(), shellName: shellName}
return model{ctx: ctx, spinner: s, isLoading: true, table: nil, tableEntries: []*data.HistoryEntry{}, runQuery: &initialQuery, queryInput: queryInput, help: help.New(), shellName: shellName, hasFinishedFirstLoad: false}
}
func (m model) Init() tea.Cmd {
@ -199,13 +205,14 @@ func preventTableOverscrolling(m model) {
func runQueryAndUpdateTable(m model, forceUpdateTable, maintainCursor bool) tea.Cmd {
if (m.runQuery != nil && *m.runQuery != m.lastQuery) || forceUpdateTable || m.searchErr != nil {
// if !m.hasFinishedFirstLoad {
// return nil
// }
query := m.lastQuery
if m.runQuery != nil {
query = *m.runQuery
}
LAST_DISPATCHED_QUERY_ID++
queryId := LAST_DISPATCHED_QUERY_ID
LAST_DISPATCHED_QUERY_TIMESTAMP = time.Now()
queryId := allocateQueryId()
return func() tea.Msg {
conf := hctx.GetConf(m.ctx)
defaultFilter := conf.DefaultFilter
@ -214,7 +221,7 @@ func runQueryAndUpdateTable(m model, forceUpdateTable, maintainCursor bool) tea.
defaultFilter = ""
}
rows, entries, searchErr := getRows(m.ctx, conf.DisplayedColumns, m.shellName, defaultFilter, query, getNumEntriesNeeded(m.ctx))
return asyncQueryFinishedMsg{queryId, rows, entries, searchErr, forceUpdateTable, maintainCursor, nil}
return asyncQueryFinishedMsg{queryId, rows, entries, searchErr, forceUpdateTable, maintainCursor, nil, false}
}
}
return nil
@ -331,6 +338,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.queryInput.SetValue(*msg.overriddenSearchQuery)
}
}
if msg.isFirstQuery {
m.hasFinishedFirstLoad = true
}
return m, nil
default:
var cmd tea.Cmd
@ -879,28 +889,72 @@ func configureColorProfile(ctx context.Context) {
}
}
func TuiQuery(ctx context.Context, shellName, initialQuery string) error {
func buildInitialQueryWithSearchEscaping(initialQueryArray []string) (string, error) {
var initialQuery string
for i, queryChunk := range initialQueryArray {
if i != 0 {
initialQuery += " "
}
if strings.HasPrefix(queryChunk, "-") {
quoted, err := json.Marshal(queryChunk)
if err != nil {
return "", fmt.Errorf("failed to marshal query chunk for escaping: %w", err)
}
initialQuery += string(quoted)
} else {
initialQuery += queryChunk
}
}
return initialQuery, nil
}
func splitQueryArray(initialQueryArray []string) []string {
var splitQueryArray []string
for _, queryChunk := range initialQueryArray {
splitQueryArray = append(splitQueryArray, strings.Split(queryChunk, " ")...)
}
return splitQueryArray
}
func allocateQueryId() int {
LAST_DISPATCHED_QUERY_ID++
LAST_DISPATCHED_QUERY_TIMESTAMP = time.Now()
return LAST_DISPATCHED_QUERY_ID
}
func TuiQuery(ctx context.Context, shellName string, initialQueryArray []string) error {
initialQueryArray = splitQueryArray(initialQueryArray)
initialQueryWithEscaping, err := buildInitialQueryWithSearchEscaping(initialQueryArray)
if err != nil {
return err
}
loadedKeyBindings = hctx.GetConf(ctx).KeyBindings.ToKeyMap()
configureColorProfile(ctx)
additionalOptions := []tea.ProgramOption{tea.WithOutput(os.Stderr)}
if hctx.GetConf(ctx).FullScreenRendering {
additionalOptions = append(additionalOptions, tea.WithAltScreen())
}
p := tea.NewProgram(initialModel(ctx, shellName, initialQuery), additionalOptions...)
p := tea.NewProgram(initialModel(ctx, shellName, initialQueryWithEscaping), additionalOptions...)
// Async: Get the initial set of rows
go func() {
LAST_DISPATCHED_QUERY_ID++
queryId := LAST_DISPATCHED_QUERY_ID
LAST_DISPATCHED_QUERY_TIMESTAMP = time.Now()
queryId := allocateQueryId()
conf := hctx.GetConf(ctx)
rows, entries, err := getRows(ctx, conf.DisplayedColumns, shellName, conf.DefaultFilter, initialQuery, getNumEntriesNeeded(ctx))
if err == nil || initialQuery == "" {
p.Send(asyncQueryFinishedMsg{queryId: queryId, rows: rows, entries: entries, searchErr: err, forceUpdateTable: true, maintainCursor: false, overriddenSearchQuery: nil})
rows, entries, err := getRows(ctx, conf.DisplayedColumns, shellName, conf.DefaultFilter, initialQueryWithEscaping, getNumEntriesNeeded(ctx))
if err == nil || initialQueryWithEscaping == "" {
if err != nil {
panic(err)
}
p.Send(asyncQueryFinishedMsg{queryId: queryId, rows: rows, entries: entries, searchErr: err, forceUpdateTable: true, maintainCursor: false, overriddenSearchQuery: nil, isFirstQuery: true})
} else {
// initialQuery is likely invalid in some way, let's just drop it
// The initial query is likely invalid in some way, let's just drop it
emptyQuery := ""
rows, entries, err := getRows(ctx, hctx.GetConf(ctx).DisplayedColumns, shellName, conf.DefaultFilter, emptyQuery, getNumEntriesNeeded(ctx))
p.Send(asyncQueryFinishedMsg{queryId: queryId, rows: rows, entries: entries, searchErr: err, forceUpdateTable: true, maintainCursor: false, overriddenSearchQuery: &emptyQuery})
if err != nil {
panic(err)
}
p.Send(asyncQueryFinishedMsg{queryId: allocateQueryId(), rows: rows, entries: entries, searchErr: err, forceUpdateTable: true, maintainCursor: false, overriddenSearchQuery: &emptyQuery, isFirstQuery: true})
}
}()
// Async: Retrieve additional entries from the backend
@ -931,13 +985,13 @@ func TuiQuery(ctx context.Context, shellName, initialQuery string) error {
p.Send(bannerMsg{banner: string(banner)})
}()
// Blocking: Start the TUI
_, err := p.Run()
_, err = p.Run()
if err != nil {
return err
}
if SELECTED_COMMAND == "" && os.Getenv("HISHTORY_TERM_INTEGRATION") != "" {
// Print out the initialQuery instead so that we don't clear the terminal
SELECTED_COMMAND = initialQuery
// Print out the initialQuery instead so that we don't clear the terminal (note that we don't use the escaped one here)
SELECTED_COMMAND = strings.Join(initialQueryArray, " ")
}
fmt.Printf("%s\n", SELECTED_COMMAND)
return nil
@ -945,4 +999,3 @@ func TuiQuery(ctx context.Context, shellName, initialQuery string) error {
// TODO: support custom key bindings
// TODO: make the help page wrap
// TODO: If the initial query contains dashes, maybe we should smartly escape them?