Skip to content

Commit 4e40925

Browse files
takemi-ohamaclaude
andauthored
fix(tui): 環境変数メニュー再構成 + Esc/Ctrl-C 二重 exit クラッシュ修正 (#65)
* refactor(tui): 環境変数メニューを参照・対話系のみに再構成 devbase list の環境変数メニューが「変数一覧 → スコープ選択 → プロジェクト 選択」と深く、プロジェクトのみ表示は .env 未作成時に何も表示されない問題が あった。メニューを参照・対話系のみに絞り、階層を浅くする。 - 変数一覧はグローバル一覧のみとし、中間プロンプトなしで即実行する - プロジェクト単位の変数一覧は TUI から削除 (CLI で実行) - get / set / delete / export / import を TUI から削除 (CLI で実行。 値の変更は edit ($EDITOR) と project (対話設定) で代替) - 不要になったヘルパ (_collect_assignment / _select_scoped_project / export・import 既定値テーブル) を削除 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tui): Esc+Ctrl-C 同時押下での Application.exit() 二重呼び出しを防止 実 TTY で Esc と Ctrl-C をほぼ同時に押す (または Ctrl-C 連打) と 「Return value already set. Application.exit() failed.」のクラッシュ画面が 出る問題を修正する。 Esc バインドは矢印キーのエスケープシーケンスと区別するため eager=False (確定待ち) で登録しており、Esc + Ctrl-C が同一入力バッチで届くと prompt_toolkit は 1 回のキー処理内で「Esc ハンドラ (exit 確定) → 残り バッファ再処理で questionary 組み込み Ctrl-C ハンドラ」を連続実行し、 exit が二重に呼ばれる。process_keys の is_done ガードは入力キューにしか 効かず、この同一バッファ再処理の経路は防げない。 全プロンプト共通の通過点 _ask_erased に _guard_after_done を追加し、 アプリの key_bindings (questionary 組み込み + 後付けの Esc/←) を ConditionalKeyBindings(kb, ~is_done) でラップして回答確定後のキー処理を 無効化する。 pty 回帰テストは Esc+Ctrl-C を 1 回の write で送出して再現する (ガードを 外すと実際にクラッシュ出力を検出して失敗することを確認済み)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0fb0082 commit 4e40925

5 files changed

Lines changed: 140 additions & 616 deletions

File tree

lib/devbase/tui/actions_env.py

