From ecece9b98b1bf4ea2326325893d75667bcee00eb Mon Sep 17 00:00:00 2001 From: InsanePrawn Date: Wed, 4 Oct 2023 17:46:11 +0200 Subject: [PATCH 1/9] prompt: add optional path tab-completion --- babi/prompt.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/babi/prompt.py b/babi/prompt.py index a9ede916..0f978da5 100644 --- a/babi/prompt.py +++ b/babi/prompt.py @@ -2,6 +2,8 @@ import curses import enum +import glob +import os from typing import TYPE_CHECKING from babi.horizontal_scrolling import line_x @@ -14,12 +16,20 @@ class Prompt: - def __init__(self, screen: Screen, prompt: str, lst: list[str]) -> None: + def __init__( + self, + screen: Screen, + prompt: str, lst: list[str], + file_glob: bool = False, + ) -> None: self._screen = screen self._prompt = prompt self._lst = lst self._y = len(lst) - 1 self._x = len(self._s) + self._dispatch = Prompt.DISPATCH.copy() + if file_glob: + self._dispatch[b'^I'] = Prompt._file_complete @property def _s(self) -> str: @@ -95,6 +105,17 @@ def _delete(self) -> None: def _cut_to_end(self) -> None: self._s = self._s[:self._x] + def _file_complete(self) -> None: + partial = self._s[:self._x] + completions = glob.glob(partial + '*') + if not completions: + return + common = os.path.commonprefix(completions) + if not common or common == partial: + return + self._s = common + self._s[self._x:] # don't eat text behind cursor + self._x = len(common) + def _resize(self) -> None: self._screen.resize() @@ -180,8 +201,8 @@ def run(self) -> PromptResult | str: self._render_prompt() key = self._screen.get_char() - if key.keyname in Prompt.DISPATCH: - ret = Prompt.DISPATCH[key.keyname](self) + if key.keyname in self._dispatch: + ret = self._dispatch[key.keyname](self) if ret is not None: return ret elif key.keyname == b'STRING': From c6f9ad01b13cb9454031e1d64c95a2edb8870543 Mon Sep 17 00:00:00 2001 From: InsanePrawn Date: Wed, 4 Oct 2023 17:48:14 +0200 Subject: [PATCH 2/9] screen: enable file tab completion when prompting during open and save --- babi/screen.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/babi/screen.py b/babi/screen.py index e26c7026..0c91eddc 100644 --- a/babi/screen.py +++ b/babi/screen.py @@ -397,6 +397,7 @@ def prompt( history: str | None = None, default_prev: bool = False, default: str | None = None, + file_glob: bool = False, ) -> str | PromptResult: default = default or '' self.status.clear() @@ -407,7 +408,7 @@ def prompt( else: history_data = [default] - ret = Prompt(self, prompt, history_data).run() + ret = Prompt(self, prompt, history_data, file_glob=file_glob).run() if ret is not PromptResult.CANCELLED and history is not None: if ret: # only put non-empty things in history @@ -720,7 +721,9 @@ def save(self) -> PromptResult | None: # TODO: strip trailing whitespace? # TODO: save atomically? if self.file.filename is None: - filename = self.prompt('enter filename') + filename = self.prompt( + 'enter filename', default=self.file.filename, file_glob=True, + ) if filename is PromptResult.CANCELLED: return PromptResult.CANCELLED else: @@ -762,7 +765,9 @@ def save(self) -> PromptResult | None: return None def save_filename(self) -> PromptResult | None: - response = self.prompt('enter filename', default=self.file.filename) + response = self.prompt( + 'enter filename', default=self.file.filename, file_glob=True, + ) if response is PromptResult.CANCELLED: return PromptResult.CANCELLED else: @@ -770,7 +775,9 @@ def save_filename(self) -> PromptResult | None: return self.save() def open_file(self) -> EditResult | None: - response = self.prompt('enter filename', history='open') + response = self.prompt( + 'enter filename', history='open', file_glob=True, + ) if response is not PromptResult.CANCELLED: opened = File( response, From 3d5d4986cd42da1ccb3188d2c2054d8960cc555b Mon Sep 17 00:00:00 2001 From: InsanePrawn Date: Wed, 4 Oct 2023 18:21:29 +0200 Subject: [PATCH 3/9] tests: test for file tab completion in open_test --- tests/features/open_test.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/features/open_test.py b/tests/features/open_test.py index 10e38b6f..63aac683 100644 --- a/tests/features/open_test.py +++ b/tests/features/open_test.py @@ -35,6 +35,37 @@ def test_open(run, tmpdir): h.press('^X') h.await_text('hello world') + h.press('^X') + h.await_exit() + + +def test_file_glob(run, tmpdir): + base = 'globtest' + prefix = base + 'ffff.txt' + f = tmpdir.join(prefix + 'f') + f.write('hello world') + g = tmpdir.join(base + 'fggg') + g.write('goodbye world') + nonexistant = str(tmpdir.join('NONEXISTANT')) + + with run(str(g)) as h: + h.await_text('goodbye world') + h.press('^P') + h.press(nonexistant) + h.press('Tab') + h.await_text(f'«{nonexistant[-7:]}') + h.press('^C') + h.await_text('cancelled') + h.press('^P') + h.press(str(tmpdir.join(base + 'fff'))) + h.press('Tab') + h.await_text(str(f)[-7:]) + h.press('Tab') # second tab shouldn't change anything + h.await_text(str(f)[-7:]) + h.press('Enter') + h.await_text('[2/2]') + h.await_text('hello world') + h.press('^X') h.press('^X') h.await_exit() From 77943fccf7563f80da676ebd68f3f2a65bca6846 Mon Sep 17 00:00:00 2001 From: InsanePrawn Date: Fri, 6 Oct 2023 15:58:14 +0200 Subject: [PATCH 4/9] prompt: add permanent _tab() handler --- babi/prompt.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/babi/prompt.py b/babi/prompt.py index 0f978da5..2511b316 100644 --- a/babi/prompt.py +++ b/babi/prompt.py @@ -20,16 +20,15 @@ def __init__( self, screen: Screen, prompt: str, lst: list[str], - file_glob: bool = False, + *, + file_glob: bool, ) -> None: self._screen = screen self._prompt = prompt self._lst = lst self._y = len(lst) - 1 self._x = len(self._s) - self._dispatch = Prompt.DISPATCH.copy() - if file_glob: - self._dispatch[b'^I'] = Prompt._file_complete + self._enable_file_complete = file_glob @property def _s(self) -> str: @@ -105,7 +104,11 @@ def _delete(self) -> None: def _cut_to_end(self) -> None: self._s = self._s[:self._x] - def _file_complete(self) -> None: + def _tab(self) -> None: + if self._enable_file_complete: + self._complete_file() + + def _complete_file(self) -> None: partial = self._s[:self._x] completions = glob.glob(partial + '*') if not completions: @@ -186,6 +189,7 @@ def _submit(self) -> str: b'KEY_DC': _delete, b'^K': _cut_to_end, # misc + b'^I': _tab, b'KEY_RESIZE': _resize, b'^R': _reverse_search, b'^M': _submit, @@ -201,8 +205,8 @@ def run(self) -> PromptResult | str: self._render_prompt() key = self._screen.get_char() - if key.keyname in self._dispatch: - ret = self._dispatch[key.keyname](self) + if key.keyname in Prompt.DISPATCH: + ret = Prompt.DISPATCH[key.keyname](self) if ret is not None: return ret elif key.keyname == b'STRING': From bd003e142482cfb4cff8745b2c75d6d3d4d1c1a4 Mon Sep 17 00:00:00 2001 From: InsanePrawn Date: Fri, 6 Oct 2023 16:11:25 +0200 Subject: [PATCH 5/9] prompt: only allow file completion at well-defined points --- babi/prompt.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/babi/prompt.py b/babi/prompt.py index 2511b316..13a128fc 100644 --- a/babi/prompt.py +++ b/babi/prompt.py @@ -109,8 +109,11 @@ def _tab(self) -> None: self._complete_file() def _complete_file(self) -> None: + # only allow completion at the end of the prompt or before a separator + if self._x != len(self._s) and self._s[self._x] not in ('/', ' '): + return partial = self._s[:self._x] - completions = glob.glob(partial + '*') + completions = glob.glob(f'{partial}*') if not completions: return common = os.path.commonprefix(completions) From 054155928bbaab80059b32b0139afb9d985a15ed Mon Sep 17 00:00:00 2001 From: InsanePrawn Date: Fri, 6 Oct 2023 16:23:28 +0200 Subject: [PATCH 6/9] screen.prompt(): make file_glob mandatory --- babi/screen.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/babi/screen.py b/babi/screen.py index 0c91eddc..fba18aed 100644 --- a/babi/screen.py +++ b/babi/screen.py @@ -393,11 +393,11 @@ def prompt( self, prompt: str, *, + file_glob: bool, allow_empty: bool = False, history: str | None = None, default_prev: bool = False, default: str | None = None, - file_glob: bool = False, ) -> str | PromptResult: default = default or '' self.status.clear() @@ -425,7 +425,7 @@ def prompt( return ret def go_to_line(self) -> None: - response = self.prompt('enter line number') + response = self.prompt('enter line number', file_glob=False) if response is not PromptResult.CANCELLED: try: lineno = int(response) @@ -456,7 +456,9 @@ def uncut(self) -> None: self.file.uncut(self.cut_buffer, self.layout.file) def _get_search_re(self, prompt: str) -> Pattern[str] | PromptResult: - response = self.prompt(prompt, history='search', default_prev=True) + response = self.prompt( + prompt, history='search', default_prev=True, file_glob=False, + ) if response is PromptResult.CANCELLED: return response try: @@ -495,7 +497,10 @@ def replace(self) -> None: search_response = self._get_search_re('search (to replace)') if search_response is not PromptResult.CANCELLED: response = self.prompt( - 'replace with', history='replace', allow_empty=True, + 'replace with', + history='replace', + allow_empty=True, + file_glob=False, ) if response is not PromptResult.CANCELLED: try: @@ -686,7 +691,7 @@ def _command_retheme(self, args: list[str]) -> None: } def command(self) -> EditResult | None: - response = self.prompt('', history='command') + response = self.prompt('', history='command', file_glob=False) if response is PromptResult.CANCELLED: return None From 9fae6553a597f284fd9c7575ffbc4297ff8c2985 Mon Sep 17 00:00:00 2001 From: InsanePrawn Date: Fri, 6 Oct 2023 19:53:12 +0200 Subject: [PATCH 7/9] adapt tests --- tests/features/open_test.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/features/open_test.py b/tests/features/open_test.py index 63aac683..33207ecf 100644 --- a/tests/features/open_test.py +++ b/tests/features/open_test.py @@ -48,21 +48,39 @@ def test_file_glob(run, tmpdir): g.write('goodbye world') nonexistant = str(tmpdir.join('NONEXISTANT')) + incomplete = f'{tmpdir.join(base)}fff' + with run(str(g)) as h: h.await_text('goodbye world') h.press('^P') h.press(nonexistant) h.press('Tab') + # no completion should be possible h.await_text(f'«{nonexistant[-7:]}') h.press('^C') h.await_text('cancelled') + h.press('^P') - h.press(str(tmpdir.join(base + 'fff'))) + h.press(incomplete) + h.await_text(incomplete[-7:]) + + # completion inside a word should be blocked + h.press('Left') + h.press('Tab') + h.await_text(incomplete[-7:]) + + # move to end of input again + h.press('Right') + + # check successful completion h.press('Tab') h.await_text(str(f)[-7:]) - h.press('Tab') # second tab shouldn't change anything + + # second tab press shouldn't change anything + h.press('Tab') h.await_text(str(f)[-7:]) + h.press('Enter') h.await_text('[2/2]') h.await_text('hello world') From d3a5bd8c44bea5c8cfda95be5b41458d0a5ee27c Mon Sep 17 00:00:00 2001 From: InsanePrawn Date: Fri, 6 Oct 2023 20:40:27 +0200 Subject: [PATCH 8/9] add search test to check that globbing is disabled --- tests/features/search_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/features/search_test.py b/tests/features/search_test.py index a3c9e8e3..2d6d3389 100644 --- a/tests/features/search_test.py +++ b/tests/features/search_test.py @@ -365,3 +365,17 @@ def test_search_history_extra_blank_lines(run, xdg_data_home): pass contents = xdg_data_home.join('babi/history/search').read() assert contents == 'hello\n' + + +def test_search_fileglob_disabled(run, tmpdir): + filename = 'aaaa.txt' + query = str(tmpdir.join(filename[0])) + f = tmpdir.join(filename) + f.write("") + with run() as h, and_exit(h): + h.press('^W') + h.press(query) + h.press('Tab') + h.await_text(query[-7:]) + h.await_text_missing(str(f)[-7:]) + h.press('^C') From 4dffbc909d1e87bc1bae0976c12a7fa9ffa61646 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 6 Oct 2023 18:42:49 +0000 Subject: [PATCH 9/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/features/search_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/features/search_test.py b/tests/features/search_test.py index 2d6d3389..80acea29 100644 --- a/tests/features/search_test.py +++ b/tests/features/search_test.py @@ -371,7 +371,7 @@ def test_search_fileglob_disabled(run, tmpdir): filename = 'aaaa.txt' query = str(tmpdir.join(filename[0])) f = tmpdir.join(filename) - f.write("") + f.write('') with run() as h, and_exit(h): h.press('^W') h.press(query)