Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions lib/devbase/tui/actions_snapshot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
"""snapshot カテゴリの TUI 操作フロー (PLAN31_2 PR5)。

サブコマンド選択メニュー → 引数収集 → ``dispatch_group(cmd_snapshot, ...)`` で
既存ハンドラへ委譲する。属性契約は plan 2.3 の表 (cli.py ``_add_snapshot_parser``
と同期済みを確認):

- create: ``name`` (None=タイムスタンプ自動命名), ``full`` (False)
- list: 追加属性なし
- restore: ``name``, ``point`` (None=全差分適用 / manager は 1 以上のみ受理)
- copy: ``name``, ``new_name``
- delete: ``name``
- rotate: ``keep`` (3)

破壊的な restore / delete は実行前に ``menu.confirm`` で確認する (plan 3.4)。
restore は ``cmd_snapshot`` 側にも TTY 時の input() 確認が残るが、TUI の規約として
メニュー段階でも確認する (多重確認になっても安全側に倒す)。

restore / copy / delete の対象 ``name`` は ``SnapshotManager.list()`` の既存一覧
から選択させる (タイプミス防止)。一覧の取得に失敗した場合のみ自由入力へ縮退する。
"""

from __future__ import annotations

from pathlib import Path

from devbase.commands.snapshot import cmd_snapshot
from devbase.log import get_logger
from devbase.snapshot.manager import SnapshotManager
from devbase.tui import menu
from devbase.tui.dispatch import dispatch_group

logger = get_logger(__name__)

# snapshot カテゴリで選べる操作 (表示順 = ハイライト既定順)。閲覧のみで安全な
# list を先頭に置き、Enter 連打では破壊的操作に到達しないようにする。
# 各 value は cmd_snapshot のサブコマンド名。
_SNAPSHOT_OPS: list[tuple[str, str]] = [
("一覧表示 (list)", "list"),
("作成 (create)", "create"),
("復元 (restore)", "restore"),
("複製 (copy)", "copy"),
("削除 (delete)", "delete"),
("ローテーション (rotate)", "rotate"),
]

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


def _select_operation():
"""snapshot の操作を選ぶサブメニュー。

戻り値: サブコマンド文字列 / ``MENU_BACK`` (Esc・← → トップへ戻る) / ``None`` (Ctrl-C 中止)。
"""
return menu.select(
"スナップショット操作を選択 "
"(↑↓ 移動 / Enter 決定 / ←・Esc 戻る / Ctrl-C 中止):",
list(_SNAPSHOT_OPS), back=True, search=False)


def _select_snapshot_name(devbase_root: Path, message: str):
"""restore/copy/delete の対象スナップショット名を既存一覧から選ばせる。

戻り値: スナップショット名 (``str``) / ``_ARG_CANCEL`` (Esc・Ctrl-C 中止、
または対象が 1 件もない)。一覧の取得に失敗した場合は自由入力へ縮退する
(存在チェックは委譲先の ``SnapshotManager`` が行う)。
"""
try:
snapshots = SnapshotManager(Path(devbase_root)).list()
except Exception:
logger.debug("スナップショット一覧の取得に失敗しました", exc_info=True)
snapshots = None

if snapshots is None:
# 一覧が取れない環境では名前を直接入力させる (中止は None → _ARG_CANCEL)。
name = menu.text(message, allow_empty=False)
return _ARG_CANCEL if name is None else name

if not snapshots:
logger.info("スナップショットがありません。先に作成 (create) してください。")
return _ARG_CANCEL

# 作成日時を添えて選びやすくする (値は名前のみ)。件数が多い場合に備え
# 文字入力での絞り込み (search=True) を有効化。search 有効時の戻る操作は
# Esc のみ (menu.select が ← バインドを外す)。
choices = [
(f"{s.get('name', '?')} ({str(s.get('created_at') or 'N/A')[:19]})",
s.get("name"))
for s in snapshots
]
sel = menu.select(
f"{message} (↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc 戻る / Ctrl-C 中止):",
choices, back=True, search=True)
if sel is menu.MENU_BACK or sel is None:
return _ARG_CANCEL
return sel


def _optional_point(message: str):
"""restore の ``--point`` を収集する (空入力 = 全差分適用 = None)。

戻り値: ``int`` / ``None`` (空入力) / ``_ARG_CANCEL`` (Esc・Ctrl-C 中止)。
``menu.integer`` は空入力で既定値を返す仕様のため、空 = None を表現したい
optional な数値はこちらで扱う (actions_project._optional_int と同じ理由)。
``SnapshotManager.restore`` は point に正の整数のみ受理するため 1 以上を要求する。
"""
while True:
raw = menu.text(message, allow_empty=True)
if raw is None:
return _ARG_CANCEL
if raw == "":
return None
try:
value = int(raw)
except ValueError:
logger.error("整数で指定してください: %r", raw)
continue
if value < 1:
logger.error("1 以上で指定してください。")
continue
return value


def _run_operation(devbase_root: Path, op: str):
"""選択された操作の引数を収集して ``dispatch_group`` で ``cmd_snapshot`` へ委譲する。

戻り値: dispatch の rc (``int``) / ``_ARG_CANCEL`` (引数収集を中止 = サブメニューへ戻る)。
破壊的な restore / delete は ``menu.confirm`` で確認し、拒否時は実行しない (plan 3.4)。
"""
if op == "list":
return dispatch_group(cmd_snapshot, devbase_root, "list")