Lines changed: 26 additions & 195 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,24 @@
1-
"""env カテゴリの TUI 操作フロー (PLAN31_2 PR3)。
1+
"""env カテゴリの TUI 操作フロー (PLAN31_2 PR3 → メニュー再構成)。
22
3-
``devbase env`` の全サブコマンド (init/list/set/get/delete/edit/sync/project/
4-
export/import) をトップ階層メニューから実行できるようにする。引数収集は
5-
``tui.menu`` のヘルパで CLI parser (cli.py ``_add_env_parser``) と同じ属性値を
6-
集め、``tui.dispatch.dispatch_group`` 経由で既存ハンドラ ``cmd_env`` へ委譲する
7-
(plan 2.3 契約表 / ロジック二重実装なし)。
3+
TUI では参照・対話系の操作のみに絞り、メニュー階層を浅くする:
4+
5+
- 変数一覧はスコープ選択の中間プロンプトを挟まず、グローバル一覧のみを
6+
即実行する。プロジェクト単位の一覧は TUI から除外する (CLI で実行)。
7+
- キー単位の get/set/delete と export/import も TUI から除外する (CLI で実行。
8+
値の変更は ``edit`` ($EDITOR) と ``project`` (対話設定) で代替できる)。
9+
10+
引数収集は ``tui.menu`` のヘルパで CLI parser (cli.py ``_add_env_parser``) と
11+
同じ属性値を集め、``tui.dispatch.dispatch_group`` 経由で既存ハンドラ
12+
``cmd_env`` へ委譲する (ロジック二重実装なし)。
813
914
project スコープ依存の扱い (plan 3.3):
10-
- ``set --project`` / ``project`` / ``list`` (プロジェクトを含む表示範囲) /
11-
``get`` (プロジェクト取得) は CWD (環境変数 ``PWD``) のプロジェクト
12-
ディレクトリで動くため、先にプロジェクト選択メニューで対象を選ばせて
13-
chdir + ``PWD`` 差し替えしてからハンドラを呼び、実行後は必ず元へ復帰する
15+
- ``project`` (対話設定) は CWD (環境変数 ``PWD``) のプロジェクトディレクトリで
16+
動くため、先にプロジェクト選択メニューで対象を選ばせて chdir + ``PWD``
17+
差し替えしてからハンドラを呼び、実行後は必ず元へ復帰する
1418
(``_run_in_project``)。``cmd_env_*`` は ``os.environ.get('PWD', os.getcwd())``
1519
で現在地を判定するため、``os.chdir`` だけでなく ``PWD`` も併せて切り替える。
16-
- ``edit`` は plan 3.3 で CWD スコープとされているが、実装 (``cmd_env_edit``) は
17-
常に ``$DEVBASE_ROOT/.env`` を開くグローバル操作のため、プロジェクト選択は
18-
行わない (plan 表と実装の乖離。parser / 実装を正とする)。
19-
20-
破壊的操作 ``delete`` は実行前に確認する (plan 3.4)。
21-
22-
export/import は引数が多いため TUI では主要引数 (``dest`` / ``source``) のみ
23-
収集し、残りは CLI parser の既定値と同一の属性を明示的に渡す (既定値の乖離を
24-
防ぐ。細かい制御が必要な場合は CLI を使う想定)。
20+
- ``edit`` は常に ``$DEVBASE_ROOT/.env`` を開くグローバル操作のため、
21+
プロジェクト選択は行わない。
2522
2623
中止系の伝搬 (Ctrl-C / Esc / ``_ARG_CANCEL``) は ``tui.flow`` のナビ規約に従う。
2724
"""
@@ -42,20 +39,16 @@
4239

4340
logger = get_logger(__name__)
4441

45-
# env カテゴリで選べる操作 (表示順 = ハイライト既定順)。参照系の list を先頭に
46-
# 置き、Enter 連打で安全な一覧表示へ到達できるようにする。各 value は cmd_env の
47-
# サブコマンド名。
42+
# env カテゴリで選べる操作 (表示順 = ハイライト既定順)。参照系のグローバル一覧を
43+
# 先頭に置き、Enter 連打で安全な一覧表示へ到達できるようにする (中間プロンプト
44+
# なしで即実行)。プロジェクト単位の一覧と get/set/delete/export/import は
45+
# TUI から除外 (CLI で実行)。
4846
_ENV_OPS: list[tuple[str, str]] = [
49-
("変数一覧 (list)", "list"),
50-
("値の取得 (get)", "get"),
51-
("変数の設定 (set)", "set"),
52-
("変数の削除 (delete)", "delete"),
47+
("変数一覧 (グローバル)", "list-global"),
5348
("エディタで編集 (edit)", "edit"),
5449
("認証情報の再同期 (sync)", "sync"),
5550
("プロジェクト変数の対話設定 (project)", "project"),
5651
("初期セットアップ (init)", "init"),
57-
("暗号化バンドルへエクスポート (export)", "export"),
58-
("バンドルからインポート (import)", "import"),
5952
]
6053

6154
# 中止系番兵は flow と同一オブジェクトを再公開する (呼び出し側・テストの契約)。
@@ -136,153 +129,10 @@ def _run_in_project(devbase_root: Path, project_name: str, fn):
136129
os.environ["PWD"] = old_pwd
137130

138131

139-
def _collect_assignment():
140-
"""``env set`` の KEY=VALUE を収集する。
141-
142-
形式エラー (``=`` 無し / キー名空) は ``cmd_env_set`` でも弾かれるが、TUI では
143-
実行前に再入力を促す。戻り値: 入力文字列 / ``MENU_BACK`` (Esc → サブメニューへ
144-
戻る) / ``None`` (Ctrl-C → 全体中止)。
145-
"""
146-
while True:
147-
raw = menu.text("設定する変数 (KEY=VALUE 形式)", allow_empty=False)
148-
if raw is None or raw is menu.MENU_BACK:
149-
return raw # None=Ctrl-C 全体中止 / MENU_BACK=Esc 戻る
150-
if "=" not in raw or not raw.partition("=")[0].strip():
151-
logger.error("形式: KEY=VALUE (キー名は必須)")
152-
continue
153-
return raw
154-
155-
156-
def _export_default_attrs() -> dict:
157-
"""``env export`` の CLI parser 既定値 (cli.py:246-279) と同一の属性セット。
158-
159-
TUI で収集しない引数も Namespace に明示的に載せ、CLI 実行と完全に同じ属性で
160-
ハンドラを呼ぶ (getattr 既定値とのズレを防ぐ)。list は呼び出しごとに新規生成。
161-
"""
162-
return {
163-
"include_projects": None,
164-
"exclude_projects": [],
165-
"no_global": False,
166-
"no_metadata": False,
167-
"recipients": [],
168-
"passphrase_env": None,
169-
"passphrase_stdin": False,
170-
"force_unencrypted": False,
171-
"unsafe_allow_unencrypted_bucket": False,
172-
}
173-
174-
175-
def _import_default_attrs() -> dict:
176-
"""``env import`` の CLI parser 既定値 (cli.py:281-328) と同一の属性セット。"""
177-
return {
178-
"merge": "keep-existing",
179-
"replace_keys": "",
180-
"replace": False,
181-
"dry_run": False,
182-
"identities": [],
183-
"passphrase_env": None,
184-
"passphrase_stdin": False,
185-
"include_projects": None,
186-
"exclude_projects": [],
187-
"no_global": False,
188-
"no_metadata": False,
189-
"merge_metadata": False,
190-
"backup_dir": None,
191-
"keep_last": 10,
192-
}
193-
194-
195-
def _select_scoped_project(devbase_root: Path, message: str, choices):
196-
"""スコープ選択 + プロジェクトスコープなら対象プロジェクトも選ぶ共通フロー。
197-
198-
list/set/get が共有する「グローバル or プロジェクトを選び、プロジェクトを
199-
含むスコープなら対象名も選ぶ」の前半 2 プロンプト。``(scope, name)`` を返す
200-
(グローバルのみのとき ``name`` は ``None``)。中止系は flow 例外で伝搬する。
201-
"""
202-
scope = flow.need(menu.select(f"{message} {menu.HINT_BACK}:",
203-
choices, back=True, search=False))
204-
name = None
205-
if scope != "global":
206-
name = flow.need(_select_project(devbase_root))
207-
return scope, name
208-
209-
210132
# ---------------------------------------------------------------------------
211133
# 各操作の引数収集 + dispatch (plan 2.3 契約)
212134
# ---------------------------------------------------------------------------
213135

214-
def _op_list(devbase_root: Path):
215-
"""``env list``: 表示範囲を収集して一覧表示する。
216-
217-
ハンドラ (``cmd_env_list``) は CWD (PWD) が projects/ 配下のときだけ
218-
プロジェクト .env を表示するため、プロジェクトを含む表示範囲
219-
(「グローバル + プロジェクト」「プロジェクトのみ」) は対象プロジェクトを
220-
選ばせて chdir + ``PWD`` 切替後に実行する (plan 3.3 / codex round3 指摘。
221-
TUI は通常 DEVBASE_ROOT で動くので、切替なしではプロジェクト分が表示
222-
されない)。「グローバルのみ」だけが切替なしで実行できる。
223-
"""
224-
scope, name = _select_scoped_project(
225-
devbase_root, "表示範囲を選択",
226-
[("グローバル + プロジェクト", "both"),
227-
("グローバルのみ (--global)", "global"),
228-
("プロジェクトのみ (--project)", "project")])
229-
230-
# --reveal / --keys は CLI 既定 (False = 機密値は伏せ字・通常表示) で実行する
231-
# (非破壊操作の確認プロンプト廃止)。必要な場合は CLI を使う想定。
232-
attrs = {"global_only": scope == "global",
233-
"project_only": scope == "project",
234-
"reveal": False, "keys_only": False}
235-
if name is None:
236-
return _dispatch(devbase_root, "list", **attrs)
237-
return _run_in_project(devbase_root, name,
238-
lambda: _dispatch(devbase_root, "list", **attrs))
239-
240-
241-
def _op_set(devbase_root: Path):
242-
"""``env set``: 設定先 (グローバル / プロジェクト) と KEY=VALUE を収集して設定する。
243-
244-
プロジェクト設定 (--project) は対象を選ばせて chdir してから実行する (plan 3.3)。
245-
"""
246-
_, name = _select_scoped_project(
247-
devbase_root, "設定先を選択",
248-
[("グローバル ($DEVBASE_ROOT/.env)", "global"),
249-
("プロジェクト (projects/<name>/.env, --project)", "project")])
250-
assignment = flow.need(_collect_assignment())
251-
252-
if name is None:
253-
return _dispatch(devbase_root, "set", assignment=assignment, project=False)
254-
return _run_in_project(
255-
devbase_root, name,
256-
lambda: _dispatch(devbase_root, "set", assignment=assignment, project=True))
257-
258-
259-
def _op_get(devbase_root: Path):
260-
"""``env get``: 取得元 (グローバル / プロジェクト) と変数名を収集して値を表示する。
261-
262-
``cmd_env_get`` はグローバル .env に無いキーを CWD (PWD) のプロジェクト .env へ
263-
フォールバックして探すが、TUI は常に DEVBASE_ROOT で動くため、そのままでは
264-
プロジェクト固有キーを取得できない。list/set と同様に取得元を選ばせ、
265-
プロジェクト選択時は chdir + ``PWD`` 切替後に実行する (codex round2 指摘)。
266-
"""
267-
_, name = _select_scoped_project(
268-
devbase_root, "取得元を選択",
269-
[("グローバル ($DEVBASE_ROOT/.env)", "global"),
270-
("プロジェクト (グローバルに無ければ projects/<name>/.env)", "project")])
271-
key = flow.need(menu.text("取得する変数名", allow_empty=False))
272-
273-
if name is None:
274-
return _dispatch(devbase_root, "get", key=key)
275-
return _run_in_project(devbase_root, name,
276-
lambda: _dispatch(devbase_root, "get", key=key))
277-
278-
279-
def _op_delete(devbase_root: Path):
280-
key = flow.need(menu.text("削除する変数名", allow_empty=False))
281-
# 破壊的操作のため実行前に確認する (plan 3.4)。拒否 / Esc は実行せず戻る。
282-
flow.confirm_or_back(f"変数 '{key}' をグローバル .env から削除しますか?")
283-
return _dispatch(devbase_root, "delete", key=key)
284-
285-
286136
def _op_project(devbase_root: Path):
287137
# プロジェクト固有変数の対話設定。projects/ 配下で動く CWD スコープ操作の
288138
# ため、対象を選ばせて chdir してから実行する (plan 3.3)。
@@ -291,40 +141,21 @@ def _op_project(devbase_root: Path):
291141
lambda: _dispatch(devbase_root, "project"))
292142

293143

294-
def _op_export(devbase_root: Path):
295-
# 主要引数 dest のみ収集。空入力は parser 既定 (./devbase-env-<TS>.dbenv)。
296-
dest = flow.need(menu.path(
297-
"出力先パス (空で既定: ./devbase-env-<タイムスタンプ>.dbenv)",
298-
allow_empty=True))
299-
return _dispatch(devbase_root, "export", dest=(dest or None),
300-
**_export_default_attrs())
301-
302-
303-
def _op_import(devbase_root: Path):
304-
# 主要引数 source のみ収集 (必須 positional)。merge は parser 既定の
305-
# keep-existing (既存キー優先) で安全側。既存 .env はハンドラ側で
306-
# バックアップされる。
307-
source = flow.need(menu.path("インポートするバンドルのパス", allow_empty=False))
308-
return _dispatch(devbase_root, "import", source=source,
309-
**_import_default_attrs())
310-
311-
312144
_OP_HANDLERS = {
145+
# グローバル一覧は引数収集なしで即実行 (chdir 不要)。--reveal/--keys は
146+
# CLI 既定の False (伏せ字・通常表示)。
313147
# sync は引数なしで即実行 (ソースファイルから認証情報を再同期する)。
314148
# edit も引数なし。$DEVBASE_ROOT/.env を $EDITOR で開くグローバル操作のため
315149
# chdir しない (plan 3.3 は CWD スコープとするが実装を正とする)。
316150
# init は --reset なし (CLI 既定) で即実行。セットアップ済みなら
317151
# cmd_env_init が案内を出して安全に終了し、やり直しは CLI --reset を使う。
152+
"list-global": lambda root: _dispatch(root, "list", global_only=True,
153+
project_only=False,
154+
reveal=False, keys_only=False),
318155
"sync": lambda root: _dispatch(root, "sync"),
319156
"edit": lambda root: _dispatch(root, "edit"),
320157
"init": lambda root: _dispatch(root, "init", reset=False),
321-
"list": _op_list,
322-
"set": _op_set,
323-
"get": _op_get,
324-
"delete": _op_delete,
325158
"project": _op_project,
326-
"export": _op_export,
327-
"import": _op_import,
328159
}
329160

330161

lib/devbase/tui/menu.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,40 @@ def _cancel(event):
9696
return _add_escape_binding(question, _cancel)
9797

9898

99+
def _guard_after_done(question):
100+
"""回答確定後 (``Application.exit`` 済み) のキー処理を無効化する。
101+
102+
prompt_toolkit は 1 回の読み取りで複数キーを同一バッチとして処理するため、
103+
確定キーの直後に入力が溜まっていると (例: Ctrl-C 連打 / Enter 直後の Ctrl-C)、
104+
1 つ目のキーで exit して戻り値が確定した後も残りのキーが同じバッチ内で
105+
処理され、questionary 組み込みの Ctrl-C ハンドラ等が再度 exit を呼んで
106+
「Return value already set. Application.exit() failed.」のクラッシュになる
107+
(実 TTY でのみ再現)。アプリ単位の key_bindings (questionary 組み込み + 本
108+
モジュールが後付けする Esc/← を含む) を ``~is_done`` でガードし、確定後の
109+
キーは無視する。
110+
"""
111+
from prompt_toolkit.filters import is_done
112+
from prompt_toolkit.key_binding import ConditionalKeyBindings
113+
114+
kb = question.application.key_bindings
115+
if kb is not None:
116+
question.application.key_bindings = ConditionalKeyBindings(kb, ~is_done)
117+
return question
118+
119+
99120
def _ask_erased(question):
100121
"""``erase_when_done`` を立ててから ``ask()`` する共通ヘルパ (全プロンプト用)。
101122
102123
questionary は回答確定時に「質問 + 回答」の collapse 行を画面へ残す。TUI は
103124
ループでメニューを再描画するため、回答のたびにこの行が蓄積して画面全体が
104125
下へずれていく (実 TTY でのみ再現する残留・行ずれ不具合)。回答後に描画ごと
105126
消去することで、メニューを常に同じ位置へ再描画する。
127+
128+
併せて ``_guard_after_done`` で確定後のキー処理を無効化する (全プロンプトが
129+
本ヘルパを通るため、ここが単一の適用点)。
106130
"""
107131
question.application.erase_when_done = True
108-
return question.ask()
132+
return _guard_after_done(question).ask()
109133

110134

111135
def _ask_with_escape(question):

0 commit comments

Comments
 (0)