Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 59 additions & 26 deletions lib/devbase/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,25 +190,49 @@ 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
のみを拾う (矢印キー移動と衝突させない)。
"""
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:
Expand All @@ -230,47 +254,56 @@ 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"),
questionary.Choice(title="再ビルド (rebuild --no-cache)", value="rebuild"),
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:
"""TUI メニューで 1 件選択して操作を起動する。

選択行が 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:
Expand Down
58 changes: 55 additions & 3 deletions tests/cli/test_project_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
Loading