if op == "create":
name = menu.text("スナップショット名 (空でタイムスタンプ自動命名)",
allow_empty=True)
if name is None:
return _ARG_CANCEL
full = menu.confirm("フルバックアップを強制しますか (--full)?", default=False)
if full is None:
return _ARG_CANCEL
# 空入力は CLI の --name 省略と同じ None (自動命名) に正規化する。
return dispatch_group(cmd_snapshot, devbase_root, "create",
name=name or None, full=full)

if op == "restore":
name = _select_snapshot_name(devbase_root, "復元するスナップショットを選択")
if name is _ARG_CANCEL:
return _ARG_CANCEL
point = _optional_point("適用する差分番号 incr-N の上限 (--point / 空で全差分適用)")
if point is _ARG_CANCEL:
return _ARG_CANCEL
point_msg = f" (incr-{point:03d} まで)" if point is not None else ""
ok = menu.confirm(
f"'{name}'{point_msg} から復元しますか? 現在のボリュームデータは上書きされます。",
default=False)
if not ok: # False (拒否) / None (中止) → 実行しない
return _ARG_CANCEL
return dispatch_group(cmd_snapshot, devbase_root, "restore",
name=name, point=point)

if op == "copy":
name = _select_snapshot_name(devbase_root, "複製元のスナップショットを選択")
if name is _ARG_CANCEL:
return _ARG_CANCEL
new_name = menu.text("複製先のスナップショット名", allow_empty=False)
if new_name is None:
return _ARG_CANCEL
return dispatch_group(cmd_snapshot, devbase_root, "copy",
name=name, new_name=new_name)

if op == "delete":
name = _select_snapshot_name(devbase_root, "削除するスナップショットを選択")
if name is _ARG_CANCEL:
return _ARG_CANCEL
ok = menu.confirm(f"スナップショット '{name}' を削除しますか?", default=False)
if not ok: # False (拒否) / None (中止) → 実行しない
return _ARG_CANCEL
return dispatch_group(cmd_snapshot, devbase_root, "delete", name=name)

if op == "rotate":
# keep=0 は manager 実装上 no-op (空スライス) のため 1 以上を要求する。
keep = menu.integer("保持する世代数 (--keep)", default=3, min_value=1)
if keep is None:
return _ARG_CANCEL
return dispatch_group(cmd_snapshot, devbase_root, "rotate", keep=keep)

# 到達しない (メニュー値は _SNAPSHOT_OPS に限定される)。保守的に no-op。
logger.error("未知の操作です: %s", op)
return _ARG_CANCEL


def run(devbase_root: Path):
"""スナップショット操作カテゴリ。操作選択 → 引数収集 → 実行。

戻り値プロトコル (トップループが ``is`` 同一性で判定する。actions_project と同じ):
- **操作を実行した場合**: ``dispatch_group`` の rc (``int``) を返す。
「実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。
- ``menu.MENU_BACK``: 操作メニューで Esc/← (操作なしでトップへ)。
- ``None``: Ctrl-C による全体中止。

引数収集を中止 (``_ARG_CANCEL``) した場合は操作メニューを再表示する。
操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。
"""
while True:
op = _select_operation()
if op is menu.MENU_BACK:
return menu.MENU_BACK
if op is None:
return None
rc = _run_operation(devbase_root, op)
if rc is _ARG_CANCEL:
continue # 引数収集を中止 → 操作メニューへ戻る
return rc # 実行 rc → 呼び出し元 (トップ) へ
18 changes: 18 additions & 0 deletions lib/devbase/tui/actions_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""status カテゴリの TUI 操作フロー (PLAN31_2 PR5)。

status は閲覧のみでサブコマンドも引数も持たない (``cmd_status(devbase_root)`` /
plan 2.2)。そのためメニューや引数収集を介さず、表示してそのまま rc を返す
薄い委譲に留める。rc (``int``) を返すことで「操作を実行した → トップへ復帰」
の戻り値プロトコル (actions_project と同じ) に従い、トップループが rc を記憶する。
"""

from __future__ import annotations

from pathlib import Path

from devbase.commands.status import cmd_status


def run(devbase_root: Path) -> int:
"""ステータスを表示し、rc を返してトップメニューへ復帰する。"""
return cmd_status(Path(devbase_root))
12 changes: 8 additions & 4 deletions lib/devbase/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
プロジェクト一覧の選択だけだった旧挙動を、全カテゴリ
(project / env / plugin / snapshot / status) を束ねるトップ階層メニューへ拡張する。

PR1 では **project カテゴリのみ配線**し、env/plugin/snapshot/status は後続 PR
(PR3〜PR5) で各 ``actions_*`` を ``_route`` に足すまでプレースホルダ案内を出す。
PR1 project、PR5 で snapshot/status を配線済み。env/plugin は後続 PR
(PR3/PR4) で各 ``actions_*`` を ``_route`` に足すまでプレースホルダ案内を出す。

後方互換 (plan 3.2):
- ``--no-interactive`` / ``--plain`` (interactive=False) と非 TTY は従来どおり一覧
Expand All @@ -26,7 +26,7 @@

from devbase.commands.project import _print_table, list_projects
from devbase.log import get_logger
from devbase.tui import actions_project, menu
from devbase.tui import actions_project, actions_snapshot, actions_status, menu

logger = get_logger(__name__)

Expand Down Expand Up @@ -56,7 +56,11 @@ def _route(category: str, devbase_root: Path):
"""
if category == "project":
return actions_project.run(devbase_root)
# PR3: env, PR4: plugin, PR5: snapshot/status をここに追加する。
if category == "snapshot":
return actions_snapshot.run(devbase_root)
if category == "status":
return actions_status.run(devbase_root)
# PR3: env, PR4: plugin をここに追加する。
logger.info("「%s」は後続 PR で実装予定です。", _LABELS.get(category, category))
return menu.MENU_BACK

Expand Down
Loading