diff --git a/tests/test_types.py b/tests/test_types.py index a322d21e..601d8d79 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -43,6 +43,18 @@ class TestCorrectedCommand(object): out, _ = capsys.readouterr() assert out == printed + def test_run_with_edit(self, capsys, monkeypatch, mocker): + script = "git branch" + edit_tpl = 'editor "{}"' + monkeypatch.setattr( + 'thefuck.types.shell.edit_command', + lambda script: edit_tpl.format(script), + ) + command = CorrectedCommand(script, None, 1000).edit() + command.run(Command(script, '')) + out, _ = capsys.readouterr() + assert out[:-1] == edit_tpl.format(script) + class TestRule(object): def test_from_path_rule_exception(self, mocker): diff --git a/tests/test_ui.py b/tests/test_ui.py index 1e539651..e88d0605 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -16,12 +16,15 @@ def patch_get_key(monkeypatch): return patch -def test_read_actions(patch_get_key): +@pytest.mark.parametrize("shell_can_edit", [True, False]) +def test_read_actions(shell_can_edit, patch_get_key): patch_get_key([ # Enter: '\n', # Enter: '\r', + # Edit: + const.KEY_BACKSPACE, 'd', # Ignored: 'x', 'y', # Up: @@ -30,11 +33,17 @@ def test_read_actions(patch_get_key): const.KEY_DOWN, 'j', # Ctrl+C: const.KEY_CTRL_C, 'q']) - assert (list(islice(ui.read_actions(), 8)) - == [const.ACTION_SELECT, const.ACTION_SELECT, - const.ACTION_PREVIOUS, const.ACTION_PREVIOUS, - const.ACTION_NEXT, const.ACTION_NEXT, - const.ACTION_ABORT, const.ACTION_ABORT]) + expected_actions = [const.ACTION_SELECT, const.ACTION_SELECT, + const.ACTION_PREVIOUS, const.ACTION_PREVIOUS, + const.ACTION_NEXT, const.ACTION_NEXT, + const.ACTION_ABORT, const.ACTION_ABORT] + number_of_items = 8 + if shell_can_edit: + expected_actions.insert(2, const.ACTION_EDIT) + expected_actions.insert(2, const.ACTION_EDIT) + number_of_items = 10 + assert (list(islice(ui.read_actions(shell_can_edit), number_of_items)) + == expected_actions) def test_command_selector(): @@ -106,3 +115,14 @@ class TestSelectCommand(object): u'{mark}\x1b[1K\rcd [enter/↑/↓/ctrl+c]\n' ).format(mark=const.USER_COMMAND_MARK) assert capsys.readouterr() == ('', stderr) + + def test_with_edit(self, capsys, patch_get_key, commands, monkeypatch): + monkeypatch.setattr('thefuck.ui.shell.can_edit', lambda: True) + patch_get_key([const.KEY_BACKSPACE, '\n']) + command = ui.select_command(iter(commands)) + assert command == commands[0] + assert command.should_edit is True + stderr = ( + u'{mark}\x1b[1K\rls [enter/edit/↑/↓/ctrl+c]\n' + ).format(mark=const.USER_COMMAND_MARK) + assert capsys.readouterr() == ('', stderr) diff --git a/thefuck/const.py b/thefuck/const.py index 8d339264..312072cc 100644 --- a/thefuck/const.py +++ b/thefuck/const.py @@ -14,15 +14,18 @@ KEY_DOWN = _GenConst('↓') KEY_CTRL_C = _GenConst('Ctrl+C') KEY_CTRL_N = _GenConst('Ctrl+N') KEY_CTRL_P = _GenConst('Ctrl+P') +KEY_BACKSPACE = _GenConst('Backspace') KEY_MAPPING = {'\x0e': KEY_CTRL_N, '\x03': KEY_CTRL_C, - '\x10': KEY_CTRL_P} + '\x10': KEY_CTRL_P, + '\x7f': KEY_BACKSPACE} ACTION_SELECT = _GenConst('select') ACTION_ABORT = _GenConst('abort') ACTION_PREVIOUS = _GenConst('previous') ACTION_NEXT = _GenConst('next') +ACTION_EDIT = _GenConst('edit') ALL_ENABLED = _GenConst('All rules enabled') DEFAULT_RULES = [ALL_ENABLED] diff --git a/thefuck/logs.py b/thefuck/logs.py index e064de67..0ba46ba3 100644 --- a/thefuck/logs.py +++ b/thefuck/logs.py @@ -56,10 +56,19 @@ def show_corrected_command(corrected_command): reset=color(colorama.Style.RESET_ALL))) -def confirm_text(corrected_command): +def edit_part(show_edit): + if show_edit: + return '/{yellow}e{bold}d{normal}it'.format( + yellow=color(colorama.Fore.YELLOW), + bold=color(colorama.Style.BRIGHT), + normal=color(colorama.Style.NORMAL)) + return '' + + +def confirm_text(corrected_command, show_edit): sys.stderr.write( (u'{prefix}{clear}{bold}{script}{reset}{side_effect} ' - u'[{green}enter{reset}/{blue}↑{reset}/{blue}↓{reset}' + u'[{green}enter{reset}{edit}{reset}/{blue}↑{reset}/{blue}↓{reset}' u'/{red}ctrl+c{reset}]').format( prefix=const.USER_COMMAND_MARK, script=corrected_command.script, @@ -69,7 +78,8 @@ def confirm_text(corrected_command): green=color(colorama.Fore.GREEN), red=color(colorama.Fore.RED), reset=color(colorama.Style.RESET_ALL), - blue=color(colorama.Fore.BLUE))) + blue=color(colorama.Fore.BLUE), + edit=edit_part(show_edit))) def debug(msg): diff --git a/thefuck/shells/bash.py b/thefuck/shells/bash.py index fa7a2072..73093008 100644 --- a/thefuck/shells/bash.py +++ b/thefuck/shells/bash.py @@ -65,6 +65,9 @@ class Bash(Generic): return dict(self._parse_alias(alias) for alias in raw_aliases if alias and '=' in alias) + def can_edit(self): + return True + def _get_history_file_name(self): return os.environ.get("HISTFILE", os.path.expanduser('~/.bash_history')) diff --git a/thefuck/shells/fish.py b/thefuck/shells/fish.py index eb7e9153..a69a53f9 100644 --- a/thefuck/shells/fish.py +++ b/thefuck/shells/fish.py @@ -127,3 +127,10 @@ class Fish(Generic): history.write(entry.encode('utf-8')) else: history.write(entry) + + def can_edit(self): + return True + + def edit_command(self, command): + """Return the shell editable command""" + return u'commandline -r "{}"'.format(command) diff --git a/thefuck/shells/generic.py b/thefuck/shells/generic.py index aa81e2ac..d542bfea 100644 --- a/thefuck/shells/generic.py +++ b/thefuck/shells/generic.py @@ -2,6 +2,7 @@ import io import os import shlex import six +import tempfile from collections import namedtuple from ..logs import warn from ..utils import memoize @@ -121,6 +122,33 @@ class Generic(object): """ + def can_edit(self): + return False + + def edit_command(self, command): + """Spawn default editor (or `vi` if not set) and edit command in a buffer""" + # Create a temporary file and write some default text + # mktemp somewhere + + editor = os.getenv("EDITOR", "vi") + + tf = tempfile.NamedTemporaryFile( + prefix="the_fuck-command_edit__", + suffix=".tmp", + delete=False) + tf.write(command.encode('utf8')) + tf.close() + + os.system(u"{} '{}' >/dev/tty".format(editor, tf.name)) + + tf = open(tf.name, 'r') + edited_message = tf.read() + tf.close() + + os.unlink(tf.name) + + return edited_message + def get_builtin_commands(self): """Returns shells builtin commands.""" return ['alias', 'bg', 'bind', 'break', 'builtin', 'case', 'cd', diff --git a/thefuck/shells/zsh.py b/thefuck/shells/zsh.py index e1fdf207..4a2da32b 100644 --- a/thefuck/shells/zsh.py +++ b/thefuck/shells/zsh.py @@ -64,6 +64,9 @@ class Zsh(Generic): value = value[1:-1] return name, value + def can_edit(self): + return True + @memoize def get_aliases(self): raw_aliases = os.environ.get('TF_SHELL_ALIASES', '').split('\n') diff --git a/thefuck/types.py b/thefuck/types.py index b3b64c35..5caa3303 100644 --- a/thefuck/types.py +++ b/thefuck/types.py @@ -212,6 +212,7 @@ class CorrectedCommand(object): self.script = script self.side_effect = side_effect self.priority = priority + self.should_edit = False def __eq__(self, other): """Ignores `priority` field.""" @@ -235,6 +236,9 @@ class CorrectedCommand(object): of running fuck in case fixed command fails again. """ + if self.should_edit: + self.script = shell.edit_command(self.script) + if settings.repeat: repeat_fuck = '{} --repeat {}--force-command {}'.format( get_alias(), @@ -244,6 +248,10 @@ class CorrectedCommand(object): else: return self.script + def edit(self): + self.should_edit = True + return self + def run(self, old_cmd): """Runs command from rule for passed command. diff --git a/thefuck/ui.py b/thefuck/ui.py index 9c05db33..573fb36e 100644 --- a/thefuck/ui.py +++ b/thefuck/ui.py @@ -3,21 +3,24 @@ import sys from .conf import settings from .exceptions import NoRuleMatched +from .shells import shell from .system import get_key from .utils import get_alias from . import logs, const -def read_actions(): +def read_actions(can_edit): """Yields actions for pressed keys.""" while True: key = get_key() - # Handle arrows, j/k (qwerty), and n/e (colemak) + # Handle arrows, edit, j/k (qwerty), and n/e (colemak) if key in (const.KEY_UP, const.KEY_CTRL_N, 'k', 'e'): yield const.ACTION_PREVIOUS elif key in (const.KEY_DOWN, const.KEY_CTRL_P, 'j', 'n'): yield const.ACTION_NEXT + elif can_edit and key in (const.KEY_BACKSPACE, 'd'): + yield const.ACTION_EDIT elif key in (const.KEY_CTRL_C, 'q'): yield const.ACTION_ABORT elif key in ('\n', '\r'): @@ -78,18 +81,21 @@ def select_command(corrected_commands): logs.show_corrected_command(selector.value) return selector.value - logs.confirm_text(selector.value) + logs.confirm_text(selector.value, shell.can_edit()) - for action in read_actions(): + for action in read_actions(shell.can_edit()): if action == const.ACTION_SELECT: sys.stderr.write('\n') return selector.value + elif action == const.ACTION_EDIT: + sys.stderr.write('\n') + return selector.value.edit() elif action == const.ACTION_ABORT: logs.failed('\nAborted') return elif action == const.ACTION_PREVIOUS: selector.previous() - logs.confirm_text(selector.value) + logs.confirm_text(selector.value, shell.can_edit()) elif action == const.ACTION_NEXT: selector.next() - logs.confirm_text(selector.value) + logs.confirm_text(selector.value, shell.can_edit())