Skip to content

Commit 29f4d9c

Browse files
takemi-ohamaclaude
andauthored
feat: PLAN31_2-project-ops project login/ps/logs/scale/build の TUI 追加 (#57)
* chore: PLAN31_2-project-ops Draft PR 作成 * feat(tui): PLAN31_2 project 操作を login/ps/logs/scale/build へ拡張 (PR2 #57) running 行の操作サブメニューを up/down/login/ps/logs/scale/build/rebuild の全操作へ 拡張し、各操作の引数を tui.menu の収集ヘルパで CLI と同じ属性 (plan 2.3 契約) として 集める。stopped/unknown は従来どおり直接 up (PR1 非回帰)。 - login: index (既定 "1") を text で収集 - ps: --all を confirm で収集 - logs: --follow を confirm、--tail を optional int (空=全件) で収集 - scale: new_scale を integer (min=1) で収集 - build: containers/<image>/Dockerfile を列挙して選択 (compose.yml 全体= image None) - down: 破壊的操作のため confirm で確認 (plan 3.4) - 引数収集を Esc/Ctrl-C で中止したら操作サブメニューへ戻る (_ARG_CANCEL) login/ps/logs/scale は running コンテナ対象のため running 行限定とした。 全 pytest 542 passed / 1 skipped (PR2 で +22)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tui): project login の空 index バグ修正 + logs --tail 負数バリデーション (PR2 #57 round1) - login: menu.text(空入力で "" → --index= 失敗) を menu.integer(default=1, min_value=1) に変更し、str(index) で文字列契約を満たして dispatch。 - _optional_int: min_value=0 検証を追加し、logs --tail への負数入力を弾いて 再入力を促す (docker compose エラー防止)。 - テスト: login を menu.integer モックに追従、負数再入力テストを追加。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 75ceba9 commit 29f4d9c

2 files changed

Lines changed: 402 additions & 26 deletions

File tree

lib/devbase/tui/actions_project.py

Lines changed: 166 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
``_show_action_menu`` / ``_fallback_select_and_up`` をこのモジュールへ移送し、
55
メニュー部品は ``tui.menu`` に、ハンドラ委譲は ``tui.dispatch`` に一般化した。
66
7-
PR1 で扱うのは既存の **一覧選択 → (running なら up/rebuild/down サブメニュー) →
8-
それ以外は直接 up** までで、login/ps/logs/scale/build の追加は PR2 で行う。
7+
PR1 で **一覧選択 → (running なら操作サブメニュー) → それ以外は直接 up** を移送し、
8+
PR2 で running 操作サブメニューを **up/down/login/ps/logs/scale/build/rebuild の全操作**
9+
へ拡張した。login/ps/logs/scale は running 中コンテナを対象とするため running 行限定、
10+
stopped/unknown は従来どおり直接 up (PR1 非回帰)。引数を要する操作は ``tui.menu`` の
11+
収集ヘルパで CLI と同じ属性値を集め、破壊的な down は ``menu.confirm`` で確認する
12+
(plan 2.3 契約表 / 3.4 破壊的操作確認)。
913
1014
一覧表示・整形 (``list_projects`` / ``_build_menu_entries``) は ``commands/project``
1115
の純粋ロジックを再利用する (TUI からも CLI(table) からも共有)。
@@ -41,36 +45,180 @@ def _select_project(rows: list[dict]):
4145
choices, back=True, search=True)
4246

4347

48+
# running 行で選べる操作 (表示順 = ハイライト既定順)。up を先頭に置き、PR1 同様
49+
# Enter 連打で再起動へ到達できるようにする。各 value は cmd_project のサブコマンド名。
50+
_RUNNING_OPS: list[tuple[str, str]] = [
51+
("再起動 (up)", "up"),
52+
("停止 (down)", "down"),
53+
("ログイン (login)", "login"),
54+
("コンテナ状態 (ps)", "ps"),
55+
("ログ表示 (logs)", "logs"),
56+
("スケール変更 (scale)", "scale"),
57+
("イメージビルド (build)", "build"),
58+
("再ビルド (rebuild --no-cache)", "rebuild"),
59+
]
60+
61+
# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。
62+
# dispatch の rc (int) や ``None`` (= 全体中止) と区別する。
63+
_ARG_CANCEL = object()
64+
65+
4466
def _select_action(name: str):
45-
"""running 中プロジェクトの操作 (up/rebuild/down) を選ぶサブメニュー
67+
"""running 中プロジェクトの操作を選ぶサブメニュー
4668
47-
戻り値: action 文字列 / ``MENU_BACK`` (Esc・← → 一覧へ戻る) / ``None`` (Ctrl-C 中止)。
69+
戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → 一覧へ戻る) / ``None`` (Ctrl-C 中止)。
4870
"""
49-
choices = [
50-
("再起動 (up)", "up"),
51-
("再ビルド (rebuild --no-cache)", "rebuild"),
52-
("停止 (down)", "down"),
53-
]
5471
return menu.select(
5572
f"'{name}' は起動中です。操作を選択 "
5673
"(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):",
74+
list(_RUNNING_OPS), back=True, search=False)
75+
76+
77+
def _optional_int(message: str, *, min_value: int = 0):
78+
"""空入力を許す整数収集 (logs --tail 等)。
79+
80+
戻り値: ``int`` / ``None`` (空入力 = 既定動作) / ``_ARG_CANCEL`` (Esc・Ctrl-C 中止)。
81+
非数値・``min_value`` 未満は再入力を促す。``menu.integer`` は空入力で既定値を返す
82+
仕様のため、空 = None を表現したい optional な数値はこちらで扱う。``min_value`` の
83+
既定は 0 で、logs --tail に負数を渡して docker compose をエラーにするのを防ぐ。
84+
"""
85+
while True:
86+
raw = menu.text(message, allow_empty=True)
87+
if raw is None:
88+
return _ARG_CANCEL
89+
if raw == "":
90+
return None
91+
try:
92+
value = int(raw)
93+
except ValueError:
94+
logger.error("整数で指定してください: %r", raw)
95+
continue
96+
if value < min_value:
97+
logger.error("%d 以上で指定してください。", min_value)
98+
continue
99+
return value
100+
101+
102+
def _select_build_image(devbase_root: Path):
103+
"""build 対象イメージを選ぶ。``containers/<image>/Dockerfile`` を列挙する。
104+
105+
戻り値: イメージ名 (``str``) / ``None`` (compose.yml 全体ビルド) / ``_ARG_CANCEL``
106+
(Esc・Ctrl-C 中止)。``containers/`` が無い / 空なら compose.yml 全体ビルド (None) に
107+
フォールバックする。
108+
"""
109+
containers_dir = Path(devbase_root) / "containers"
110+
images = sorted(
111+
d.name for d in containers_dir.iterdir()
112+
if d.is_dir() and (d / "Dockerfile").exists()
113+
) if containers_dir.is_dir() else []
114+
115+
if not images:
116+
# 個別イメージが無ければ compose.yml 全体ビルド (image=None) のみ。
117+
return None
118+
119+
# value="" を「compose.yml 全体」に割り当て、選択メニューの None (Ctrl-C) と衝突
120+
# させない。呼び出し側で空文字 → None へ変換する。
121+
choices = [("compose.yml 全体をビルド", "")] + [(img, img) for img in images]
122+
sel = menu.select(
123+
"ビルドするイメージを選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):",
57124
choices, back=True, search=False)
125+
if sel is menu.MENU_BACK or sel is None:
126+
return _ARG_CANCEL
127+
return sel or None # "" → None (compose 全体)
128+
129+
130+
def _run_operation(devbase_root: Path, name: str, op: str):
131+
"""選択された操作の引数を収集して ``dispatch_lifecycle`` で起動する。
132+
133+
戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 = サブメニューへ戻る)。
134+
引数を要さない up/rebuild は即実行。down は破壊的のため ``menu.confirm`` で確認する。
135+
"""
136+
if op in ("up", "rebuild"):
137+
# up は scale 属性を参照する (常に None。他コマンドは無視する)。
138+
return dispatch_lifecycle(op, name, scale=None)
139+
140+
if op == "down":
141+
ok = menu.confirm(f"'{name}' のコンテナを停止しますか?", default=False)
142+
if not ok: # False (拒否) / None (中止) → 実行しない
143+
return _ARG_CANCEL
144+
return dispatch_lifecycle("down", name)
145+
146+
if op == "login":
147+
# menu.text は空入力 (既定値を消して確定) で "" を返し、wrapper で --index=
148+
# と展開されてコマンドが失敗する。menu.integer なら空入力は default=1 を返し、
149+
# min_value=1 で正の整数を保証する。cmd_login の index は文字列契約なので str 化。
150+
index = menu.integer("ログインするコンテナ番号", default=1, min_value=1)
151+
if index is None:
152+
return _ARG_CANCEL
153+
return dispatch_lifecycle("login", name, index=str(index))
154+
155+
if op == "ps":
156+
all_c = menu.confirm("停止中も含め全コンテナを表示しますか (--all)?", default=False)
157+
if all_c is None:
158+
return _ARG_CANCEL
159+
return dispatch_lifecycle("ps", name, all=all_c)
160+
161+
if op == "logs":
162+
follow = menu.confirm("ログを追従表示しますか (--follow)?", default=False)
163+
if follow is None:
164+
return _ARG_CANCEL
165+
tail = _optional_int("末尾何行を表示しますか (空で全件)")
166+
if tail is _ARG_CANCEL:
167+
return _ARG_CANCEL
168+
return dispatch_lifecycle("logs", name, follow=follow, tail=tail)
169+
170+
if op == "scale":
171+
new_scale = menu.integer(f"'{name}' の新しいコンテナ数", min_value=1)
172+
if new_scale is None:
173+
return _ARG_CANCEL
174+
return dispatch_lifecycle("scale", name, new_scale=new_scale)
175+
176+
if op == "build":
177+
image = _select_build_image(devbase_root)
178+
if image is _ARG_CANCEL:
179+
return _ARG_CANCEL
180+
return dispatch_lifecycle("build", name, image=image)
181+
182+
# 到達しない (メニュー値は _RUNNING_OPS に限定される)。保守的に no-op。
183+
logger.error("未知の操作です: %s", op)
184+
return _ARG_CANCEL
185+
186+
187+
def _operation_menu(devbase_root: Path, name: str):
188+
"""running 行の操作サブメニューを回す。
189+
190+
戻り値プロトコル (run と同じ ``is`` 同一性判定):
191+
- dispatch の rc (``int``): 操作を実行 → 呼び出し元へ (最終的にトップへ復帰)。
192+
- ``menu.MENU_BACK``: Esc/← で一覧へ戻る。
193+
- ``None``: Ctrl-C で全体中止。
194+
195+
引数収集を中止 (``_ARG_CANCEL``) した場合はサブメニューを再表示する。
196+
"""
197+
while True:
198+
op = _select_action(name)
199+
if op is menu.MENU_BACK:
200+
return menu.MENU_BACK
201+
if op is None:
202+
return None
203+
rc = _run_operation(devbase_root, name, op)
204+
if rc is _ARG_CANCEL:
205+
continue # 引数収集を中止 → サブメニューへ戻る
206+
return rc # 実行 rc → 呼び出し元へ
58207

59208

60209
def run(devbase_root: Path):
61-
"""プロジェクト操作カテゴリ。一覧選択 → up/rebuild/down を起動する
210+
"""プロジェクト操作カテゴリ。一覧選択 → (running は操作サブメニュー / 他は up)
62211
63212
戻り値プロトコル (トップループが ``is`` 同一性で判定する):
64213
- **操作を実行した場合**: ``dispatch_lifecycle`` の rc (``int``) を返す。
65214
「実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。これにより
66-
``project up/down/rebuild`` の失敗が ``devbase list`` の終了コードへ伝搬する。
215+
project 操作の失敗が ``devbase list`` の終了コードへ伝搬する。
67216
- ``menu.MENU_BACK``: 一覧で Esc/← (操作なしでトップへ) / プロジェクト無し。
68217
- ``None``: 一覧・サブメニューで Ctrl-C による全体中止。
69218
70-
選択行が running 中なら ``_select_action`` で up/rebuild/down を選ばせ、それ以外
71-
(stopped / unknown 等) は従来どおり直接 ``project up`` を起動する。サブメニューで
72-
Esc/← を押すと (``MENU_BACK``) 一覧へ戻る。操作完了後はトップメニューへ復帰する
73-
(plan 3.5 状態遷移: Exec → Top)。
219+
選択行が running 中なら ``_operation_menu`` で全操作を選ばせ、それ以外
220+
(stopped / unknown) は従来どおり直接 ``project up`` を起動する (PR1 非回帰)。
221+
操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。
74222
"""
75223
projects_dir = Path(devbase_root) / "projects"
76224
while True:
@@ -88,12 +236,11 @@ def run(devbase_root: Path):
88236
row = rows[idx]
89237
name = row["name"]
90238
if str(row.get("status", "")).startswith("running"):
91-
action = _select_action(name)
92-
if action is menu.MENU_BACK:
239+
rc = _operation_menu(devbase_root, name)
240+
if rc is menu.MENU_BACK:
93241
continue # 一覧へ戻る
94-
if action is None:
242+
if rc is None:
95243
return None # Ctrl-C → 全体中止
96-
rc = dispatch_lifecycle(action, name, scale=None)
97244
else:
98245
rc = dispatch_lifecycle("up", name, scale=None)
99246

0 commit comments

Comments
 (0)