Skip to content

Commit 5ab845e

Browse files
authored
Merge pull request #1170 from lazmond3/feature/add-fzf-history
Add fzf history search feature
2 parents d3c9d9c + 6b2838e commit 5ab845e

10 files changed

+114
-6
lines changed

changelog.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Internal:
1111
Features:
1212
---------
1313

14+
* Added fzf history search functionality. The feature can switch between the old implementation and the new one based on the presence of the fzf binary.
15+
1416

1517
1.27.2 (2024/04/03)
1618
===================
@@ -24,7 +26,6 @@ Bug Fixes:
2426
1.27.1 (2024/03/28)
2527
===================
2628

27-
2829
Bug Fixes:
2930
----------
3031

mycli/AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ Contributors:
9797
* Zhanze Wang
9898
* Houston Wong
9999
* Mohamed Rezk
100+
* Ryosuke Kazami
100101

101102

102103
Created by:

mycli/key_bindings.py

+8
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from prompt_toolkit.filters import completion_is_selected, emacs_mode
44
from prompt_toolkit.key_binding import KeyBindings
55

6+
from .packages.toolkit.fzf import search_history
7+
68
_logger = logging.getLogger(__name__)
79

810

@@ -101,6 +103,12 @@ def _(event):
101103
cursorpos_abs -= 1
102104
b.cursor_position = min(cursorpos_abs, len(b.text))
103105

106+
@kb.add('c-r', filter=emacs_mode)
107+
def _(event):
108+
"""Search history using fzf or default reverse incremental search."""
109+
_logger.debug('Detected <C-r> key.')
110+
search_history(event)
111+
104112
@kb.add('enter', filter=completion_is_selected)
105113
def _(event):
106114
"""Makes the enter key work as the tab key only when showing the menu.

mycli/main.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,14 @@
3636
from prompt_toolkit.layout.processors import (HighlightMatchingBracketProcessor,
3737
ConditionalProcessor)
3838
from prompt_toolkit.lexers import PygmentsLexer
39-
from prompt_toolkit.history import FileHistory
4039
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
4140

4241
from .packages.special.main import NO_QUERY
4342
from .packages.prompt_utils import confirm, confirm_destructive_query
4443
from .packages.tabular_output import sql_format
4544
from .packages import special
4645
from .packages.special.favoritequeries import FavoriteQueries
46+
from .packages.toolkit.history import FileHistoryWithTimestamp
4747
from .sqlcompleter import SQLCompleter
4848
from .clitoolbar import create_toolbar_tokens_func
4949
from .clistyle import style_factory, style_factory_output
@@ -626,7 +626,7 @@ def run_cli(self):
626626
history_file = os.path.expanduser(
627627
os.environ.get('MYCLI_HISTFILE', '~/.mycli-history'))
628628
if dir_path_exists(history_file):
629-
history = FileHistory(history_file)
629+
history = FileHistoryWithTimestamp(history_file)
630630
else:
631631
history = None
632632
self.echo(

mycli/packages/filepaths.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def guess_socket_location():
100100
for r, dirs, files in os.walk(directory, topdown=True):
101101
for filename in files:
102102
name, ext = os.path.splitext(filename)
103-
if name.startswith("mysql") and ext in ('.socket', '.sock'):
103+
if name.startswith("mysql") and name != "mysqlx" and ext in ('.socket', '.sock'):
104104
return os.path.join(r, filename)
105105
dirs[:] = [d for d in dirs if d.startswith("mysql")]
106106
return None

mycli/packages/toolkit/__init__.py

Whitespace-only changes.

mycli/packages/toolkit/fzf.py

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from shutil import which
2+
3+
from pyfzf import FzfPrompt
4+
from prompt_toolkit import search
5+
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
6+
7+
from .history import FileHistoryWithTimestamp
8+
9+
10+
class Fzf(FzfPrompt):
11+
def __init__(self):
12+
self.executable = which("fzf")
13+
if self.executable:
14+
super().__init__()
15+
16+
def is_available(self) -> bool:
17+
return self.executable is not None
18+
19+
20+
def search_history(event: KeyPressEvent):
21+
buffer = event.current_buffer
22+
history = buffer.history
23+
24+
fzf = Fzf()
25+
26+
if fzf.is_available() and isinstance(history, FileHistoryWithTimestamp):
27+
history_items_with_timestamp = history.load_history_with_timestamp()
28+
29+
formatted_history_items = []
30+
original_history_items = []
31+
for item, timestamp in history_items_with_timestamp:
32+
formatted_item = item.replace('\n', ' ')
33+
timestamp = timestamp.split(".")[0] if "." in timestamp else timestamp
34+
formatted_history_items.append(f"{timestamp} {formatted_item}")
35+
original_history_items.append(item)
36+
37+
result = fzf.prompt(formatted_history_items, fzf_options="--tiebreak=index")
38+
39+
if result:
40+
selected_index = formatted_history_items.index(result[0])
41+
buffer.text = original_history_items[selected_index]
42+
buffer.cursor_position = len(buffer.text)
43+
else:
44+
# Fallback to default reverse incremental search
45+
search.start_search(direction=search.SearchDirection.BACKWARD)

mycli/packages/toolkit/history.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import os
2+
from typing import Iterable, Union, List, Tuple
3+
4+
from prompt_toolkit.history import FileHistory
5+
6+
_StrOrBytesPath = Union[str, bytes, os.PathLike]
7+
8+
9+
class FileHistoryWithTimestamp(FileHistory):
10+
"""
11+
:class:`.FileHistory` class that stores all strings in a file with timestamp.
12+
"""
13+
14+
def __init__(self, filename: _StrOrBytesPath) -> None:
15+
self.filename = filename
16+
super().__init__(filename)
17+
18+
def load_history_with_timestamp(self) -> List[Tuple[str, str]]:
19+
"""
20+
Load history entries along with their timestamps.
21+
22+
Returns:
23+
List[Tuple[str, str]]: A list of tuples where each tuple contains
24+
a history entry and its corresponding timestamp.
25+
"""
26+
history_with_timestamp: List[Tuple[str, str]] = []
27+
lines: List[str] = []
28+
timestamp: str = ""
29+
30+
def add() -> None:
31+
if lines:
32+
# Join and drop trailing newline.
33+
string = "".join(lines)[:-1]
34+
history_with_timestamp.append((string, timestamp))
35+
36+
if os.path.exists(self.filename):
37+
with open(self.filename, "rb") as f:
38+
for line_bytes in f:
39+
line = line_bytes.decode("utf-8", errors="replace")
40+
41+
if line.startswith("#"):
42+
# Extract timestamp
43+
timestamp = line[2:].strip()
44+
elif line.startswith("+"):
45+
lines.append(line[1:])
46+
else:
47+
add()
48+
lines = []
49+
50+
add()
51+
52+
return list(reversed(history_with_timestamp))

requirements-dev.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ pyperclip>=1.8.1
1414
importlib_resources>=5.0.0
1515
pyaes>=1.6.1
1616
sqlglot>=5.1.3
17-
setuptools
17+
setuptools<=71.1.0

setup.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
'configobj >= 5.0.5',
3030
'cli_helpers[styles] >= 2.2.1',
3131
'pyperclip >= 1.8.1',
32-
'pyaes >= 1.6.1'
32+
'pyaes >= 1.6.1',
33+
'pyfzf >= 0.3.1',
3334
]
3435

3536
if sys.version_info.minor < 9:

0 commit comments

Comments
 (0)