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
This commit is contained in:
Johannes Altmanninger 2022-07-17 20:23:54 +02:00
parent b0233c9aa7
commit dcff0a2f2b
8 changed files with 108 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<reader_data_t> {
std::shared_ptr<history_t> 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<reader_data_t> {
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<reader_data_t> {
/// 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<reader_data_t> {
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<source_range_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_t> &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()) {