Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tab completion for file paths in save/open prompts #323

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
30 changes: 29 additions & 1 deletion babi/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import curses
import enum
import glob
import os
from typing import TYPE_CHECKING

from babi.horizontal_scrolling import line_x
Expand All @@ -14,12 +16,19 @@


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,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is maybe not a good name -- it's really a flag for whether tab completion of files occurs -- and can probably default to False

) -> None:
self._screen = screen
self._prompt = prompt
self._lst = lst
self._y = len(lst) - 1
self._x = len(self._s)
self._enable_file_complete = file_glob

@property
def _s(self) -> str:
Expand Down Expand Up @@ -95,6 +104,24 @@ def _delete(self) -> None:
def _cut_to_end(self) -> None:
self._s = self._s[:self._x]

def _tab(self) -> None:
if self._enable_file_complete:
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]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't seem quite right -- it's going to take imagine foo.txt is on disk and you've written fo][od and you tab you're going to get foo.txtod which is nonsensical. I think it should really only listen to tab if you're at the end of the string

Copy link
Author

@InsanePrawn InsanePrawn Oct 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes, this is what I want, and I use this with tools that allow it.
Example: so][/foo.txt -> someLongFolder/foo.txt

This is especially useful to me to create a new file in an existing folder.

I understand this is a question of preference, I'd personally prefer this behaviour to stay as is.

[I usually add a space to the right of the cursor before hitting tab for visual separation and then delete it after completion has done its job, but that uses the same mechanic. I doubt we'd want to check for whitespace after the cursor and make it conditional]

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in that case it could listen to tab if it is at the end of the string or before a path separator I guess?

completions = glob.glob(f'{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()

Expand Down Expand Up @@ -165,6 +192,7 @@ def _submit(self) -> str:
b'KEY_DC': _delete,
b'^K': _cut_to_end,
# misc
b'^I': _tab,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this belongs with editing

b'KEY_RESIZE': _resize,
b'^R': _reverse_search,
b'^M': _submit,
Expand Down
28 changes: 20 additions & 8 deletions babi/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ def prompt(
self,
prompt: str,
*,
file_glob: bool,
allow_empty: bool = False,
history: str | None = None,
default_prev: bool = False,
Expand All @@ -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
Expand All @@ -424,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)
Expand Down Expand Up @@ -455,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:
Expand Down Expand Up @@ -494,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:
Expand Down Expand Up @@ -685,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

Expand Down Expand Up @@ -720,7 +726,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,
)
Comment on lines +729 to +731
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this changes the behaviour. these shouldn't have a default

if filename is PromptResult.CANCELLED:
return PromptResult.CANCELLED
else:
Expand Down Expand Up @@ -762,15 +770,19 @@ 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:
self.file.filename = response
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,
Expand Down
49 changes: 49 additions & 0 deletions tests/features/open_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,55 @@ 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'
Comment on lines +43 to +44
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than variables I'd rather see the actual strings in the assertions -- rather than having to hunt around for what is actually being tested the expressions should be clear in tests. https://testing.googleblog.com/2014/07/testing-on-toilet-dont-put-logic-in.html

f = tmpdir.join(prefix + 'f')
f.write('hello world')
g = tmpdir.join(base + 'fggg')
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(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:])

# 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')
h.press('^X')
h.press('^X')
h.await_exit()
14 changes: 14 additions & 0 deletions tests/features/search_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')