diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py index 03357c0..b6fd366 100644 --- a/lib/devbase/commands/project.py +++ b/lib/devbase/commands/project.py @@ -190,13 +190,16 @@ def _start_project_up(name: str) -> int: return _start_project_action(name, "up") -def _with_escape_cancel(question): - """questionary の select に Esc 単独押下での中止を後付けする。 +# サブメニュー (_show_action_menu) で Esc を押した際の「トップメニューへ戻る」 +# シグナル。``None`` (= Ctrl-C による全体中止) と区別するための番兵。 +_MENU_BACK = object() - questionary 2.x の select は Ctrl-C / Ctrl-Q しか中止に割り当てないため、 - 生成済み ``Question.application`` の key_bindings に Escape ハンドラを足す。 - Ctrl-C と同じく ``KeyboardInterrupt`` で抜けるので ``ask()`` は ``None`` - (= 中止) を返す。 + +def _add_escape_binding(question, handler): + """questionary の select に Esc 単独押下のハンドラを後付けする共通処理。 + + questionary 2.x の select は Ctrl-C / Ctrl-Q しか割り当てないため、生成済み + ``Question.application`` の key_bindings に Escape ハンドラを足す。 Escape は矢印キー等のエスケープシーケンス (``\\x1b[A`` 等) の先頭バイトでも あるため、``eager=False`` で登録し prompt_toolkit のフラッシュ待ちで単独 Esc @@ -204,11 +207,32 @@ def _with_escape_cancel(question): """ from prompt_toolkit.keys import Keys - @question.application.key_bindings.add(Keys.Escape) - def _(event): + question.application.key_bindings.add(Keys.Escape)(handler) + return question + + +def _with_escape_cancel(question): + """Esc 単独押下で中止する select を返す。 + + Ctrl-C と同じく ``KeyboardInterrupt`` で抜けるので ``ask()`` は ``None`` + (= 中止) を返す。トップメニュー (戻り先が無い) 用。 + """ + def _cancel(event): event.app.exit(exception=KeyboardInterrupt, style="class:aborting") - return question + return _add_escape_binding(question, _cancel) + + +def _with_escape_back(question): + """Esc 単独押下で ``_MENU_BACK`` を返す select を返す。 + + Ctrl-C は questionary 既定どおり中止 (``ask()`` が ``None``) のまま残し、Esc + だけを「1 つ前のメニューへ戻る」シグナルに割り当てる。サブメニュー用。 + """ + def _back(event): + event.app.exit(result=_MENU_BACK) + + return _add_escape_binding(question, _back) def _show_menu(rows: list[dict]) -> int | None: @@ -230,11 +254,15 @@ def _show_menu(rows: list[dict]) -> int | None: return _with_escape_cancel(question).ask() # value (= rows index) / 中止時 None -def _show_action_menu(name: str) -> str | None: +def _show_action_menu(name: str): """running 中プロジェクトの操作 (up/rebuild/down) を選ぶサブメニュー。 - 選択された action 文字列 (``"up"`` / ``"rebuild"`` / ``"down"``) を返す。 - 中止 (Esc / Ctrl-C) 時は None。テストではこの関数を monkeypatch する。 + 戻り値: + - action 文字列 (``"up"`` / ``"rebuild"`` / ``"down"``): 操作を選択 + - ``_MENU_BACK``: Esc 押下 → トップメニューへ戻る + - ``None``: Ctrl-C 押下 → 全体中止 + + テストではこの関数を monkeypatch する。 """ choices = [ questionary.Choice(title="再起動 (up)", value="up"), @@ -242,12 +270,13 @@ def _show_action_menu(name: str) -> str | None: questionary.Choice(title="停止 (down)", value="down"), ] question = questionary.select( - f"'{name}' は起動中です。操作を選択 (↑↓ 移動 / Enter 決定 / Esc・Ctrl-C 中止):", + f"'{name}' は起動中です。操作を選択 " + "(↑↓ 移動 / Enter 決定 / Esc 戻る / Ctrl-C 中止):", choices=choices, use_arrow_keys=True, use_shortcuts=False, ) - return _with_escape_cancel(question).ask() + return _with_escape_back(question).ask() def _tui_select_and_up(rows: list[dict]) -> int: @@ -255,22 +284,26 @@ def _tui_select_and_up(rows: list[dict]) -> int: 選択行が running 中なら ``_show_action_menu`` で up/rebuild/down を選ばせ、 それ以外 (stopped / unknown 等) は従来どおり直接 ``project up`` を起動する。 + サブメニューで Esc を押すと (``_MENU_BACK``) トップメニューへ戻る。 """ - idx = _show_menu(rows) - if idx is None: - logger.info("中止しました。") - return 0 - - row = rows[idx] - name = row["name"] - if str(row.get("status", "")).startswith("running"): - action = _show_action_menu(name) - if action is None: + while True: + idx = _show_menu(rows) + if idx is None: logger.info("中止しました。") return 0 - return _start_project_action(name, action) - return _start_project_action(name, "up") + row = rows[idx] + name = row["name"] + if str(row.get("status", "")).startswith("running"): + action = _show_action_menu(name) + if action is _MENU_BACK: + continue # Esc → トップメニューへ戻る + if action is None: + logger.info("中止しました。") # Ctrl-C → 全体中止 + return 0 + return _start_project_action(name, action) + + return _start_project_action(name, "up") def _interactive_select_and_up(rows: list[dict]) -> int: diff --git a/tests/cli/test_project_list.py b/tests/cli/test_project_list.py index afd7530..e6180af 100644 --- a/tests/cli/test_project_list.py +++ b/tests/cli/test_project_list.py @@ -769,8 +769,8 @@ def test_with_escape_cancel_registers_escape_binding(): lambda m: m._show_menu([{"name": "carmo", "plugin": "-", "status": "stopped"}]), lambda m: m._show_action_menu("carmo"), ]) -def test_select_menus_wire_escape_cancel(monkeypatch, call): - """_show_menu / _show_action_menu が select に Esc 中止を仕込んでから ask する。""" +def test_select_menus_wire_escape_binding(monkeypatch, call): + """_show_menu / _show_action_menu が select に Esc バインドを仕込んでから ask する。""" pytest.importorskip("questionary") from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys @@ -786,7 +786,59 @@ def test_select_menus_wire_escape_cancel(monkeypatch, call): assert call(project_mod) == "sentinel" esc = [b for b in kb.bindings if Keys.Escape in b.keys] - assert len(esc) == 1, "Esc 中止バインドが登録されていない" + assert len(esc) == 1, "Esc バインドが登録されていない" + + +def test_with_escape_back_returns_sentinel_on_escape(): + """_with_escape_back の Esc ハンドラは _MENU_BACK を result として返すこと。""" + questionary = pytest.importorskip("questionary") + from prompt_toolkit.keys import Keys + + from devbase.commands import project as project_mod + + q = questionary.select("t", choices=[questionary.Choice(title="a", value="a")]) + assert project_mod._with_escape_back(q) is q + + esc = [b for b in q.application.key_bindings.bindings if Keys.Escape in b.keys] + assert len(esc) == 1 + assert esc[0].eager() is False # 矢印キーのエスケープシーケンスと衝突させない + + # ハンドラは Ctrl-C (KeyboardInterrupt=全体中止) と異なり _MENU_BACK を返す + captured = {} + fake_app = types.SimpleNamespace(exit=lambda **kw: captured.update(kw)) + esc[0].handler(types.SimpleNamespace(app=fake_app)) + assert captured == {"result": project_mod._MENU_BACK} + + +def test_tui_running_action_escape_returns_to_top_menu(monkeypatch): + """running 行のサブメニューで Esc (_MENU_BACK) を押すとトップメニューへ戻る。""" + from devbase.commands import project as project_mod + from devbase.commands import container as container_mod + + rows = [ + {"name": "carmo", "plugin": "-", "status": "running (1 containers)"}, + {"name": "beta", "plugin": "-", "status": "stopped"}, + ] + # 1 回目: running 行 (idx0) を選ぶ / 2 回目: stopped 行 (idx1) を選ぶ + menu_calls = [] + monkeypatch.setattr( + project_mod, "_show_menu", + lambda rows: (menu_calls.append(1), 0 if len(menu_calls) == 1 else 1)[1]) + # サブメニューでは Esc → _MENU_BACK (トップメニューへ戻る) + monkeypatch.setattr(project_mod, "_show_action_menu", + lambda name: project_mod._MENU_BACK) + + captured = {} + monkeypatch.setattr(container_mod, "cmd_project", + lambda args: captured.update( + subcommand=args.subcommand, name=args.name) or 0) + + rc = project_mod._tui_select_and_up(rows) + assert rc == 0 + assert len(menu_calls) == 2, "Esc でトップメニューが再表示される" + # 2 回目に選んだ stopped 行が直接 up される + assert captured["name"] == "beta" + assert captured["subcommand"] == "up" def test_interactive_falls_back_when_no_terminal_menu(tmp_path, monkeypatch):