From 389515ae28d2f91bd9e266b345f7702f0c24a143 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Sat, 19 Oct 2024 13:11:34 -0700 Subject: [PATCH 1/5] Add support for full-screen rendering (#258) --- README.md | 13 +++++ client/client_test.go | 49 +++++++++++++++++++ client/cmd/configGet.go | 11 +++++ client/cmd/configSet.go | 14 ++++++ client/hctx/hctx.go | 2 + .../testdata/TestTui-FullScreenCompactRender | 40 +++++++++++++++ client/testdata/TestTui-FullScreenHelp | 46 +++++++++++++++++ client/testdata/TestTui-FullScreenRender | 42 ++++++++++++++++ client/tui/tui.go | 40 ++++++++++----- 9 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 client/testdata/TestTui-FullScreenCompactRender create mode 100644 client/testdata/TestTui-FullScreenHelp create mode 100644 client/testdata/TestTui-FullScreenRender diff --git a/README.md b/README.md index f251a07..c2cb8d4 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,19 @@ You can configure a custom timestamp format for hiSHtory via `hishtory config-se +
+Custom rendering
+ +By default, hiHStory tries to render the TUI in a reasonable way that balances terminal space consumption and TUI usability. If you find that you wish to customize this behavior, there are two config options that you can experiment with enabling: + +``` +hishtory config-set compact-mode true # Renders the TUI in "compact mode" with less whitespace +hishtory config-set full-screen true # Renders the TUI in "full-screen mode" so that it uses the entire terminal +``` + +
+ +
Web UI for sharing
diff --git a/client/client_test.go b/client/client_test.go index 68d8219..59edfb1 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -119,6 +119,7 @@ func TestParam(t *testing.T) { t.Run("testTui/ai", wrapTestForSharding(testTui_ai)) t.Run("testTui/defaultFilter", wrapTestForSharding(testTui_defaultFilter)) t.Run("testTui/escaping", wrapTestForSharding(testTui_escaping)) + t.Run("testTui/fullscreen", wrapTestForSharding(testTui_fullscreen)) // Assert there are no leaked connections assertNoLeakedConnections(t) @@ -1871,6 +1872,54 @@ func testTui_escaping(t *testing.T) { testutils.CompareGoldens(t, out, "TestTui-Escaping") } +func testTui_fullscreen(t *testing.T) { + // Setup + defer testutils.BackupAndRestore(t)() + tester, _, _ := setupTestTui(t, Online) + + // By default full-screen mode is disabled + require.Equal(t, "false", strings.TrimSpace(tester.RunInteractiveShell(t, `hishtory config-get full-screen`))) + require.Equal(t, "false", strings.TrimSpace(tester.RunInteractiveShell(t, `hishtory config-get compact-mode`))) + + // Test that we can enable it + tester.RunInteractiveShell(t, `hishtory config-set full-screen true`) + require.Equal(t, "true", strings.TrimSpace(tester.RunInteractiveShell(t, `hishtory config-get full-screen`))) + + // Test that it renders in full-screen mode taking up the entire terminal + out := captureTerminalOutput(t, tester, []string{ + "echo foo ENTER", + "hishtory SPACE tquery ENTER", + }) + testutils.CompareGoldens(t, out, "TestTui-FullScreenRender") + + // Test that it clears full-screen mode and restores the original terminal state + out = captureTerminalOutput(t, tester, []string{ + "echo SPACE foo ENTER", + "hishtory SPACE tquery ENTER", + "Escape", + }) + require.Contains(t, out, "echo foo\n") + require.Contains(t, out, "hishtory tquery\n") + require.NotContains(t, out, "Search Query") + require.True(t, len(strings.Split(out, "\n")) <= 7) + + // Test that it renders the help page fine + out = captureTerminalOutput(t, tester, []string{ + "echo SPACE foo ENTER", + "hishtory SPACE tquery ENTER", + "C-h", + }) + testutils.CompareGoldens(t, out, "TestTui-FullScreenHelp") + + // Test that it renders fine in full-screen mode and compact-mode + tester.RunInteractiveShell(t, `hishtory config-set compact-mode true`) + out = captureTerminalOutput(t, tester, []string{ + "echo foo ENTER", + "hishtory SPACE tquery ENTER", + }) + testutils.CompareGoldens(t, out, "TestTui-FullScreenCompactRender") +} + func testTui_defaultFilter(t *testing.T) { // Setup defer testutils.BackupAndRestore(t)() diff --git a/client/cmd/configGet.go b/client/cmd/configGet.go index b777993..acd21ca 100644 --- a/client/cmd/configGet.go +++ b/client/cmd/configGet.go @@ -184,6 +184,7 @@ func init() { configGetCmd.AddCommand(getAiCompletionEndpoint) configGetCmd.AddCommand(getCompactMode) configGetCmd.AddCommand(getLogLevelCmd) + configGetCmd.AddCommand(getFullScreenCmd) } var getLogLevelCmd = &cobra.Command{ @@ -195,3 +196,13 @@ var getLogLevelCmd = &cobra.Command{ fmt.Println(config.LogLevel.String()) }, } + +var getFullScreenCmd = &cobra.Command{ + Use: "full-screen", + Short: "Get whether or not hishtory is configured to run in full-screen mode", + Run: func(cmd *cobra.Command, args []string) { + ctx := hctx.MakeContext() + config := hctx.GetConf(ctx) + fmt.Println(config.FullScreenRendering) + }, +} diff --git a/client/cmd/configSet.go b/client/cmd/configSet.go index 3b7d586..d9e2b31 100644 --- a/client/cmd/configSet.go +++ b/client/cmd/configSet.go @@ -262,6 +262,19 @@ var setLogLevelCmd = &cobra.Command{ }, } +var setFullScreenCmd = &cobra.Command{ + Use: "full-screen", + Short: "Configure whether or not hishtory is configured to run in full-screen mode", + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + ValidArgs: []string{"true", "false"}, + Run: func(cmd *cobra.Command, args []string) { + ctx := hctx.MakeContext() + config := hctx.GetConf(ctx) + config.FullScreenRendering = args[0] == "true" + lib.CheckFatalError(hctx.SetConfig(config)) + }, +} + func init() { rootCmd.AddCommand(configSetCmd) configSetCmd.AddCommand(setEnableControlRCmd) @@ -277,6 +290,7 @@ func init() { configSetCmd.AddCommand(setAiCompletionEndpoint) configSetCmd.AddCommand(compactMode) configSetCmd.AddCommand(setLogLevelCmd) + configSetCmd.AddCommand(setFullScreenCmd) setColorSchemeCmd.AddCommand(setColorSchemeSelectedText) setColorSchemeCmd.AddCommand(setColorSchemeSelectedBackground) setColorSchemeCmd.AddCommand(setColorSchemeBorderColor) diff --git a/client/hctx/hctx.go b/client/hctx/hctx.go index 9d0e36e..8764ed7 100644 --- a/client/hctx/hctx.go +++ b/client/hctx/hctx.go @@ -221,6 +221,8 @@ type ClientConfig struct { KeyBindings keybindings.SerializableKeyMap `json:"key_bindings"` // The log level for hishtory (e.g., "debug", "info", "warn", "error") LogLevel logrus.Level `json:"log_level"` + // Whether the TUI should render in full-screen mode + FullScreenRendering bool `json:"full_screen_rendering"` } type ColorScheme struct { diff --git a/client/testdata/TestTui-FullScreenCompactRender b/client/testdata/TestTui-FullScreenCompactRender new file mode 100644 index 0000000..eb01d54 --- /dev/null +++ b/client/testdata/TestTui-FullScreenCompactRender @@ -0,0 +1,40 @@ +Search Query: > ls +┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Hostname CWD Timestamp Runtime Exit Code Command │ +│────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ localhost /tmp/ Oct 17 2022 21:43:21 PDT 3s 2 echo 'aaaaaa bbbb' │ +│ localhost /tmp/ Oct 17 2022 21:43:16 PDT 3s 2 ls ~/ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/client/testdata/TestTui-FullScreenHelp b/client/testdata/TestTui-FullScreenHelp new file mode 100644 index 0000000..07dc589 --- /dev/null +++ b/client/testdata/TestTui-FullScreenHelp @@ -0,0 +1,46 @@ +Search Query: > ls + +┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Hostname CWD Timestamp Runtime Exit Code Command │ +│────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ localhost /tmp/ Oct 17 2022 21:43:21 PDT 3s 2 echo 'aaaaaa bbbb' │ +│ localhost /tmp/ Oct 17 2022 21:43:16 PDT 3s 2 ls ~/ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +hiSHtory: Search your shell history +↑ scroll up ↓ scroll down pgup page up pgdn page down +← move left → move right shift+← scroll the table left shift+→ scroll the table right +enter select an entry ctrl+k delete the highlighted entry esc exit hiSHtory ctrl+h help +ctrl+x select an entry and cd into that directory \ No newline at end of file diff --git a/client/testdata/TestTui-FullScreenRender b/client/testdata/TestTui-FullScreenRender new file mode 100644 index 0000000..ca00974 --- /dev/null +++ b/client/testdata/TestTui-FullScreenRender @@ -0,0 +1,42 @@ +Search Query: > ls + +┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Hostname CWD Timestamp Runtime Exit Code Command │ +│────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ localhost /tmp/ Oct 17 2022 21:43:21 PDT 3s 2 echo 'aaaaaa bbbb' │ +│ localhost /tmp/ Oct 17 2022 21:43:16 PDT 3s 2 ls ~/ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +hiSHtory: Search your shell history • ctrl+h help \ No newline at end of file diff --git a/client/tui/tui.go b/client/tui/tui.go index 9e8440d..9a755e5 100644 --- a/client/tui/tui.go +++ b/client/tui/tui.go @@ -29,11 +29,6 @@ import ( "golang.org/x/term" ) -const ( - TABLE_HEIGHT = 20 - PADDED_NUM_ENTRIES = TABLE_HEIGHT * 5 -) - var ( CURRENT_QUERY_FOR_HIGHLIGHTING string = "" SELECTED_COMMAND string = "" @@ -218,7 +213,7 @@ func runQueryAndUpdateTable(m model, forceUpdateTable, maintainCursor bool) tea. // The default filter was cleared for this session, so don't apply it defaultFilter = "" } - rows, entries, searchErr := getRows(m.ctx, conf.DisplayedColumns, m.shellName, defaultFilter, query, PADDED_NUM_ENTRIES) + rows, entries, searchErr := getRows(m.ctx, conf.DisplayedColumns, m.shellName, defaultFilter, query, getNumEntriesNeeded(m.ctx)) return asyncQueryFinishedMsg{queryId, rows, entries, searchErr, forceUpdateTable, maintainCursor, nil} } } @@ -457,7 +452,7 @@ func getBaseStyle(config hctx.ClientConfig) lipgloss.Style { func renderNullableTable(m model, helpText string) string { if m.table == nil { - return strings.Repeat("\n", TABLE_HEIGHT+3) + return strings.Repeat("\n", getTableHeight(m.ctx)+3) } helpTextLen := strings.Count(helpText, "\n") baseStyle := getBaseStyle(*hctx.GetConf(m.ctx)) @@ -656,6 +651,25 @@ func min(a, b int) int { return b } +func getTableHeight(ctx context.Context) int { + config := hctx.GetConf(ctx) + if config.FullScreenRendering { + _, terminalHeight, err := getTerminalSize() + if err != nil { + // A reasonable guess at a default if for some reason we fail to retrieve the terminal size + return 30 + } + return max(terminalHeight-15, 20) + } + // Default to 20 when not full-screen since we want to balance showing a large table with not using the entire screen + return 20 +} + +func getNumEntriesNeeded(ctx context.Context) int { + // Get more than table height since the TUI filters some out (e.g. duplicate entries) + return getTableHeight(ctx) * 5 +} + func makeTable(ctx context.Context, shellName string, rows []table.Row) (table.Model, error) { config := hctx.GetConf(ctx) columns, err := makeTableColumns(ctx, shellName, config.DisplayedColumns, rows) @@ -689,7 +703,7 @@ func makeTable(ctx context.Context, shellName string, rows []table.Row) (table.M if isExtraCompactHeightMode(ctx) { tuiSize -= 3 } - tableHeight := min(TABLE_HEIGHT, terminalHeight-tuiSize) + tableHeight := min(getTableHeight(ctx), terminalHeight-tuiSize) t := table.New( table.WithColumns(columns), table.WithRows(rows), @@ -868,20 +882,24 @@ func configureColorProfile(ctx context.Context) { func TuiQuery(ctx context.Context, shellName, initialQuery string) error { loadedKeyBindings = hctx.GetConf(ctx).KeyBindings.ToKeyMap() configureColorProfile(ctx) - p := tea.NewProgram(initialModel(ctx, shellName, initialQuery), tea.WithOutput(os.Stderr)) + 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...) // Async: Get the initial set of rows go func() { LAST_DISPATCHED_QUERY_ID++ queryId := LAST_DISPATCHED_QUERY_ID LAST_DISPATCHED_QUERY_TIMESTAMP = time.Now() conf := hctx.GetConf(ctx) - rows, entries, err := getRows(ctx, conf.DisplayedColumns, shellName, conf.DefaultFilter, initialQuery, PADDED_NUM_ENTRIES) + 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}) } else { // initialQuery 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, PADDED_NUM_ENTRIES) + 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}) } }() From c58f28a04d0994ad4bfea9ac9df5745c2c6ba919 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Sat, 19 Oct 2024 14:17:53 -0700 Subject: [PATCH 2/5] Release v0.314 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 5478c71..9346fab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -313 +314 From e96993b673439780ecfeac3e5178bd1844fed4d3 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Sun, 20 Oct 2024 11:31:38 -0700 Subject: [PATCH 3/5] Disable tmate --- .github/workflows/go-test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index e50df19..c54aeff 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -105,11 +105,11 @@ jobs: with: name: goldens-used-${{ matrix.os }}-${{ matrix.test_shard }} path: /tmp/goldens-used.txt - - name: Setup tmate session - if: ${{ failure() }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true + # - name: Setup tmate session + # if: ${{ failure() }} + # uses: mxschmitt/action-tmate@v3 + # with: + # limit-access-to-actor: true check-goldens: runs-on: ubuntu-latest needs: test From 905afd91c313fdf887153154075c5784c692c38c Mon Sep 17 00:00:00 2001 From: David Dworken Date: Sun, 20 Oct 2024 12:09:16 -0700 Subject: [PATCH 4/5] Add support to skip modifying shell configs during the hishtory update process, since the shell configs are already set up at that point and haven't ever needed to change (#259) * Skip modifying shell configs during the hishtory update process, since the shell configs are already set up at that point and haven't ever needed to change * Use separate flag for skipping update config mods to enable future flexibility * Revert the update change since that needs to be in a separate release --- client/cmd/install.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/client/cmd/install.go b/client/cmd/install.go index 1123a26..e9c7cdc 100644 --- a/client/cmd/install.go +++ b/client/cmd/install.go @@ -26,10 +26,14 @@ import ( ) var ( - offlineInit *bool - forceInit *bool - offlineInstall *bool - skipConfigModification *bool + offlineInit *bool + forceInit *bool + offlineInstall *bool + skipConfigModification *bool + skipUpdateConfigModification *bool + + //lint:ignore U1000 Flag that is allowed to be specified, but not used + currentlyInstalledVersion *string ) var installCmd = &cobra.Command{ @@ -45,7 +49,7 @@ var installCmd = &cobra.Command{ if strings.HasPrefix(secretKey, "-") { lib.CheckFatalError(fmt.Errorf("secret key %#v looks like a CLI flag, please use a secret key that does not start with a -", secretKey)) } - lib.CheckFatalError(install(secretKey, *offlineInstall, *skipConfigModification)) + lib.CheckFatalError(install(secretKey, *offlineInstall, *skipConfigModification || *skipUpdateConfigModification)) if os.Getenv("HISHTORY_SKIP_INIT_IMPORT") == "" { db, err := hctx.OpenLocalSqliteDb() lib.CheckFatalError(err) @@ -686,4 +690,6 @@ func init() { forceInit = initCmd.Flags().Bool("force", false, "Force re-init without any prompts") offlineInstall = installCmd.Flags().Bool("offline", false, "Install hiSHtory in offline mode with all syncing capabilities disabled") skipConfigModification = installCmd.Flags().Bool("skip-config-modification", false, "Skip modifying shell configs and instead instruct the user on how to modify their configs") + skipUpdateConfigModification = installCmd.Flags().Bool("skip-update-config-modification", false, "Skip modifying shell configs for updates") + currentlyInstalledVersion = installCmd.Flags().String("currently-installed-version", "", "The currently installed version (used by the update command)") } From 0023c726364b147a418848fc9e10fb29996f4ba2 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Sun, 20 Oct 2024 12:22:29 -0700 Subject: [PATCH 5/5] 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 --- client/client_test.go | 34 ++++--- client/cmd/query.go | 2 +- client/lib/lib.go | 13 ++- client/lib/lib_test.go | 5 + client/testdata/TestTimestampFormat-tquery | 8 +- client/testdata/TestTui-SearchQuoteDash | 27 ++++++ .../testCustomColumns-tquery-bash-isAction | 2 +- .../testCustomColumns-tquery-zsh-isAction | 2 +- .../testRemoveDuplicateRows-enabled-export | 1 + .../testRemoveDuplicateRows-enabled-query | 1 + .../testRemoveDuplicateRows-enabled-tquery | 2 +- .../testdata/testRemoveDuplicateRows-tquery | 2 +- client/testutils.go | 2 +- client/tui/tui.go | 91 +++++++++++++++---- 14 files changed, 148 insertions(+), 44 deletions(-) create mode 100644 client/testdata/TestTui-SearchQuoteDash diff --git a/client/client_test.go b/client/client_test.go index 59edfb1..38d8c4b 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -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") } diff --git a/client/cmd/query.go b/client/cmd/query.go index 4da2629..108fa12 100644 --- a/client/cmd/query.go +++ b/client/cmd/query.go @@ -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)) }, } diff --git a/client/lib/lib.go b/client/lib/lib.go index c59dd57..ec984f4 100644 --- a/client/lib/lib.go +++ b/client/lib/lib.go @@ -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 { - tokens = append(tokens, string(token)) + if string(token) != "" { + tokens = append(tokens, string(token)) + } token = token[:0] splits++ } else if runeQuery[i] == '\\' && i+1 < len(runeQuery) { @@ -982,8 +984,13 @@ 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] == ':' { - token = append(token, '\\') + if (isInSingleQuotedString || isInDoubleQuotedString) && separator == ' ' { + if runeQuery[i] == ':' { + token = append(token, '\\') + } + if runeQuery[i] == '-' && len(token) == 0 { + token = append(token, '\\') + } } token = append(token, runeQuery[i]) } diff --git a/client/lib/lib_test.go b/client/lib/lib_test.go index 2d3ad93..013b727 100644 --- a/client/lib/lib_test.go +++ b/client/lib/lib_test.go @@ -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) diff --git a/client/testdata/TestTimestampFormat-tquery b/client/testdata/TestTimestampFormat-tquery index 8b55daf..b45d9ba 100644 --- a/client/testdata/TestTimestampFormat-tquery +++ b/client/testdata/TestTimestampFormat-tquery @@ -1,10 +1,10 @@ -Search Query: > -pipefail -tablesizing +Search Query: > table_cmd -tquery ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ Hostname CWD Timestamp Runtime Exit Code Command │ +│ 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 ~/foo/ 2022/Apr/16 01:03 24s 3 table_cmd2 │ +│ localhost /tmp/ 2022/Apr/16 01:03 4s 2 table_cmd1 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… │ │ │ │ │ │ │ diff --git a/client/testdata/TestTui-SearchQuoteDash b/client/testdata/TestTui-SearchQuoteDash new file mode 100644 index 0000000..812b0b9 --- /dev/null +++ b/client/testdata/TestTui-SearchQuoteDash @@ -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 \ No newline at end of file diff --git a/client/testdata/testCustomColumns-tquery-bash-isAction b/client/testdata/testCustomColumns-tquery-bash-isAction index dd108e8..c913ad2 100644 --- a/client/testdata/testCustomColumns-tquery-bash-isAction +++ b/client/testdata/testCustomColumns-tquery-bash-isAction @@ -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 \ No newline at end of file diff --git a/client/testdata/testCustomColumns-tquery-zsh-isAction b/client/testdata/testCustomColumns-tquery-zsh-isAction index dd108e8..c913ad2 100644 --- a/client/testdata/testCustomColumns-tquery-zsh-isAction +++ b/client/testdata/testCustomColumns-tquery-zsh-isAction @@ -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 \ No newline at end of file diff --git a/client/testdata/testRemoveDuplicateRows-enabled-export b/client/testdata/testRemoveDuplicateRows-enabled-export index 05b21f2..d2da25c 100644 --- a/client/testdata/testRemoveDuplicateRows-enabled-export +++ b/client/testdata/testRemoveDuplicateRows-enabled-export @@ -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 diff --git a/client/testdata/testRemoveDuplicateRows-enabled-query b/client/testdata/testRemoveDuplicateRows-enabled-query index d517fce..dc86486 100644 --- a/client/testdata/testRemoveDuplicateRows-enabled-query +++ b/client/testdata/testRemoveDuplicateRows-enabled-query @@ -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 diff --git a/client/testdata/testRemoveDuplicateRows-enabled-tquery b/client/testdata/testRemoveDuplicateRows-enabled-tquery index 75c9d99..eaae0e8 100644 --- a/client/testdata/testRemoveDuplicateRows-enabled-tquery +++ b/client/testdata/testRemoveDuplicateRows-enabled-tquery @@ -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 \ No newline at end of file diff --git a/client/testdata/testRemoveDuplicateRows-tquery b/client/testdata/testRemoveDuplicateRows-tquery index a33fe72..e5966e9 100644 --- a/client/testdata/testRemoveDuplicateRows-tquery +++ b/client/testdata/testRemoveDuplicateRows-tquery @@ -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 \ No newline at end of file diff --git a/client/testutils.go b/client/testutils.go index 39bbf49..caee5c3 100644 --- a/client/testutils.go +++ b/client/testutils.go @@ -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 { diff --git a/client/tui/tui.go b/client/tui/tui.go index 9a755e5..19f3bab 100644 --- a/client/tui/tui.go +++ b/client/tui/tui.go @@ -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?