|
| 1 | +"""snapshot カテゴリの TUI 操作フロー (PLAN31_2 PR5)。 |
| 2 | +
|
| 3 | +サブコマンド選択メニュー → 引数収集 → ``dispatch_group(cmd_snapshot, ...)`` で |
| 4 | +既存ハンドラへ委譲する。属性契約は plan 2.3 の表 (cli.py ``_add_snapshot_parser`` |
| 5 | +と同期済みを確認): |
| 6 | +
|
| 7 | +- create: ``name`` (None=タイムスタンプ自動命名), ``full`` (False) |
| 8 | +- list: 追加属性なし |
| 9 | +- restore: ``name``, ``point`` (None=全差分適用 / manager は 1 以上のみ受理) |
| 10 | +- copy: ``name``, ``new_name`` |
| 11 | +- delete: ``name`` |
| 12 | +- rotate: ``keep`` (3) |
| 13 | +
|
| 14 | +破壊的な restore / delete は実行前に ``menu.confirm`` で確認する (plan 3.4)。 |
| 15 | +restore は ``cmd_snapshot`` 側にも TTY 時の input() 確認が残るが、TUI の規約として |
| 16 | +メニュー段階でも確認する (多重確認になっても安全側に倒す)。 |
| 17 | +
|
| 18 | +restore / copy / delete の対象 ``name`` は ``SnapshotManager.list()`` の既存一覧 |
| 19 | +から選択させる (タイプミス防止)。一覧の取得に失敗した場合のみ自由入力へ縮退する。 |
| 20 | +""" |
| 21 | + |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +from pathlib import Path |
| 25 | + |
| 26 | +from devbase.commands.snapshot import cmd_snapshot |
| 27 | +from devbase.log import get_logger |
| 28 | +from devbase.snapshot.manager import SnapshotManager |
| 29 | +from devbase.tui import menu |
| 30 | +from devbase.tui.dispatch import dispatch_group |
| 31 | + |
| 32 | +logger = get_logger(__name__) |
| 33 | + |
| 34 | +# snapshot カテゴリで選べる操作 (表示順 = ハイライト既定順)。閲覧のみで安全な |
| 35 | +# list を先頭に置き、Enter 連打では破壊的操作に到達しないようにする。 |
| 36 | +# 各 value は cmd_snapshot のサブコマンド名。 |
| 37 | +_SNAPSHOT_OPS: list[tuple[str, str]] = [ |
| 38 | + ("一覧表示 (list)", "list"), |
| 39 | + ("作成 (create)", "create"), |
| 40 | + ("復元 (restore)", "restore"), |
| 41 | + ("複製 (copy)", "copy"), |
| 42 | + ("削除 (delete)", "delete"), |
| 43 | + ("ローテーション (rotate)", "rotate"), |
| 44 | +] |
| 45 | + |
| 46 | +# 引数収集を Esc/Ctrl-C で中止したことを示す番兵 (= サブメニューへ戻る)。 |
| 47 | +# dispatch の rc (int) や ``None`` (= 全体中止) と区別する (actions_project と同じ規約)。 |
| 48 | +_ARG_CANCEL = object() |
| 49 | + |
| 50 | + |
| 51 | +def _select_operation(): |
| 52 | + """snapshot の操作を選ぶサブメニュー。 |
| 53 | +
|
| 54 | + 戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None`` (Ctrl-C 中止)。 |
| 55 | + """ |
| 56 | + return menu.select( |
| 57 | + "スナップショット操作を選択 " |
| 58 | + "(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):", |
| 59 | + list(_SNAPSHOT_OPS), back=True, search=False) |
| 60 | + |
| 61 | + |
| 62 | +def _select_snapshot_name(devbase_root: Path, message: str): |
| 63 | + """restore/copy/delete の対象スナップショット名を既存一覧から選ばせる。 |
| 64 | +
|
| 65 | + 戻り値: スナップショット名 (``str``) / ``_ARG_CANCEL`` (Esc・Ctrl-C 中止、 |
| 66 | + または対象が 1 件もない)。一覧の取得に失敗した場合は自由入力へ縮退する |
| 67 | + (存在チェックは委譲先の ``SnapshotManager`` が行う)。 |
| 68 | + """ |
| 69 | + try: |
| 70 | + snapshots = SnapshotManager(Path(devbase_root)).list() |
| 71 | + except Exception: |
| 72 | + logger.debug("スナップショット一覧の取得に失敗しました", exc_info=True) |
| 73 | + snapshots = None |
| 74 | + |
| 75 | + if snapshots is None: |
| 76 | + # 一覧が取れない環境では名前を直接入力させる (中止は None → _ARG_CANCEL)。 |
| 77 | + name = menu.text(message, allow_empty=False) |
| 78 | + return _ARG_CANCEL if name is None else name |
| 79 | + |
| 80 | + if not snapshots: |
| 81 | + logger.info("スナップショットがありません。先に作成 (create) してください。") |
| 82 | + return _ARG_CANCEL |
| 83 | + |
| 84 | + # 作成日時を添えて選びやすくする (値は名前のみ)。件数が多い場合に備え |
| 85 | + # 文字入力での絞り込み (search=True) を有効化。search 有効時の戻る操作は |
| 86 | + # Esc のみ (menu.select が ← バインドを外す)。 |
| 87 | + choices = [ |
| 88 | + (f"{s.get('name', '?')} ({str(s.get('created_at') or 'N/A')[:19]})", |
| 89 | + s.get("name")) |
| 90 | + for s in snapshots |
| 91 | + ] |
| 92 | + sel = menu.select( |
| 93 | + f"{message} (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc 戻る / Ctrl-C 中止):", |
| 94 | + choices, back=True, search=True) |
| 95 | + if sel is menu.MENU_BACK or sel is None: |
| 96 | + return _ARG_CANCEL |
| 97 | + return sel |
| 98 | + |
| 99 | + |
| 100 | +def _optional_point(message: str): |
| 101 | + """restore の ``--point`` を収集する (空入力 = 全差分適用 = None)。 |
| 102 | +
|
| 103 | + 戻り値: ``int`` / ``None`` (空入力) / ``_ARG_CANCEL`` (Esc・Ctrl-C 中止)。 |
| 104 | + ``menu.integer`` は空入力で既定値を返す仕様のため、空 = None を表現したい |
| 105 | + optional な数値はこちらで扱う (actions_project._optional_int と同じ理由)。 |
| 106 | + ``SnapshotManager.restore`` は point に正の整数のみ受理するため 1 以上を要求する。 |
| 107 | + """ |
| 108 | + while True: |
| 109 | + raw = menu.text(message, allow_empty=True) |
| 110 | + if raw is None: |
| 111 | + return _ARG_CANCEL |
| 112 | + if raw == "": |
| 113 | + return None |
| 114 | + try: |
| 115 | + value = int(raw) |
| 116 | + except ValueError: |
| 117 | + logger.error("整数で指定してください: %r", raw) |
| 118 | + continue |
| 119 | + if value < 1: |
| 120 | + logger.error("1 以上で指定してください。") |
| 121 | + continue |
| 122 | + return value |
| 123 | + |
| 124 | + |
| 125 | +def _run_operation(devbase_root: Path, op: str): |
| 126 | + """選択された操作の引数を収集して ``dispatch_group`` で ``cmd_snapshot`` へ委譲する。 |
| 127 | +
|
| 128 | + 戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 = サブメニューへ戻る)。 |
| 129 | + 破壊的な restore / delete は ``menu.confirm`` で確認し、拒否時は実行しない (plan 3.4)。 |
| 130 | + """ |
| 131 | + if op == "list": |
| 132 | + return dispatch_group(cmd_snapshot, devbase_root, "list") |
| 133 | + |
| 134 | + if op == "create": |
| 135 | + name = menu.text("スナップショット名 (空でタイムスタンプ自動命名)", |
| 136 | + allow_empty=True) |
| 137 | + if name is None: |
| 138 | + return _ARG_CANCEL |
| 139 | + full = menu.confirm("フルバックアップを強制しますか (--full)?", default=False) |
| 140 | + if full is None: |
| 141 | + return _ARG_CANCEL |
| 142 | + # 空入力は CLI の --name 省略と同じ None (自動命名) に正規化する。 |
| 143 | + return dispatch_group(cmd_snapshot, devbase_root, "create", |
| 144 | + name=name or None, full=full) |
| 145 | + |
| 146 | + if op == "restore": |
| 147 | + name = _select_snapshot_name(devbase_root, "復元するスナップショットを選択") |
| 148 | + if name is _ARG_CANCEL: |
| 149 | + return _ARG_CANCEL |
| 150 | + point = _optional_point("適用する差分番号 incr-N の上限 (--point / 空で全差分適用)") |
| 151 | + if point is _ARG_CANCEL: |
| 152 | + return _ARG_CANCEL |
| 153 | + point_msg = f" (incr-{point:03d} まで)" if point is not None else "" |
| 154 | + ok = menu.confirm( |
| 155 | + f"'{name}'{point_msg} から復元しますか? 現在のボリュームデータは上書きされます。", |
| 156 | + default=False) |
| 157 | + if not ok: # False (拒否) / None (中止) → 実行しない |
| 158 | + return _ARG_CANCEL |
| 159 | + return dispatch_group(cmd_snapshot, devbase_root, "restore", |
| 160 | + name=name, point=point) |
| 161 | + |
| 162 | + if op == "copy": |
| 163 | + name = _select_snapshot_name(devbase_root, "複製元のスナップショットを選択") |
| 164 | + if name is _ARG_CANCEL: |
| 165 | + return _ARG_CANCEL |
| 166 | + new_name = menu.text("複製先のスナップショット名", allow_empty=False) |
| 167 | + if new_name is None: |
| 168 | + return _ARG_CANCEL |
| 169 | + return dispatch_group(cmd_snapshot, devbase_root, "copy", |
| 170 | + name=name, new_name=new_name) |
| 171 | + |
| 172 | + if op == "delete": |
| 173 | + name = _select_snapshot_name(devbase_root, "削除するスナップショットを選択") |
| 174 | + if name is _ARG_CANCEL: |
| 175 | + return _ARG_CANCEL |
| 176 | + ok = menu.confirm(f"スナップショット '{name}' を削除しますか?", default=False) |
| 177 | + if not ok: # False (拒否) / None (中止) → 実行しない |
| 178 | + return _ARG_CANCEL |
| 179 | + return dispatch_group(cmd_snapshot, devbase_root, "delete", name=name) |
| 180 | + |
| 181 | + if op == "rotate": |
| 182 | + # keep=0 は manager 実装上 no-op (空スライス) のため 1 以上を要求する。 |
| 183 | + keep = menu.integer("保持する世代数 (--keep)", default=3, min_value=1) |
| 184 | + if keep is None: |
| 185 | + return _ARG_CANCEL |
| 186 | + return dispatch_group(cmd_snapshot, devbase_root, "rotate", keep=keep) |
| 187 | + |
| 188 | + # 到達しない (メニュー値は _SNAPSHOT_OPS に限定される)。保守的に no-op。 |
| 189 | + logger.error("未知の操作です: %s", op) |
| 190 | + return _ARG_CANCEL |
| 191 | + |
| 192 | + |
| 193 | +def run(devbase_root: Path): |
| 194 | + """スナップショット操作カテゴリ。操作選択 → 引数収集 → 実行。 |
| 195 | +
|
| 196 | + 戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同じ): |
| 197 | + - **操作を実行した場合**: ``dispatch_group`` の rc (``int``) を返す。 |
| 198 | + 「実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。 |
| 199 | + - ``menu.MENU_BACK``: 操作メニューで Esc/← (操作なしでトップへ)。 |
| 200 | + - ``None``: Ctrl-C による全体中止。 |
| 201 | +
|
| 202 | + 引数収集を中止 (``_ARG_CANCEL``) した場合は操作メニューを再表示する。 |
| 203 | + 操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。 |
| 204 | + """ |
| 205 | + while True: |
| 206 | + op = _select_operation() |
| 207 | + if op is menu.MENU_BACK: |
| 208 | + return menu.MENU_BACK |
| 209 | + if op is None: |
| 210 | + return None |
| 211 | + rc = _run_operation(devbase_root, op) |
| 212 | + if rc is _ARG_CANCEL: |
| 213 | + continue # 引数収集を中止 → 操作メニューへ戻る |
| 214 | + return rc # 実行 rc → 呼び出し元 (トップ) へ |
0 commit comments