diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index 31c329595..868d3c4d4 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -5,7 +5,11 @@ from commitizen import defaults from commitizen.cz.base import BaseCommitizen -from commitizen.cz.utils import multiple_line_breaker, required_validator +from commitizen.cz.utils import ( + get_multiline_key_bindings, + multiple_line_breaker, + required_validator, +) if TYPE_CHECKING: from commitizen.question import CzQuestion @@ -133,6 +137,8 @@ def questions(self) -> list[CzQuestion]: "Provide additional contextual information about the code changes: (press [enter] to skip)\n" ), "filter": multiple_line_breaker, + "multiline": True, + "key_bindings": get_multiline_key_bindings(), }, { "type": "confirm", diff --git a/commitizen/cz/utils.py b/commitizen/cz/utils.py index ba5eace44..2f7fa7838 100644 --- a/commitizen/cz/utils.py +++ b/commitizen/cz/utils.py @@ -1,14 +1,33 @@ import os import re import tempfile +from functools import lru_cache from pathlib import Path +from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent +from prompt_toolkit.keys import Keys + from commitizen import git from commitizen.cz import exceptions _RE_LOCAL_VERSION = re.compile(r"\+.+") +@lru_cache(maxsize=1) +def get_multiline_key_bindings() -> KeyBindings: + kb = KeyBindings() + + @kb.add(Keys.Enter) + def handle_enter(event: KeyPressEvent) -> None: + buff = event.app.current_buffer + if buff.text == "": + buff.validate_and_handle() + else: + buff.insert_text("\n") + + return kb + + def required_validator(answer: str, msg: object = None) -> str: if not answer: raise exceptions.AnswerRequiredError(msg) diff --git a/commitizen/question.py b/commitizen/question.py index 043b8f3ba..cadbd038d 100644 --- a/commitizen/question.py +++ b/commitizen/question.py @@ -1,6 +1,8 @@ from collections.abc import Callable from typing import Literal, TypedDict +from prompt_toolkit.key_binding import KeyBindings + class Choice(TypedDict, total=False): value: str @@ -21,6 +23,8 @@ class InputQuestion(TypedDict, total=False): name: str message: str filter: Callable[[str], str] + multiline: bool + key_bindings: KeyBindings class ConfirmQuestion(TypedDict): diff --git a/docs/images/cli_interactive/bump.gif b/docs/images/cli_interactive/bump.gif index 56fe61fda..82129aaac 100644 Binary files a/docs/images/cli_interactive/bump.gif and b/docs/images/cli_interactive/bump.gif differ diff --git a/docs/images/cli_interactive/commit.gif b/docs/images/cli_interactive/commit.gif index 12014a199..4671a06aa 100644 Binary files a/docs/images/cli_interactive/commit.gif and b/docs/images/cli_interactive/commit.gif differ diff --git a/docs/images/cli_interactive/init.gif b/docs/images/cli_interactive/init.gif index 3c3e3a315..437b96643 100644 Binary files a/docs/images/cli_interactive/init.gif and b/docs/images/cli_interactive/init.gif differ diff --git a/docs/images/cli_interactive/shortcut_custom.gif b/docs/images/cli_interactive/shortcut_custom.gif index aea66dd0d..d7c3fff9a 100644 Binary files a/docs/images/cli_interactive/shortcut_custom.gif and b/docs/images/cli_interactive/shortcut_custom.gif differ diff --git a/docs/images/cli_interactive/shortcut_default.gif b/docs/images/cli_interactive/shortcut_default.gif index 0330e2088..38ebb6264 100644 Binary files a/docs/images/cli_interactive/shortcut_default.gif and b/docs/images/cli_interactive/shortcut_default.gif differ diff --git a/tests/test_cz_conventional_commits.py b/tests/test_cz_conventional_commits.py index fc78b3fd4..f99dafb56 100644 --- a/tests/test_cz_conventional_commits.py +++ b/tests/test_cz_conventional_commits.py @@ -1,4 +1,5 @@ import pytest +from prompt_toolkit.key_binding import KeyBindings from commitizen.cz.conventional_commits.conventional_commits import ( ConventionalCommitsCz, @@ -173,3 +174,10 @@ def test_info(config): conventional_commits = ConventionalCommitsCz(config) info = conventional_commits.info() assert isinstance(info, str) + + +def test_body_question_is_multiline(config): + cz = ConventionalCommitsCz(config) + body_question = next(q for q in cz.questions() if q["name"] == "body") + assert body_question["multiline"] is True + assert isinstance(body_question["key_bindings"], KeyBindings) diff --git a/tests/test_cz_utils.py b/tests/test_cz_utils.py index 25c960c9a..64e2e5a07 100644 --- a/tests/test_cz_utils.py +++ b/tests/test_cz_utils.py @@ -1,9 +1,38 @@ import pytest +from prompt_toolkit.keys import Keys from pytest_mock import MockFixture from commitizen.cz import exceptions, utils +def test_multiline_key_bindings_enter_submits_on_empty(mocker: MockFixture): + kb = utils.get_multiline_key_bindings() + handler = next(b.handler for b in kb.bindings if Keys.Enter in b.keys) + + buff = mocker.MagicMock() + buff.text = "" + event = mocker.MagicMock() + event.app.current_buffer = buff + + handler(event) + buff.validate_and_handle.assert_called_once() + buff.insert_text.assert_not_called() + + +def test_multiline_key_bindings_enter_inserts_newline(mocker: MockFixture): + kb = utils.get_multiline_key_bindings() + handler = next(b.handler for b in kb.bindings if Keys.Enter in b.keys) + + buff = mocker.MagicMock() + buff.text = "some content" + event = mocker.MagicMock() + event.app.current_buffer = buff + + handler(event) + buff.insert_text.assert_called_once_with("\n") + buff.validate_and_handle.assert_not_called() + + def test_required_validator(): assert utils.required_validator("test") == "test"