|
| 1 | +"""plugin カテゴリの TUI 操作フロー (PLAN31_2 PR4)。 |
| 2 | +
|
| 3 | +``devbase plugin`` の全サブコマンド (list/install/uninstall/update/info/sync/migrate) |
| 4 | +と ``plugin repo`` のサブ階層 (add/remove/list/refresh) を TUI から実行できるようにする。 |
| 5 | +引数は ``tui.menu`` の収集ヘルパで CLI parser と同じ属性値 (plan 2.3 契約表) を集め、 |
| 6 | +``tui.dispatch.dispatch_group`` 経由で既存ハンドラ ``cmd_plugin`` へ委譲する |
| 7 | +(ロジック二重実装なし)。 |
| 8 | +
|
| 9 | +uninstall/update/info および repo remove/refresh の ``name`` は、registry |
| 10 | +(``plugins.yml``) から取得した導入済み plugin / 登録済みリポジトリの一覧から |
| 11 | +選択させる (自由入力によるタイプミスを防ぐ)。破壊的な uninstall / repo remove は |
| 12 | +``menu.confirm`` で実行前確認する (plan 3.4)。 |
| 13 | +
|
| 14 | +ナビ規約 (actions_project と同一): |
| 15 | +- Esc / ← = 1 つ前のメニューへ戻る (``menu.MENU_BACK``) |
| 16 | +- Ctrl-C = 全体中止 (``None`` を伝搬) |
| 17 | +- 引数収集の中止 (``_ARG_CANCEL``) = 直前のサブメニューを再表示 |
| 18 | +""" |
| 19 | + |
| 20 | +from __future__ import annotations |
| 21 | + |
| 22 | +from pathlib import Path |
| 23 | + |
| 24 | +from devbase.errors import DevbaseError |
| 25 | +from devbase.log import get_logger |
| 26 | +from devbase.tui import menu |
| 27 | +from devbase.tui.dispatch import dispatch_group |
| 28 | + |
| 29 | +logger = get_logger(__name__) |
| 30 | + |
| 31 | +# plugin サブコマンド (表示順 = ハイライト既定順)。閲覧系の list を先頭に置き、 |
| 32 | +# Enter 連打で安全な一覧表示へ到達できるようにする。value は cmd_plugin の subcommand 名 |
| 33 | +# (repo のみサブ階層メニューへの分岐)。 |
| 34 | +_PLUGIN_OPS: list[tuple[str, str]] = [ |
| 35 | + ("一覧表示 (list)", "list"), |
| 36 | + ("インストール (install)", "install"), |
| 37 | + ("アンインストール (uninstall)", "uninstall"), |
| 38 | + ("更新 (update)", "update"), |
| 39 | + ("詳細表示 (info)", "info"), |
| 40 | + ("プロジェクトリンク再同期 (sync)", "sync"), |
| 41 | + ("レガシー構成の移行 (migrate)", "migrate"), |
| 42 | + ("リポジトリ管理 (repo)", "repo"), |
| 43 | +] |
| 44 | + |
| 45 | +# plugin repo サブ階層 (表示順 = ハイライト既定順)。value は repo_command 名。 |
| 46 | +_REPO_OPS: list[tuple[str, str]] = [ |
| 47 | + ("リポジトリ一覧 (list)", "list"), |
| 48 | + ("リポジトリ登録 (add)", "add"), |
| 49 | + ("リポジトリ削除 (remove)", "remove"), |
| 50 | + ("リポジトリ更新 (refresh)", "refresh"), |
| 51 | +] |
| 52 | + |
| 53 | +# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。 |
| 54 | +# dispatch の rc (int) や ``None`` (= 全体中止) と区別する (actions_project と同じ)。 |
| 55 | +_ARG_CANCEL = object() |
| 56 | + |
| 57 | + |
| 58 | +def _dispatch(devbase_root: Path, subcommand: str, **attrs) -> int: |
| 59 | + """``cmd_plugin`` へ委譲する (plan 2.3 の属性契約は呼び出し側が守る)。 |
| 60 | +
|
| 61 | + import を呼び出し時まで遅延させ、テストが ``commands.plugin.cmd_plugin`` を |
| 62 | + monkeypatch で差し替えられるようにする (dispatch_lifecycle と同じ流儀)。 |
| 63 | + """ |
| 64 | + from devbase.commands.plugin import cmd_plugin |
| 65 | + |
| 66 | + return dispatch_group(cmd_plugin, devbase_root, subcommand, **attrs) |
| 67 | + |
| 68 | + |
| 69 | +# --------------------------------------------------------------------------- |
| 70 | +# 名前選択 (registry から一覧を取得して選ばせる) |
| 71 | +# --------------------------------------------------------------------------- |
| 72 | + |
| 73 | +def _installed_plugin_names(devbase_root: Path) -> list[str]: |
| 74 | + """導入済み plugin 名の一覧を registry (plugins.yml) から取得する。""" |
| 75 | + from devbase.plugin.registry import PluginRegistry |
| 76 | + |
| 77 | + try: |
| 78 | + return [p.name for p in PluginRegistry(Path(devbase_root)).list_installed()] |
| 79 | + except DevbaseError as e: |
| 80 | + logger.error("%s", e) |
| 81 | + return [] |
| 82 | + |
| 83 | + |
| 84 | +def _repository_names(devbase_root: Path) -> list[str]: |
| 85 | + """登録済みリポジトリ名の一覧を registry (plugins.yml) から取得する。""" |
| 86 | + from devbase.plugin.registry import PluginRegistry |
| 87 | + |
| 88 | + try: |
| 89 | + return [r.name for r in PluginRegistry(Path(devbase_root)).list_repositories()] |
| 90 | + except DevbaseError as e: |
| 91 | + logger.error("%s", e) |
| 92 | + return [] |
| 93 | + |
| 94 | + |
| 95 | +def _select_name(message: str, names: list[str], *, all_label: str | None = None): |
| 96 | + """名前一覧から 1 件選ばせる共通ヘルパ。 |
| 97 | +
|
| 98 | + ``all_label`` 指定時は「全対象」(value="") を先頭に置く。選択メニューの ``None`` |
| 99 | + (Ctrl-C) と衝突させないため空文字を番兵にし、呼び出し側へは ``None`` に変換して |
| 100 | + 返す (_select_build_image と同じ流儀)。 |
| 101 | +
|
| 102 | + 戻り値: 名前 (``str``) / ``None`` (all_label 選択 = 全対象) / ``_ARG_CANCEL`` |
| 103 | + (Esc・←・Ctrl-C 中止)。 |
| 104 | + """ |
| 105 | + choices: list[tuple[str, str]] = [] |
| 106 | + if all_label is not None: |
| 107 | + choices.append((all_label, "")) |
| 108 | + choices += [(n, n) for n in names] |
| 109 | + sel = menu.select( |
| 110 | + f"{message} (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", |
| 111 | + choices, back=True, search=False) |
| 112 | + if sel is menu.MENU_BACK or sel is None: |
| 113 | + return _ARG_CANCEL |
| 114 | + return sel or None # "" → None (全対象) |
| 115 | + |
| 116 | + |
| 117 | +def _select_installed_plugin(devbase_root: Path, message: str, *, |
| 118 | + all_label: str | None = None): |
| 119 | + """導入済み plugin から 1 件選ばせる。対象が無ければ案内して ``_ARG_CANCEL``。""" |
| 120 | + names = _installed_plugin_names(devbase_root) |
| 121 | + if not names: |
| 122 | + logger.info("導入済みの plugin がありません。" |
| 123 | + "`plugin install` で導入してください。") |
| 124 | + return _ARG_CANCEL |
| 125 | + return _select_name(message, names, all_label=all_label) |
| 126 | + |
| 127 | + |
| 128 | +def _select_repository(devbase_root: Path, message: str, *, |
| 129 | + all_label: str | None = None): |
| 130 | + """登録済みリポジトリから 1 件選ばせる。対象が無ければ案内して ``_ARG_CANCEL``。""" |
| 131 | + names = _repository_names(devbase_root) |
| 132 | + if not names: |
| 133 | + logger.info("登録済みのリポジトリがありません。" |
| 134 | + "`plugin repo add` で登録してください。") |
| 135 | + return _ARG_CANCEL |
| 136 | + return _select_name(message, names, all_label=all_label) |
| 137 | + |
| 138 | + |
| 139 | +# --------------------------------------------------------------------------- |
| 140 | +# サブメニュー |
| 141 | +# --------------------------------------------------------------------------- |
| 142 | + |
| 143 | +def _select_operation(): |
| 144 | + """plugin 操作を選ぶサブメニュー。 |
| 145 | +
|
| 146 | + 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None`` |
| 147 | + (Ctrl-C 中止)。 |
| 148 | + """ |
| 149 | + return menu.select( |
| 150 | + "plugin 操作を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", |
| 151 | + list(_PLUGIN_OPS), back=True, search=False) |
| 152 | + |
| 153 | + |
| 154 | +def _select_repo_operation(): |
| 155 | + """plugin repo 操作を選ぶサブ階層メニュー。 |
| 156 | +
|
| 157 | + 戻り値: repo_command 文字列 / ``MENU_BACK`` (Esc・← → plugin メニューへ戻る) / |
| 158 | + ``None`` (Ctrl-C 中止)。 |
| 159 | + """ |
| 160 | + return menu.select( |
| 161 | + "リポジトリ操作を選択 (↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", |
| 162 | + list(_REPO_OPS), back=True, search=False) |
| 163 | + |
| 164 | + |
| 165 | +# --------------------------------------------------------------------------- |
| 166 | +# 各操作の引数収集 + dispatch (plan 2.3 契約) |
| 167 | +# --------------------------------------------------------------------------- |
| 168 | + |
| 169 | +def _run_operation(devbase_root: Path, op: str): |
| 170 | + """選択された plugin 操作の引数を収集して ``cmd_plugin`` へ委譲する。 |
| 171 | +
|
| 172 | + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 = |
| 173 | + サブメニューへ戻る)。破壊的な uninstall は ``menu.confirm`` で確認する (plan 3.4)。 |
| 174 | + """ |
| 175 | + if op == "list": |
| 176 | + # --available: 導入済み一覧の代わりに未導入の利用可能 plugin を表示する。 |
| 177 | + available = menu.confirm( |
| 178 | + "未導入の利用可能 plugin を表示しますか (--available)?", default=False) |
| 179 | + if available is None: |
| 180 | + return _ARG_CANCEL |
| 181 | + return _dispatch(devbase_root, "list", available=available) |
| 182 | + |
| 183 | + if op == "install": |
| 184 | + source = menu.text( |
| 185 | + "インストールする plugin の source (名前 / URL / パス)", |
| 186 | + allow_empty=False) |
| 187 | + if source is None: |
| 188 | + return _ARG_CANCEL |
| 189 | + link = menu.confirm( |
| 190 | + "symlink としてインストールしますか (--link)?", default=False) |
| 191 | + if link is None: |
| 192 | + return _ARG_CANCEL |
| 193 | + install_all = menu.confirm( |
| 194 | + "リポジトリ内の全 plugin をインストールしますか (--all)?", default=False) |
| 195 | + if install_all is None: |
| 196 | + return _ARG_CANCEL |
| 197 | + return _dispatch(devbase_root, "install", |
| 198 | + source=source, link=link, install_all=install_all) |
| 199 | + |
| 200 | + if op == "uninstall": |
| 201 | + name = _select_installed_plugin( |
| 202 | + devbase_root, "アンインストールする plugin を選択") |
| 203 | + if name is _ARG_CANCEL: |
| 204 | + return _ARG_CANCEL |
| 205 | + ok = menu.confirm(f"plugin '{name}' をアンインストールしますか?", default=False) |
| 206 | + if not ok: # False (拒否) / None (中止) → 実行しない |
| 207 | + return _ARG_CANCEL |
| 208 | + return _dispatch(devbase_root, "uninstall", name=name) |
| 209 | + |
| 210 | + if op == "update": |
| 211 | + # name=None で全 plugin 更新 (CLI の `plugin update` 引数省略と同じ)。 |
| 212 | + name = _select_installed_plugin( |
| 213 | + devbase_root, "更新する plugin を選択", |
| 214 | + all_label="全 plugin を更新") |
| 215 | + if name is _ARG_CANCEL: |
| 216 | + return _ARG_CANCEL |
| 217 | + return _dispatch(devbase_root, "update", name=name) |
| 218 | + |
| 219 | + if op == "info": |
| 220 | + name = _select_installed_plugin( |
| 221 | + devbase_root, "詳細を表示する plugin を選択") |
| 222 | + if name is _ARG_CANCEL: |
| 223 | + return _ARG_CANCEL |
| 224 | + return _dispatch(devbase_root, "info", name=name) |
| 225 | + |
| 226 | + if op in ("sync", "migrate"): |
| 227 | + # 引数なし (plan 2.3: sync/migrate は属性なし)。即実行。 |
| 228 | + return _dispatch(devbase_root, op) |
| 229 | + |
| 230 | + # 到達しない (メニュー値は _PLUGIN_OPS に限定される)。保守的に no-op。 |
| 231 | + logger.error("未知の操作です: %s", op) |
| 232 | + return _ARG_CANCEL |
| 233 | + |
| 234 | + |
| 235 | +def _run_repo_operation(devbase_root: Path, op: str): |
| 236 | + """選択された plugin repo 操作の引数を収集して ``cmd_plugin`` へ委譲する。 |
| 237 | +
|
| 238 | + repo 系は ``subcommand='repo'`` + ``repo_command=<op>`` の二段属性で |
| 239 | + ``cmd_repo`` へ分岐する (plan 2.3 契約)。戻り値プロトコルは ``_run_operation`` |
| 240 | + と同じ。破壊的な remove は ``menu.confirm`` で確認する (plan 3.4)。 |
| 241 | + """ |
| 242 | + if op == "list": |
| 243 | + return _dispatch(devbase_root, "repo", repo_command="list") |
| 244 | + |
| 245 | + if op == "add": |
| 246 | + url = menu.text( |
| 247 | + "登録するリポジトリの URL (GitHub は owner/repo 短縮形も可)", |
| 248 | + allow_empty=False) |
| 249 | + if url is None: |
| 250 | + return _ARG_CANCEL |
| 251 | + # --name は任意 (空で URL から自動命名)。空文字は None へ変換して渡す。 |
| 252 | + name = menu.text("カスタム名 (--name 空で自動)", allow_empty=True) |
| 253 | + if name is None: |
| 254 | + return _ARG_CANCEL |
| 255 | + return _dispatch(devbase_root, "repo", |
| 256 | + repo_command="add", url=url, name=name or None) |
| 257 | + |
| 258 | + if op == "remove": |
| 259 | + name = _select_repository(devbase_root, "削除するリポジトリを選択") |
| 260 | + if name is _ARG_CANCEL: |
| 261 | + return _ARG_CANCEL |
| 262 | + ok = menu.confirm(f"リポジトリ '{name}' を削除しますか?", default=False) |
| 263 | + if not ok: # False (拒否) / None (中止) → 実行しない |
| 264 | + return _ARG_CANCEL |
| 265 | + force = menu.confirm( |
| 266 | + "未 commit / 未 push の変更があっても強制削除しますか (--force)?", |
| 267 | + default=False) |
| 268 | + if force is None: |
| 269 | + return _ARG_CANCEL |
| 270 | + return _dispatch(devbase_root, "repo", |
| 271 | + repo_command="remove", name=name, force=force) |
| 272 | + |
| 273 | + if op == "refresh": |
| 274 | + # name=None で全リポジトリを refresh (CLI の引数省略と同じ)。 |
| 275 | + name = _select_repository( |
| 276 | + devbase_root, "更新するリポジトリを選択", |
| 277 | + all_label="全リポジトリを更新") |
| 278 | + if name is _ARG_CANCEL: |
| 279 | + return _ARG_CANCEL |
| 280 | + return _dispatch(devbase_root, "repo", repo_command="refresh", name=name) |
| 281 | + |
| 282 | + # 到達しない (メニュー値は _REPO_OPS に限定される)。保守的に no-op。 |
| 283 | + logger.error("未知のリポジトリ操作です: %s", op) |
| 284 | + return _ARG_CANCEL |
| 285 | + |
| 286 | + |
| 287 | +# --------------------------------------------------------------------------- |
| 288 | +# メニューループ |
| 289 | +# --------------------------------------------------------------------------- |
| 290 | + |
| 291 | +def _repo_menu(devbase_root: Path): |
| 292 | + """plugin repo のサブ階層メニューを回す。 |
| 293 | +
|
| 294 | + 戻り値プロトコル (``is`` 同一性判定): |
| 295 | + - dispatch の rc (``int``): 操作を実行 → 呼び出し元へ (最終的にトップへ復帰)。 |
| 296 | + - ``menu.MENU_BACK``: Esc/← で plugin メニューへ戻る。 |
| 297 | + - ``None``: Ctrl-C で全体中止。 |
| 298 | +
|
| 299 | + 引数収集を中止 (``_ARG_CANCEL``) した場合はサブ階層メニューを再表示する。 |
| 300 | + """ |
| 301 | + while True: |
| 302 | + op = _select_repo_operation() |
| 303 | + if op is menu.MENU_BACK: |
| 304 | + return menu.MENU_BACK |
| 305 | + if op is None: |
| 306 | + return None |
| 307 | + rc = _run_repo_operation(devbase_root, op) |
| 308 | + if rc is _ARG_CANCEL: |
| 309 | + continue # 引数収集を中止 → サブ階層メニューへ戻る |
| 310 | + return rc # 実行 rc → 呼び出し元へ |
| 311 | + |
| 312 | + |
| 313 | +def run(devbase_root: Path): |
| 314 | + """プラグイン操作カテゴリ。操作選択 → 引数収集 → ``cmd_plugin`` へ委譲。 |
| 315 | +
|
| 316 | + 戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同じ): |
| 317 | + - **操作を実行した場合**: dispatch の rc (``int``) を返す。失敗 (非0) は |
| 318 | + ``devbase list`` の終了コードへ伝搬する。 |
| 319 | + - ``menu.MENU_BACK``: 操作なしでトップメニューへ戻る (Esc/←)。 |
| 320 | + - ``None``: Ctrl-C による全体中止。 |
| 321 | +
|
| 322 | + repo はサブ階層メニュー (``_repo_menu``) へ分岐し、Esc/← で plugin メニューへ |
| 323 | + 戻れる。引数収集を中止した場合は plugin メニューを再表示する。 |
| 324 | + 操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。 |
| 325 | + """ |
| 326 | + while True: |
| 327 | + op = _select_operation() |
| 328 | + if op is menu.MENU_BACK: |
| 329 | + return menu.MENU_BACK |
| 330 | + if op is None: |
| 331 | + return None |
| 332 | + |
| 333 | + if op == "repo": |
| 334 | + rc = _repo_menu(devbase_root) |
| 335 | + if rc is menu.MENU_BACK: |
| 336 | + continue # plugin メニューへ戻る |
| 337 | + return rc # 実行 rc (int) / None (Ctrl-C) を伝搬 |
| 338 | + rc = _run_operation(devbase_root, op) |
| 339 | + if rc is _ARG_CANCEL: |
| 340 | + continue # 引数収集を中止 → plugin メニューへ戻る |
| 341 | + |
| 342 | + # 操作完了 → トップメニューへ復帰。rc は呼び出し側 (top loop) が記憶し |
| 343 | + # 最終的な devbase の終了コードへ伝搬させる。 |
| 344 | + return rc |
0 commit comments