Skip to content

Commit 4ed16b6

Browse files
takemi-ohamaclaude
andcommitted
fix(tui): 引数入力プロンプトの Ctrl-C を Esc と区別し全体中止を伝搬 (PR #55 round4 major)
menu.text/confirm/path が Esc と Ctrl-C を同じ None で返し、各 actions_* が それを _ARG_CANCEL に畳んでいたため、引数入力中の Ctrl-C がナビ規約の 「全体中止」にならずサブメニュー再表示になっていた (round2 で対応した 選択メニューと同じ問題が入力プロンプトに残存)。 - menu: _ask_with_escape を with_escape_back(bind_left=False) ベースへ変更し、 text/confirm/path/integer が Esc=MENU_BACK / Ctrl-C=None を区別して返す 新契約に統一 (← は入力カーソル移動用に空ける) - actions_env/plugin/project/snapshot: 入力ヘルパの None (Ctrl-C) を 呼び出し元まで return し、MENU_BACK (Esc) のみ _ARG_CANCEL とする。 confirm の「not ok」判定は MENU_BACK が truthy のため is 判定を先行 - _optional_int / _optional_point: 「空入力 = None (既定動作)」と Ctrl-C の None が衝突するため専用番兵 _ABORT を導入し、呼び出し側で None へ変換 テスト: 入力系キャンセルを Esc=_ARG_CANCEL / Ctrl-C=None の新契約へ パラメトライズ更新し、Ctrl-C 全体中止と MENU_BACK 伝搬の回帰テストを追加 (701→732 passed)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 412ab5b commit 4ed16b6

10 files changed

Lines changed: 468 additions & 191 deletions

lib/devbase/tui/actions_env.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,13 @@ def _collect_assignment():
150150
"""``env set`` の KEY=VALUE を収集する。
151151
152152
形式エラー (``=`` 無し / キー名空) は ``cmd_env_set`` でも弾かれるが、TUI では
153-
実行前に再入力を促す。戻り値: 入力文字列 / ``None`` (Esc・Ctrl-C 中止)。
153+
実行前に再入力を促す。戻り値: 入力文字列 / ``MENU_BACK`` (Esc → サブメニューへ
154+
戻る) / ``None`` (Ctrl-C → 全体中止)。
154155
"""
155156
while True:
156157
raw = menu.text("設定する変数 (KEY=VALUE 形式)", allow_empty=False)
157-
if raw is None:
158-
return None
158+
if raw is None or raw is menu.MENU_BACK:
159+
return raw # None=Ctrl-C 全体中止 / MENU_BACK=Esc 戻る
159160
if "=" not in raw or not raw.partition("=")[0].strip():
160161
logger.error("形式: KEY=VALUE (キー名は必須)")
161162
continue
@@ -232,10 +233,14 @@ def _run_list(devbase_root: Path):
232233

233234
reveal = menu.confirm("機密値を伏せ字にせず表示しますか (--reveal)?", default=False)
234235
if reveal is None:
235-
return _ARG_CANCEL
236+
return None # Ctrl-C → 全体中止
237+
if reveal is menu.MENU_BACK:
238+
return _ARG_CANCEL # Esc → サブメニューへ戻る
236239
keys_only = menu.confirm("キー名のみ表示しますか (--keys)?", default=False)
237240
if keys_only is None:
238-
return _ARG_CANCEL
241+
return None # Ctrl-C → 全体中止
242+
if keys_only is menu.MENU_BACK:
243+
return _ARG_CANCEL # Esc → サブメニューへ戻る
239244

240245
if scope in ("both", "project"):
241246
return _run_in_project(
@@ -274,7 +279,9 @@ def _run_set(devbase_root: Path):
274279

275280
assignment = _collect_assignment()
276281
if assignment is None:
277-
return _ARG_CANCEL
282+
return None # Ctrl-C → 全体中止
283+
if assignment is menu.MENU_BACK:
284+
return _ARG_CANCEL # Esc → サブメニューへ戻る
278285

279286
if scope == "project":
280287
return _run_in_project(
@@ -312,7 +319,9 @@ def _run_get(devbase_root: Path):
312319

313320
key = menu.text("取得する変数名", allow_empty=False)
314321
if key is None:
315-
return _ARG_CANCEL
322+
return None # Ctrl-C → 全体中止
323+
if key is menu.MENU_BACK:
324+
return _ARG_CANCEL # Esc → サブメニューへ戻る
316325

317326
if scope == "project":
318327
return _run_in_project(
@@ -324,8 +333,8 @@ def _run_get(devbase_root: Path):
324333
def _run_operation(devbase_root: Path, op: str):
325334
"""選択された env 操作の引数を収集して ``cmd_env`` へ委譲する。
326335
327-
戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 =
328-
サブメニューへ戻る) / ``None`` (選択メニューで Ctrl-C → 全体中止)。
336+
戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc で引数収集を中止 =
337+
サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。
329338
属性は plan 2.3 の契約表 (cli.py parser と同期確認済み) に従う。
330339
"""
331340
if op == "sync":
@@ -342,7 +351,9 @@ def _run_operation(devbase_root: Path, op: str):
342351
reset = menu.confirm(
343352
"既存の設定をバックアップしてやり直しますか (--reset)?", default=False)
344353
if reset is None:
345-
return _ARG_CANCEL
354+
return None # Ctrl-C → 全体中止
355+
if reset is menu.MENU_BACK:
356+
return _ARG_CANCEL # Esc → サブメニューへ戻る
346357
return _dispatch(devbase_root, "init", reset=reset)
347358

348359
if op == "list":
@@ -357,13 +368,17 @@ def _run_operation(devbase_root: Path, op: str):
357368
if op == "delete":
358369
key = menu.text("削除する変数名", allow_empty=False)
359370
if key is None:
360-
return _ARG_CANCEL
361-
# 破壊的操作のため実行前に確認する (plan 3.4)。拒否 (False) / 中止 (None)
362-
# は実行せずサブメニューへ戻る。
371+
return None # Ctrl-C → 全体中止
372+
if key is menu.MENU_BACK:
373+
return _ARG_CANCEL # Esc → サブメニューへ戻る
374+
# 破壊的操作のため実行前に確認する (plan 3.4)。拒否 (False) / Esc は実行せず
375+
# サブメニューへ戻る。MENU_BACK は truthy のため is 判定を not より先に行う。
363376
ok = menu.confirm(f"変数 '{key}' をグローバル .env から削除しますか?",
364377
default=False)
365-
if not ok:
366-
return _ARG_CANCEL
378+
if ok is None:
379+
return None # Ctrl-C → 全体中止
380+
if ok is menu.MENU_BACK or not ok:
381+
return _ARG_CANCEL # Esc / 拒否 → 実行しない
367382
return _dispatch(devbase_root, "delete", key=key)
368383

369384
if op == "project":
@@ -382,7 +397,9 @@ def _run_operation(devbase_root: Path, op: str):
382397
dest = menu.path("出力先パス (空で既定: ./devbase-env-<タイムスタンプ>.dbenv)",
383398
allow_empty=True)
384399
if dest is None:
385-
return _ARG_CANCEL
400+
return None # Ctrl-C → 全体中止
401+
if dest is menu.MENU_BACK:
402+
return _ARG_CANCEL # Esc → サブメニューへ戻る
386403
return _dispatch(devbase_root, "export", dest=(dest or None),
387404
**_export_default_attrs())
388405

@@ -392,7 +409,9 @@ def _run_operation(devbase_root: Path, op: str):
392409
# バックアップされる。
393410
source = menu.path("インポートするバンドルのパス", allow_empty=False)
394411
if source is None:
395-
return _ARG_CANCEL
412+
return None # Ctrl-C → 全体中止
413+
if source is menu.MENU_BACK:
414+
return _ARG_CANCEL # Esc → サブメニューへ戻る
396415
return _dispatch(devbase_root, "import", source=source,
397416
**_import_default_attrs())
398417

lib/devbase/tui/actions_plugin.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -172,32 +172,40 @@ def _select_repo_operation():
172172
def _run_operation(devbase_root: Path, op: str):
173173
"""選択された plugin 操作の引数を収集して ``cmd_plugin`` へ委譲する。
174174
175-
戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 =
176-
サブメニューへ戻る) / ``None`` (選択メニューで Ctrl-C → 全体中止)。
175+
戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc で引数収集を中止 =
176+
サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。
177177
破壊的な uninstall は ``menu.confirm`` で確認する (plan 3.4)。
178178
"""
179179
if op == "list":
180180
# --available: 導入済み一覧の代わりに未導入の利用可能 plugin を表示する。
181181
available = menu.confirm(
182182
"未導入の利用可能 plugin を表示しますか (--available)?", default=False)
183183
if available is None:
184-
return _ARG_CANCEL
184+
return None # Ctrl-C → 全体中止
185+
if available is menu.MENU_BACK:
186+
return _ARG_CANCEL # Esc → サブメニューへ戻る
185187
return _dispatch(devbase_root, "list", available=available)
186188

187189
if op == "install":
188190
source = menu.text(
189191
"インストールする plugin の source (名前 / URL / パス)",
190192
allow_empty=False)
191193
if source is None:
192-
return _ARG_CANCEL
194+
return None # Ctrl-C → 全体中止
195+
if source is menu.MENU_BACK:
196+
return _ARG_CANCEL # Esc → サブメニューへ戻る
193197
link = menu.confirm(
194198
"symlink としてインストールしますか (--link)?", default=False)
195199
if link is None:
196-
return _ARG_CANCEL
200+
return None # Ctrl-C → 全体中止
201+
if link is menu.MENU_BACK:
202+
return _ARG_CANCEL # Esc → サブメニューへ戻る
197203
install_all = menu.confirm(
198204
"リポジトリ内の全 plugin をインストールしますか (--all)?", default=False)
199205
if install_all is None:
200-
return _ARG_CANCEL
206+
return None # Ctrl-C → 全体中止
207+
if install_all is menu.MENU_BACK:
208+
return _ARG_CANCEL # Esc → サブメニューへ戻る
201209
return _dispatch(devbase_root, "install",
202210
source=source, link=link, install_all=install_all)
203211

@@ -209,8 +217,10 @@ def _run_operation(devbase_root: Path, op: str):
209217
if name is _ARG_CANCEL:
210218
return _ARG_CANCEL
211219
ok = menu.confirm(f"plugin '{name}' をアンインストールしますか?", default=False)
212-
if not ok: # False (拒否) / None (中止) → 実行しない
213-
return _ARG_CANCEL
220+
if ok is None:
221+
return None # Ctrl-C → 全体中止
222+
if ok is menu.MENU_BACK or not ok:
223+
return _ARG_CANCEL # Esc / 拒否 → 実行しない
214224
return _dispatch(devbase_root, "uninstall", name=name)
215225

216226
if op == "update":
@@ -257,11 +267,15 @@ def _run_repo_operation(devbase_root: Path, op: str):
257267
"登録するリポジトリの URL (GitHub は owner/repo 短縮形も可)",
258268
allow_empty=False)
259269
if url is None:
260-
return _ARG_CANCEL
270+
return None # Ctrl-C → 全体中止
271+
if url is menu.MENU_BACK:
272+
return _ARG_CANCEL # Esc → サブメニューへ戻る
261273
# --name は任意 (空で URL から自動命名)。空文字は None へ変換して渡す。
262274
name = menu.text("カスタム名 (--name 空で自動)", allow_empty=True)
263275
if name is None:
264-
return _ARG_CANCEL
276+
return None # Ctrl-C → 全体中止
277+
if name is menu.MENU_BACK:
278+
return _ARG_CANCEL # Esc → サブメニューへ戻る
265279
return _dispatch(devbase_root, "repo",
266280
repo_command="add", url=url, name=name or None)
267281

@@ -272,13 +286,17 @@ def _run_repo_operation(devbase_root: Path, op: str):
272286
if name is _ARG_CANCEL:
273287
return _ARG_CANCEL
274288
ok = menu.confirm(f"リポジトリ '{name}' を削除しますか?", default=False)
275-
if not ok: # False (拒否) / None (中止) → 実行しない
276-
return _ARG_CANCEL
289+
if ok is None:
290+
return None # Ctrl-C → 全体中止
291+
if ok is menu.MENU_BACK or not ok:
292+
return _ARG_CANCEL # Esc / 拒否 → 実行しない
277293
force = menu.confirm(
278294
"未 commit / 未 push の変更があっても強制削除しますか (--force)?",
279295
default=False)
280296
if force is None:
281-
return _ARG_CANCEL
297+
return None # Ctrl-C → 全体中止
298+
if force is menu.MENU_BACK:
299+
return _ARG_CANCEL # Esc → サブメニューへ戻る
282300
return _dispatch(devbase_root, "repo",
283301
repo_command="remove", name=name, force=force)
284302

lib/devbase/tui/actions_project.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,14 @@ def _select_project(rows: list[dict]):
5858
("再ビルド (rebuild --no-cache)", "rebuild"),
5959
]
6060

61-
# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。
62-
# dispatch の rc (int) や ``None`` (= 全体中止) と区別する。
61+
# 引数収集を Esc で中止したことを示す番兵 (= サブメニューへ戻る)。
62+
# dispatch の rc (int) や ``None`` (= Ctrl-C 全体中止) と区別する。
6363
_ARG_CANCEL = object()
6464

65+
# _optional_int の Ctrl-C 番兵。「空入力 (None = 既定動作)」と衝突するため
66+
# ``None`` を直接返せず、専用番兵で返して呼び出し側で ``None`` (全体中止) へ変換する。
67+
_ABORT = object()
68+
6569

6670
def _select_action(name: str):
6771
"""running 中プロジェクトの操作を選ぶサブメニュー。
@@ -77,15 +81,19 @@ def _select_action(name: str):
7781
def _optional_int(message: str, *, min_value: int = 0):
7882
"""空入力を許す整数収集 (logs --tail 等)。
7983
80-
戻り値: ``int`` / ``None`` (空入力 = 既定動作) / ``_ARG_CANCEL`` (Esc・Ctrl-C 中止)。
84+
戻り値: ``int`` / ``None`` (空入力 = 既定動作) / ``_ARG_CANCEL`` (Esc → サブ
85+
メニューへ戻る) / ``_ABORT`` (Ctrl-C → 全体中止。空入力の ``None`` と衝突する
86+
ため専用番兵で返し、呼び出し側で ``None`` へ変換する)。
8187
非数値・``min_value`` 未満は再入力を促す。``menu.integer`` は空入力で既定値を返す
8288
仕様のため、空 = None を表現したい optional な数値はこちらで扱う。``min_value`` の
8389
既定は 0 で、logs --tail に負数を渡して docker compose をエラーにするのを防ぐ。
8490
"""
8591
while True:
8692
raw = menu.text(message, allow_empty=True)
8793
if raw is None:
88-
return _ARG_CANCEL
94+
return _ABORT # Ctrl-C → 全体中止 (呼び出し側で None へ変換)
95+
if raw is menu.MENU_BACK:
96+
return _ARG_CANCEL # Esc → サブメニューへ戻る
8997
if raw == "":
9098
return None
9199
try:
@@ -133,8 +141,8 @@ def _select_build_image(devbase_root: Path):
133141
def _run_operation(devbase_root: Path, name: str, op: str):
134142
"""選択された操作の引数を収集して ``dispatch_lifecycle`` で起動する。
135143
136-
戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 = サブメニューへ
137-
戻る) / ``None`` (選択メニューで Ctrl-C → 全体中止)。
144+
戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (Esc で引数収集を中止 =
145+
サブメニューへ戻る) / ``None`` (選択・入力中の Ctrl-C → 全体中止)。
138146
引数を要さない up/rebuild は即実行。down は破壊的のため ``menu.confirm`` で確認する。
139147
"""
140148
if op in ("up", "rebuild"):
@@ -143,8 +151,10 @@ def _run_operation(devbase_root: Path, name: str, op: str):
143151

144152
if op == "down":
145153
ok = menu.confirm(f"'{name}' のコンテナを停止しますか?", default=False)
146-
if not ok: # False (拒否) / None (中止) → 実行しない
147-
return _ARG_CANCEL
154+
if ok is None:
155+
return None # Ctrl-C → 全体中止
156+
if ok is menu.MENU_BACK or not ok:
157+
return _ARG_CANCEL # Esc / 拒否 → 実行しない
148158
return dispatch_lifecycle("down", name)
149159

150160
if op == "login":
@@ -153,28 +163,38 @@ def _run_operation(devbase_root: Path, name: str, op: str):
153163
# min_value=1 で正の整数を保証する。cmd_login の index は文字列契約なので str 化。
154164
index = menu.integer("ログインするコンテナ番号", default=1, min_value=1)
155165
if index is None:
156-
return _ARG_CANCEL
166+
return None # Ctrl-C → 全体中止
167+
if index is menu.MENU_BACK:
168+
return _ARG_CANCEL # Esc → サブメニューへ戻る
157169
return dispatch_lifecycle("login", name, index=str(index))
158170

159171
if op == "ps":
160172
all_c = menu.confirm("停止中も含め全コンテナを表示しますか (--all)?", default=False)
161173
if all_c is None:
162-
return _ARG_CANCEL
174+
return None # Ctrl-C → 全体中止
175+
if all_c is menu.MENU_BACK:
176+
return _ARG_CANCEL # Esc → サブメニューへ戻る
163177
return dispatch_lifecycle("ps", name, all=all_c)
164178

165179
if op == "logs":
166180
follow = menu.confirm("ログを追従表示しますか (--follow)?", default=False)
167181
if follow is None:
168-
return _ARG_CANCEL
182+
return None # Ctrl-C → 全体中止
183+
if follow is menu.MENU_BACK:
184+
return _ARG_CANCEL # Esc → サブメニューへ戻る
169185
tail = _optional_int("末尾何行を表示しますか (空で全件)")
186+
if tail is _ABORT:
187+
return None # Ctrl-C → 全体中止
170188
if tail is _ARG_CANCEL:
171-
return _ARG_CANCEL
189+
return _ARG_CANCEL # Esc → サブメニューへ戻る
172190
return dispatch_lifecycle("logs", name, follow=follow, tail=tail)
173191

174192
if op == "scale":
175193
new_scale = menu.integer(f"'{name}' の新しいコンテナ数", min_value=1)
176194
if new_scale is None:
177-
return _ARG_CANCEL
195+
return None # Ctrl-C → 全体中止
196+
if new_scale is menu.MENU_BACK:
197+
return _ARG_CANCEL # Esc → サブメニューへ戻る
178198
return dispatch_lifecycle("scale", name, new_scale=new_scale)
179199

180200
if op == "build":

0 commit comments

Comments
 (0)