Add search history
This commit is contained in:
94
babi.py
94
babi.py
@@ -205,6 +205,25 @@ class Status:
|
||||
def __init__(self) -> None:
|
||||
self._status = ''
|
||||
self._action_counter = -1
|
||||
self._history: Dict[str, List[str]] = collections.defaultdict(list)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def save_history(self) -> Generator[None, None, None]:
|
||||
history_dir = os.path.join(
|
||||
os.environ.get('XDG_DATA_HOME') or
|
||||
os.path.expanduser('~/.local/share'),
|
||||
'babi/history',
|
||||
)
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
for filename in os.listdir(history_dir):
|
||||
with open(os.path.join(history_dir, filename)) as f:
|
||||
self._history[filename] = f.read().splitlines()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for k, v in self._history.items():
|
||||
with open(os.path.join(history_dir, k), 'w') as f:
|
||||
f.write('\n'.join(v) + '\n')
|
||||
|
||||
def update(self, status: str) -> None:
|
||||
self._status = status
|
||||
@@ -233,10 +252,22 @@ class Status:
|
||||
if self._action_counter < 0:
|
||||
self.clear()
|
||||
|
||||
def prompt(self, screen: 'Screen', prompt: str) -> str:
|
||||
def prompt(
|
||||
self,
|
||||
screen: 'Screen',
|
||||
prompt: str,
|
||||
*,
|
||||
history: Optional[str] = None,
|
||||
) -> str:
|
||||
if history is not None:
|
||||
lst = [*self._history[history], '']
|
||||
lst_pos = len(lst) - 1
|
||||
else:
|
||||
lst = ['']
|
||||
lst_pos = 0
|
||||
|
||||
self.clear()
|
||||
pos = 0
|
||||
buf = ''
|
||||
while True:
|
||||
if not prompt or curses.COLS < 7:
|
||||
prompt_s = ''
|
||||
@@ -246,7 +277,8 @@ class Status:
|
||||
prompt_s = f'{prompt}: '
|
||||
|
||||
width = curses.COLS - len(prompt_s)
|
||||
cmd = f'{prompt_s}{_scrolled_line(buf, pos, width, current=True)}'
|
||||
line = _scrolled_line(lst[lst_pos], pos, width, current=True)
|
||||
cmd = f'{prompt_s}{line}'
|
||||
screen.stdscr.insstr(curses.LINES - 1, 0, cmd, curses.A_REVERSE)
|
||||
line_x = _line_x(pos, width)
|
||||
screen.stdscr.move(curses.LINES - 1, len(prompt_s) + pos - line_x)
|
||||
@@ -257,25 +289,36 @@ class Status:
|
||||
elif key.key == curses.KEY_LEFT:
|
||||
pos = max(0, pos - 1)
|
||||
elif key.key == curses.KEY_RIGHT:
|
||||
pos = min(len(buf), pos + 1)
|
||||
pos = min(len(lst[lst_pos]), pos + 1)
|
||||
elif key.key == curses.KEY_UP:
|
||||
lst_pos = max(0, lst_pos - 1)
|
||||
pos = len(lst[lst_pos])
|
||||
elif key.key == curses.KEY_DOWN:
|
||||
lst_pos = min(len(lst) - 1, lst_pos + 1)
|
||||
pos = len(lst[lst_pos])
|
||||
elif key.key == curses.KEY_HOME or key.keyname == b'^A':
|
||||
pos = 0
|
||||
elif key.key == curses.KEY_END or key.keyname == b'^E':
|
||||
pos = len(buf)
|
||||
pos = len(lst[lst_pos])
|
||||
elif key.key == curses.KEY_BACKSPACE:
|
||||
if pos > 0:
|
||||
buf = buf[:pos - 1] + buf[pos:]
|
||||
lst[lst_pos] = lst[lst_pos][:pos - 1] + lst[lst_pos][pos:]
|
||||
pos -= 1
|
||||
elif key.key == curses.KEY_DC:
|
||||
if pos < len(buf):
|
||||
buf = buf[:pos] + buf[pos + 1:]
|
||||
if pos < len(lst[lst_pos]):
|
||||
lst[lst_pos] = lst[lst_pos][:pos] + lst[lst_pos][pos + 1:]
|
||||
elif isinstance(key.wch, str) and key.wch.isprintable():
|
||||
buf = buf[:pos] + key.wch + buf[pos:]
|
||||
c = key.wch
|
||||
lst[lst_pos] = lst[lst_pos][:pos] + c + lst[lst_pos][pos:]
|
||||
pos += 1
|
||||
elif key.keyname == b'^C':
|
||||
return ''
|
||||
elif key.key == ord('\r'):
|
||||
return buf
|
||||
if history is not None:
|
||||
history_lst = self._history[history]
|
||||
if not history_lst or history_lst[-1] != lst[lst_pos]:
|
||||
history_lst.append(lst[lst_pos])
|
||||
return lst[lst_pos]
|
||||
|
||||
|
||||
def _restore_lines_eof_invariant(lines: MutableSequenceNoSlice) -> None:
|
||||
@@ -959,7 +1002,7 @@ def _edit(screen: Screen) -> EditResult:
|
||||
else:
|
||||
screen.file.go_to_line(lineno, screen.margin)
|
||||
elif key.keyname == b'^W':
|
||||
response = screen.status.prompt(screen, 'search')
|
||||
response = screen.status.prompt(screen, 'search', history='search')
|
||||
if response == '':
|
||||
screen.status.update('cancelled')
|
||||
else:
|
||||
@@ -1007,20 +1050,21 @@ def c_main(stdscr: 'curses._CursesWindow', args: argparse.Namespace) -> None:
|
||||
if args.color_test:
|
||||
return _color_test(stdscr)
|
||||
screen = Screen(stdscr, [File(f) for f in args.filenames or [None]])
|
||||
while screen.files:
|
||||
screen.i = screen.i % len(screen.files)
|
||||
res = _edit(screen)
|
||||
if res == EditResult.EXIT:
|
||||
del screen.files[screen.i]
|
||||
screen.status.clear()
|
||||
elif res == EditResult.NEXT:
|
||||
screen.i += 1
|
||||
screen.status.clear()
|
||||
elif res == EditResult.PREV:
|
||||
screen.i -= 1
|
||||
screen.status.clear()
|
||||
else:
|
||||
raise AssertionError(f'unreachable {res}')
|
||||
with screen.status.save_history():
|
||||
while screen.files:
|
||||
screen.i = screen.i % len(screen.files)
|
||||
res = _edit(screen)
|
||||
if res == EditResult.EXIT:
|
||||
del screen.files[screen.i]
|
||||
screen.status.clear()
|
||||
elif res == EditResult.NEXT:
|
||||
screen.i += 1
|
||||
screen.status.clear()
|
||||
elif res == EditResult.PREV:
|
||||
screen.i -= 1
|
||||
screen.status.clear()
|
||||
else:
|
||||
raise AssertionError(f'unreachable {res}')
|
||||
|
||||
|
||||
def _init_screen() -> 'curses._CursesWindow':
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from typing import List
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from hecate import Runner
|
||||
@@ -10,6 +12,13 @@ from hecate import Runner
|
||||
import babi
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def xdg_data_home(tmpdir):
|
||||
data_home = tmpdir.join('data_home')
|
||||
with mock.patch.dict(os.environ, {'XDG_DATA_HOME': str(data_home)}):
|
||||
yield data_home
|
||||
|
||||
|
||||
def test_list_spy_repr():
|
||||
assert repr(babi.ListSpy(['a', 'b', 'c'])) == "ListSpy(['a', 'b', 'c'])"
|
||||
|
||||
@@ -698,6 +707,83 @@ def test_search_cancel(ten_lines, key):
|
||||
h.await_text('cancelled')
|
||||
|
||||
|
||||
def test_search_history_recorded():
|
||||
with run() as h, and_exit(h):
|
||||
h.press('^W')
|
||||
h.await_text('search:')
|
||||
h.press_and_enter('asdf')
|
||||
h.await_text('no matches')
|
||||
|
||||
h.press('^W')
|
||||
h.press('Up')
|
||||
h.await_text('search: asdf')
|
||||
h.press('BSpace')
|
||||
h.press('test')
|
||||
h.await_text('search: asdtest')
|
||||
h.press('Down')
|
||||
h.await_text_missing('asdtest')
|
||||
h.press('Down') # can't go past the end
|
||||
h.press('Up')
|
||||
h.await_text('asdtest')
|
||||
h.press('Up') # can't go past the beginning
|
||||
h.await_text('asdtest')
|
||||
h.press('enter')
|
||||
h.await_text('no matches')
|
||||
|
||||
h.press('^W')
|
||||
h.press('Up')
|
||||
h.await_text('search: asdtest')
|
||||
h.press('Up')
|
||||
h.await_text('search: asdf')
|
||||
h.press('^C')
|
||||
|
||||
|
||||
def test_search_history_duplicates_dont_repeat():
|
||||
with run() as h, and_exit(h):
|
||||
h.press('^W')
|
||||
h.await_text('search:')
|
||||
h.press_and_enter('search1')
|
||||
h.await_text('no matches')
|
||||
|
||||
h.press('^W')
|
||||
h.press('search2')
|
||||
h.await_text('search:')
|
||||
h.press_and_enter('search2')
|
||||
h.await_text('no matches')
|
||||
|
||||
h.press('^W')
|
||||
h.press('search2')
|
||||
h.await_text('search:')
|
||||
h.press_and_enter('search2')
|
||||
h.await_text('no matches')
|
||||
|
||||
h.press('^W')
|
||||
h.press('Up')
|
||||
h.await_text('search2')
|
||||
h.press('Up')
|
||||
h.await_text('search1')
|
||||
h.press('Enter')
|
||||
|
||||
|
||||
def test_search_history_is_saved_between_sessions(xdg_data_home):
|
||||
with run() as h, and_exit(h):
|
||||
h.press('^W')
|
||||
h.press_and_enter('search1')
|
||||
h.press('^W')
|
||||
h.press_and_enter('search2')
|
||||
|
||||
contents = xdg_data_home.join('babi/history/search').read()
|
||||
assert contents == 'search1\nsearch2\n'
|
||||
|
||||
with run() as h, and_exit(h):
|
||||
h.press('^W')
|
||||
h.press('Up')
|
||||
h.await_text('search: search2')
|
||||
h.press('Up')
|
||||
h.await_text('search: search1')
|
||||
h.press('Enter')
|
||||
|
||||
|
||||
def test_scrolling_arrow_key_movement(ten_lines):
|
||||
with run(str(ten_lines), height=10) as h, and_exit(h):
|
||||
h.await_text('line_7')
|
||||
|
||||
Reference in New Issue
Block a user