From dcff0a2f2b803cf18896de786782779828e24a71 Mon Sep 17 00:00:00 2001 From: Johannes Altmanninger Date: Sun, 17 Jul 2022 20:23:54 +0200 Subject: [PATCH] Add Control+R incremental history search in pager This reimplements ridiculousfish/control_r which is a more future-proof approach than #6686. Pressing Control+R shows history in our pager and allows to search filter commands with the pager search field. On the surface, this works just like in other shells; though there are some differences. - Our pager shows multiple results at a time. - Other shells allow to use up arrow/down arrow to select adjacent entries in history. Shouldn't be hard to implement but the hidden state might confuse users and it doesn't play well with up-or-search, so this is left out. Users might expect the history pager to use subsequence matching (fuzzy matching) like the completion pager, however due to the history pager design it uses substring matching. We could change this in future, however that means we would also want to change the ordering from "reverse-chronological" to "longest common subsequence" (e.g. what fuzzy finders do), because otherwise a query "fis" might give this ordering: fsck /dev/disk/by-partlabel/Linux\x20filesystem fish which is probably not what the user wants. The pager shows only a small number of history items at a time. This is because, as explained above, the history pager does not support subsequence matching, so navigating it does not scale well. Closes #602 --- CHANGELOG.rst | 1 + doc_src/cmds/bind.rst | 3 + .../functions/fish_default_key_bindings.fish | 3 +- src/complete.h | 2 + src/input.cpp | 1 + src/input_common.h | 1 + src/pager.cpp | 3 +- src/reader.cpp | 110 +++++++++++++++--- 8 files changed, 108 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3d72b987a..681886e3e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -24,6 +24,7 @@ Interactive improvements New or improved bindings ^^^^^^^^^^^^^^^^^^^^^^^^ - The :kbd:`Alt-H` binding will now show the manpage of the command under cursor instead of the always skipping ``sudo`` and the likes (:issue:`9020`). +- New special input function ``history-pager`` (:kbd:``Control-R``) opens the command history in the searchable pager (incremental search) (:issue:`602`). Improved prompts ^^^^^^^^^^^^^^^^ diff --git a/doc_src/cmds/bind.rst b/doc_src/cmds/bind.rst index e192f788d..a44c69203 100644 --- a/doc_src/cmds/bind.rst +++ b/doc_src/cmds/bind.rst @@ -196,6 +196,9 @@ The following special input functions are available: move one word to the right; or if at the end of the commandline, accept one word from the current autosuggestion. +``history-pager`` + invoke the searchable pager on history (incremental search). + ``history-search-backward`` search the history for the previous match diff --git a/share/functions/fish_default_key_bindings.fish b/share/functions/fish_default_key_bindings.fish index a0ea7aa2a..68e676e3f 100644 --- a/share/functions/fish_default_key_bindings.fish +++ b/share/functions/fish_default_key_bindings.fish @@ -85,8 +85,7 @@ function fish_default_key_bindings -d "emacs-like key binds" bind --preset $argv \ed kill-word - # Let ctrl+r search history if there is something in the commandline. - bind --preset $argv \cr 'commandline | string length -q; and commandline -f history-search-backward' + bind --preset $argv \cr history-pager # term-specific special bindings switch "$TERM" diff --git a/src/complete.h b/src/complete.h index f2ac9a65d..72dd39e18 100644 --- a/src/complete.h +++ b/src/complete.h @@ -45,6 +45,8 @@ enum { COMPLETE_DONT_SORT = 1 << 5, /// This completion looks to have the same string as an existing argument. COMPLETE_DUPLICATES_ARGUMENT = 1 << 6, + /// This completes not just a token but replaces the entire commandline. + COMPLETE_REPLACES_COMMANDLINE = 1 << 7, }; using complete_flags_t = uint8_t; diff --git a/src/input.cpp b/src/input.cpp index 329b8e6b4..d5b6a4a24 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -130,6 +130,7 @@ static constexpr const input_function_metadata_t input_function_metadata[] = { {L"forward-jump-till", readline_cmd_t::forward_jump_till}, {L"forward-single-char", readline_cmd_t::forward_single_char}, {L"forward-word", readline_cmd_t::forward_word}, + {L"history-pager", readline_cmd_t::history_pager}, {L"history-prefix-search-backward", readline_cmd_t::history_prefix_search_backward}, {L"history-prefix-search-forward", readline_cmd_t::history_prefix_search_forward}, {L"history-search-backward", readline_cmd_t::history_search_backward}, diff --git a/src/input_common.h b/src/input_common.h index 6cec3c7f7..da7558137 100644 --- a/src/input_common.h +++ b/src/input_common.h @@ -25,6 +25,7 @@ enum class readline_cmd_t { history_search_forward, history_prefix_search_backward, history_prefix_search_forward, + history_pager, delete_char, backward_delete_char, kill_line, diff --git a/src/pager.cpp b/src/pager.cpp index a5795a1f6..99d46f274 100644 --- a/src/pager.cpp +++ b/src/pager.cpp @@ -377,6 +377,7 @@ void pager_t::refilter_completions() { } void pager_t::set_completions(const completion_list_t &raw_completions) { + selected_completion_idx = PAGER_SELECTION_NONE; // Get completion infos out of it. unfiltered_completion_infos = process_completions_into_infos(raw_completions); @@ -498,7 +499,7 @@ bool pager_t::completion_try_print(size_t cols, const wcstring &prefix, const co // these are the "past the last value". progress_text = format_string(_(L"rows %lu to %lu of %lu"), start_row + 1, stop_row, row_count); - } else if (completion_infos.empty() && !unfiltered_completion_infos.empty()) { + } else if (search_field_shown && completion_infos.empty()) { // Everything is filtered. progress_text = _(L"(no matches)"); } diff --git a/src/reader.cpp b/src/reader.cpp index 84d2fc867..f2693cafb 100644 --- a/src/reader.cpp +++ b/src/reader.cpp @@ -185,6 +185,12 @@ static debounce_t &debounce_highlighting() { return *res; } +static debounce_t &debounce_history_pager() { + const long kHistoryPagerTimeoutMs = 500; + static auto res = new debounce_t(kHistoryPagerTimeoutMs); + return *res; +} + bool edit_t::operator==(const edit_t &other) const { return cursor_position_before_edit == other.cursor_position_before_edit && offset == other.offset && length == other.length && old == other.old && @@ -563,6 +569,10 @@ struct highlight_result_t { wcstring text; }; +struct history_pager_result_t { + completion_list_t matched_commands; +}; + /// readline_loop_state_t encapsulates the state used in a readline loop. /// It is always stack allocated transient. This state should not be "publicly visible"; public /// state should be in reader_data_t. @@ -682,6 +692,8 @@ class reader_data_t : public std::enable_shared_from_this { std::shared_ptr history{}; /// The history search. reader_history_search_t history_search{}; + /// Whether the in-pager history search is active. + bool history_pager_active{false}; /// The cursor selection mode. cursor_selection_mode_t cursor_selection_mode{cursor_selection_mode_t::exclusive}; @@ -721,7 +733,9 @@ class reader_data_t : public std::enable_shared_from_this { wcstring in_flight_highlight_request; wcstring in_flight_autosuggest_request; - bool is_navigating_pager_contents() const { return this->pager.is_navigating_contents(); } + bool is_navigating_pager_contents() const { + return this->pager.is_navigating_contents() || history_pager_active; + } /// The line that is currently being edited. Typically the command line, but may be the search /// field. @@ -740,6 +754,7 @@ class reader_data_t : public std::enable_shared_from_this { /// Do what we need to do whenever our command line changes. void command_line_changed(const editable_line_t *el); void maybe_refilter_pager(const editable_line_t *el); + void fill_history_pager(); /// Do what we need to do whenever our pager selection changes. void pager_selection_changed(); @@ -828,7 +843,8 @@ class reader_data_t : public std::enable_shared_from_this { void handle_readline_command(readline_cmd_t cmd, readline_loop_state_t &rls); void clear_pager(); - void select_completion_in_direction(selection_motion_t dir); + void select_completion_in_direction(selection_motion_t dir, + bool force_selection_change = false); void flash(); maybe_t get_selection() const; @@ -1201,6 +1217,10 @@ void reader_data_t::command_line_changed(const editable_line_t *el) { // Update the gen count. s_generation.store(1 + read_generation_count(), std::memory_order_relaxed); } else if (el == &this->pager.search_field_line) { + if (history_pager_active) { + fill_history_pager(); + return; + } this->pager.refilter_completions(); this->pager_selection_changed(); } @@ -1214,6 +1234,38 @@ void reader_data_t::maybe_refilter_pager(const editable_line_t *el) { } } +static history_pager_result_t history_pager_search(const std::shared_ptr &history, + const wcstring &search_string) { + // Use a small page size because we don't always offer practical ways to select a item from + // a large page (since we don't support subsequence filtering here). + constexpr size_t page_size = 12; + completion_list_t completions; + history_search_t search{history, search_string, history_search_type_t::contains, + smartcase_flags(search_string)}; + while (completions.size() < page_size && search.go_backwards()) { + const history_item_t &item = search.current_item(); + completions.push_back(completion_t{ + item.str(), L"", string_fuzzy_match_t::exact_match(), + COMPLETE_REPLACES_COMMANDLINE | COMPLETE_DONT_ESCAPE | COMPLETE_DONT_SORT}); + } + return {completions}; +} + +void reader_data_t::fill_history_pager() { + auto shared_this = this->shared_from_this(); + const wcstring &search_term = pager.search_field_line.text(); + debounce_history_pager().perform( + [=]() { return history_pager_search(shared_this->history, search_term); }, + [shared_this, search_term](const history_pager_result_t &result) { + if (search_term != shared_this->pager.search_field_line.text()) + return; // Stale request. + shared_this->pager.set_completions(result.matched_commands); + shared_this->select_completion_in_direction(selection_motion_t::next, true); + shared_this->super_highlight_me_plenty(); + shared_this->layout_and_repaint(L"history-pager"); + }); +} + void reader_data_t::pager_selection_changed() { ASSERT_IS_MAIN_THREAD(); @@ -1486,6 +1538,7 @@ static bool command_ends_paging(readline_cmd_t c, bool focused_on_search_field) } case rl::complete: case rl::complete_and_search: + case rl::history_pager: case rl::backward_char: case rl::forward_char: case rl::forward_single_char: @@ -1590,14 +1643,14 @@ void reader_data_t::delete_char(bool backward) { /// using syntax highlighting, etc. /// Returns true if the string changed. void reader_data_t::insert_string(editable_line_t *el, const wcstring &str) { - if (str.empty()) return; - - if (!history_search.active() && want_to_coalesce_insertion_of(*el, str)) { - el->insert_coalesce(str); - assert(el->undo_history.may_coalesce); - } else { - el->push_edit(edit_t(el->position(), 0, str)); - el->undo_history.may_coalesce = el->undo_history.try_coalesce || (str.size() == 1); + if (!str.empty()) { + if (!history_search.active() && want_to_coalesce_insertion_of(*el, str)) { + el->insert_coalesce(str); + assert(el->undo_history.may_coalesce); + } else { + el->push_edit(edit_t(el->position(), 0, str)); + el->undo_history.may_coalesce = el->undo_history.try_coalesce || (str.size() == 1); + } } if (el == &command_line) { @@ -1640,6 +1693,7 @@ wcstring completion_apply_to_command_line(const wcstring &val, complete_flags_t bool append_only) { bool add_space = !bool(flags & COMPLETE_NO_SPACE); bool do_replace = bool(flags & COMPLETE_REPLACES_TOKEN); + bool do_replace_commandline = bool(flags & COMPLETE_REPLACES_COMMANDLINE); bool do_escape = !bool(flags & COMPLETE_DONT_ESCAPE); bool no_tilde = bool(flags & COMPLETE_DONT_ESCAPE_TILDES); @@ -1647,6 +1701,12 @@ wcstring completion_apply_to_command_line(const wcstring &val, complete_flags_t bool back_into_trailing_quote = false; bool have_space_after_token = command_line[cursor_pos] == L' '; + if (do_replace_commandline) { + assert(!do_escape && "unsupported completion flag"); + *inout_cursor_pos = val.size(); + return val; + } + if (do_replace) { size_t move_cursor; const wchar_t *begin, *end; @@ -1935,11 +1995,15 @@ void reader_data_t::accept_autosuggestion(bool full, bool single, move_word_styl } // Ensure we have no pager contents. -void reader_data_t::clear_pager() { pager.clear(); } +void reader_data_t::clear_pager() { + pager.clear(); + history_pager_active = false; +} -void reader_data_t::select_completion_in_direction(selection_motion_t dir) { +void reader_data_t::select_completion_in_direction(selection_motion_t dir, + bool force_selection_change) { bool selection_changed = pager.select_next_completion_in_direction(dir, current_page_rendering); - if (selection_changed) { + if (force_selection_change || selection_changed) { pager_selection_changed(); } } @@ -3641,6 +3705,26 @@ void reader_data_t::handle_readline_command(readline_cmd_t c, readline_loop_stat } break; } + case rl::history_pager: { + if (history_pager_active) { + // TODO Deepen search. + break; + } + + // Record our cycle_command_line. + cycle_command_line = command_line.text(); + cycle_cursor_pos = command_line.position(); + + this->history_pager_active = true; + this->history_pager_history_index_start = 0; + this->history_pager_history_index_end = 0; + // Update the pager data. + pager.set_search_field_shown(true); + pager.set_prefix(L""); + // Update the search field, which triggers the actual history search. + insert_string(&pager.search_field_line, command_line.text()); + break; + } case rl::backward_char: { editable_line_t *el = active_edit_line(); if (is_navigating_pager_contents()) {