Skip to content

Commit 8db78e5

Browse files
takemi-ohamaclaude
andauthored
feat: PLAN31_2-snapshot-status snapshot 全操作 + status 閲覧の TUI 追加 (#60)
* chore: PLAN31_2-snapshot-status Draft PR 作成 * feat: PLAN31_2-snapshot-status snapshot 全操作 + status 閲覧の TUI 追加 - tui/actions_snapshot.py 新設: create/list/restore/copy/delete/rotate の 操作メニューと引数収集 (属性契約は cli.py parser と突き合わせ済み)。 restore/copy/delete の対象は SnapshotManager.list() の既存一覧から選択 (取得失敗時は自由入力へ縮退)。破壊的な restore/delete は menu.confirm で 確認し、拒否時は実行しない (plan 3.4) - tui/actions_status.py 新設: cmd_status(devbase_root) への薄い委譲 (引数なし・閲覧のみ。表示後トップへ rc を返す) - tui/app.py: _route に snapshot/status を配線 (残りは PR3 env / PR4 plugin) - テスト 39 件追加 (582 passed / 1 skipped、ベースライン 544 から退行ゼロ) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7a1e763 commit 8db78e5

6 files changed

Lines changed: 687 additions & 10 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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 → 呼び出し元 (トップ) へ

lib/devbase/tui/actions_status.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""status カテゴリの TUI 操作フロー (PLAN31_2 PR5)。
2+
3+
status は閲覧のみでサブコマンドも引数も持たない (``cmd_status(devbase_root)`` /
4+
plan 2.2)。そのためメニューや引数収集を介さず、表示してそのまま rc を返す
5+
薄い委譲に留める。rc (``int``) を返すことで「操作を実行した → トップへ復帰」
6+
の戻り値プロトコル (actions_project と同じ) に従い、トップループが rc を記憶する。
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from pathlib import Path
12+
13+
from devbase.commands.status import cmd_status
14+
15+
16+
def run(devbase_root: Path) -> int:
17+
"""ステータスを表示し、rc を返してトップメニューへ復帰する。"""
18+
return cmd_status(Path(devbase_root))

lib/devbase/tui/app.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
プロジェクト一覧の選択だけだった旧挙動を、全カテゴリ
55
(project / env / plugin / snapshot / status) を束ねるトップ階層メニューへ拡張する。
66
7-
PR1 で project、PR3 で env、PR4 で plugin カテゴリを配線済み。snapshot/status
8-
後続 PR (PR5) で各 ``actions_*`` を ``_route`` に足すまでプレースホルダ案内を出す
7+
PR1 で project、PR3 で env、PR4 で plugin、PR5 で snapshot/status を配線済みで、
8+
全カテゴリがトップ階層メニューから実行できる
99
1010
後方互換 (plan 3.2):
1111
- ``--no-interactive`` / ``--plain`` (interactive=False) と非 TTY は従来どおり一覧
@@ -26,7 +26,8 @@
2626

2727
from devbase.commands.project import _print_table, list_projects
2828
from devbase.log import get_logger
29-
from devbase.tui import actions_env, actions_plugin, actions_project, menu
29+
from devbase.tui import (actions_env, actions_plugin, actions_project,
30+
actions_snapshot, actions_status, menu)
3031

3132
logger = get_logger(__name__)
3233

@@ -60,7 +61,10 @@ def _route(category: str, devbase_root: Path):
6061
return actions_env.run(devbase_root)
6162
if category == "plugin":
6263
return actions_plugin.run(devbase_root)
63-
# PR5: snapshot/status をここに追加する。
64+
if category == "snapshot":
65+
return actions_snapshot.run(devbase_root)
66+
if category == "status":
67+
return actions_status.run(devbase_root)
6468
logger.info("「%s」は後続 PR で実装予定です。", _LABELS.get(category, category))
6569
return menu.MENU_BACK
6670

0 commit comments

Comments
 (0)