Skip to content
Merged
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
65 changes: 11 additions & 54 deletions lib/devbase/tui/actions_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,40 +11,21 @@
収集ヘルパで CLI と同じ属性値を集め、破壊的な down は ``menu.confirm`` で確認する
(plan 2.3 契約表 / 3.4 破壊的操作確認)。

一覧表示・整形 (``list_projects`` / ``_build_menu_entries``) は ``commands/project``
の純粋ロジックを再利用する (TUI からも CLI(table) からも共有)
プロジェクト一覧の表示・選択は ``tui.app`` (トップ画面) が担い、本モジュールは
選択された 1 行の処理 (``handle_row``) と questionary 不在時のフォールバックを提供する
"""

from __future__ import annotations

from pathlib import Path

from devbase.commands.project import (
_STATUS_COLOR,
_build_menu_entries,
list_projects,
)
from devbase.log import get_logger
from devbase.tui import menu
from devbase.tui.dispatch import dispatch_lifecycle

logger = get_logger(__name__)


def _select_project(rows: list[dict]):
"""一覧から 1 件選ばせ rows の index を返す。Esc → ``MENU_BACK`` / Ctrl-C → ``None``。

件数が多いため文字入力での絞り込み (search=True) を有効にする。search 有効時は
← が入力カーソル移動と衝突するため戻る操作は Esc のみ (menu.select が調整する)。
"""
entries = _build_menu_entries(rows, colorize=_STATUS_COLOR)
choices = [(entry, i) for i, entry in enumerate(entries)]
return menu.select(
"操作するプロジェクトを選択 "
"(↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc 戻る / Ctrl-C 中止):",
choices, back=True, search=True)


# running 行で選べる操作 (表示順 = ハイライト既定順)。up を先頭に置き、PR1 同様
# Enter 連打で再起動へ到達できるようにする。各 value は cmd_project のサブコマンド名。
_RUNNING_OPS: list[tuple[str, str]] = [
Expand Down Expand Up @@ -232,47 +213,23 @@ def _operation_menu(devbase_root: Path, name: str):
return rc # 実行 rc → 呼び出し元へ


def run(devbase_root: Path):
"""プロジェクト操作カテゴリ。一覧選択 → (running は操作サブメニュー / 他は up)。
def handle_row(devbase_root: Path, row: dict):
"""一覧で選択された 1 プロジェクト行を処理する (トップ画面から呼ばれる)。

戻り値プロトコル (トップループが ``is`` 同一性で判定する):
- **操作を実行した場合**: ``dispatch_lifecycle`` の rc (``int``) を返す。
実行したのでトップへ戻る、rc は呼び出し側が記憶」の意味。これにより
実行したので一覧へ戻る、rc は呼び出し側が記憶」の意味。これにより
project 操作の失敗が ``devbase list`` の終了コードへ伝搬する。
- ``menu.MENU_BACK``: 一覧で Esc/← (操作なしでトップへ) / プロジェクト無し
- ``None``: 一覧・サブメニューで Ctrl-C による全体中止。
- ``menu.MENU_BACK``: 操作サブメニューで Esc/← (操作なしで一覧へ)
- ``None``: サブメニューで Ctrl-C による全体中止。

選択行が running 中なら ``_operation_menu`` で全操作を選ばせ、それ以外
(stopped / unknown) は従来どおり直接 ``project up`` を起動する (PR1 非回帰)。
操作完了後はトップメニューへ復帰する (plan 3.5 状態遷移: Exec → Top)。
"""
projects_dir = Path(devbase_root) / "projects"
while True:
rows = list_projects(projects_dir)
if not rows:
logger.info("プロジェクトがありません (%s)。", projects_dir)
return menu.MENU_BACK

idx = _select_project(rows)
if idx is menu.MENU_BACK:
return menu.MENU_BACK
if idx is None:
return None # Ctrl-C → 全体中止

row = rows[idx]
name = row["name"]
if str(row.get("status", "")).startswith("running"):
rc = _operation_menu(devbase_root, name)
if rc is menu.MENU_BACK:
continue # 一覧へ戻る
if rc is None:
return None # Ctrl-C → 全体中止
else:
rc = dispatch_lifecycle("up", name, scale=None)

# 操作完了 → トップメニューへ復帰。rc は呼び出し側 (top loop) が記憶し
# 最終的な devbase の終了コードへ伝搬させる。
return rc
name = row["name"]
if str(row.get("status", "")).startswith("running"):
return _operation_menu(devbase_root, name)
return dispatch_lifecycle("up", name, scale=None)


def fallback_select_and_up(rows: list[dict]) -> int:
Expand Down
89 changes: 56 additions & 33 deletions lib/devbase/tui/app.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,43 @@
"""トップ階層メニューとカテゴリ routing (`devbase list` の入口)
"""`devbase list` の入口: プロジェクト一覧を最上位画面とする TUI

``run(devbase_root, args)`` が ``cmd_project_list`` から呼ばれる新しい入口。
プロジェクト一覧の選択だけだった旧挙動を、全カテゴリ
(project / env / plugin / snapshot / status) を束ねるトップ階層メニューへ拡張する。

PR1 で project、PR3 で env、PR4 で plugin、PR5 で snapshot/status を配線済みで、
全カテゴリがトップ階層メニューから実行できる。
``run(devbase_root, args)`` が ``cmd_project_list`` から呼ばれる入口。
利用頻度が最も高い **プロジェクト一覧を起動直後のトップ画面** とし、
プロジェクト選択 → (running なら操作サブメニュー / それ以外は up) を最短経路にする。
env / plugin / snapshot / status は一覧の末尾に並ぶカテゴリ項目から遷移する。

後方互換 (plan 3.2):
- ``--no-interactive`` / ``--plain`` (interactive=False) と非 TTY は従来どおり一覧
テーブルのみ。
- questionary 不在時はトップメニューを出さず、従来の番号入力フォールバック
- questionary 不在時は一覧メニューを出さず、従来の番号入力フォールバック
(project up) へ縮退して muscle-memory を保全する。
- トップメニューでは「プロジェクト操作」を先頭に置き既定ハイライトとすることで、
Enter 連打で従来の project 選択フローへ到達できるようにする
- 一覧は先頭プロジェクトを既定ハイライトとし、Enter 連打で従来の
「最初のプロジェクトを up」へ最短到達できる

ナビ規約: トップメニューは Esc / Ctrl-C で中止 (戻り先なし)。各カテゴリ内では
Esc / ← でトップメニューへ戻る (``menu.MENU_BACK``)、Ctrl-C で全体中止 (``None``)。
ナビ規約: トップ (プロジェクト一覧) は Esc / Ctrl-C で中止 (戻り先なし)。
各カテゴリ・サブメニュー内では Esc / ← で 1 つ前へ戻る (``menu.MENU_BACK``)、
Ctrl-C で全体中止 (``None``)。
"""

from __future__ import annotations

import sys
from pathlib import Path

from devbase.commands.project import _print_table, list_projects
from devbase.commands.project import (
_STATUS_COLOR,
_build_menu_entries,
_print_table,
list_projects,
)
from devbase.log import get_logger
from devbase.tui import (actions_env, actions_plugin, actions_project,
actions_snapshot, actions_status, menu)

logger = get_logger(__name__)

# トップメニューのカテゴリ (表示順 = ハイライト既定順)。先頭の「プロジェクト操作」を
# 既定ハイライトにして従来フローへ Enter 連打で到達できるようにする (plan 3.2)。
# プロジェクト一覧の末尾に並べるカテゴリ (表示順)。``(key, label)`` で保持し、
# 一覧メニューには ``label (key)`` 形式で表示する (key 入力での絞り込みも効く)。
TOP_CATEGORIES: list[tuple[str, str]] = [
("project", "プロジェクト操作"),
("env", "環境変数"),
("plugin", "プラグイン"),
("snapshot", "スナップショット"),
Expand All @@ -49,14 +52,9 @@ def _route(category: str, devbase_root: Path):

戻り値は各カテゴリの戻り値プロトコルに従う:
- 操作実行時はその rc (``int``)
- 操作なしでトップへ戻るときは ``menu.MENU_BACK``
- 操作なしで一覧へ戻るときは ``menu.MENU_BACK``
- Ctrl-C 全体中止のときは ``None``

後続 PR は対応する ``actions_*`` の呼び出しをここに 1 行追加する
(各カテゴリ別ファイルのため衝突しにくい)。未実装カテゴリは ``MENU_BACK``。
"""
if category == "project":
return actions_project.run(devbase_root)
if category == "env":
return actions_env.run(devbase_root)
if category == "plugin":
Expand All @@ -65,12 +63,28 @@ def _route(category: str, devbase_root: Path):
return actions_snapshot.run(devbase_root)
if category == "status":
return actions_status.run(devbase_root)
logger.info("「%s」は後続 PR で実装予定です。", _LABELS.get(category, category))
logger.error("未知のカテゴリです: %s", _LABELS.get(category, category))
return menu.MENU_BACK


def _select_top(rows: list[dict]):
"""トップ画面: プロジェクト一覧 + カテゴリ項目から 1 件選ばせる。

戻り値: rows の index (``int`` = プロジェクト選択) / カテゴリ key (``str``) /
``None`` (Esc・Ctrl-C → 終了)。プロジェクトとカテゴリは値の型で判別する。
件数が多いため文字入力での絞り込み (search=True) を有効にする。
"""
entries = _build_menu_entries(rows, colorize=_STATUS_COLOR)
choices: list[tuple[str, object]] = [(entry, i) for i, entry in enumerate(entries)]
choices += [(f"{label} ({key})", key) for key, label in TOP_CATEGORIES]
return menu.select(
"プロジェクトまたは操作を選択 "
"(↑↓ 移動 / 名前で絞り込み / Enter 決定 / Esc・Ctrl-C 終了):",
choices, back=False, search=True)


def _top_menu_loop(devbase_root: Path) -> int:
"""トップ階層メニューのループ
"""トップ画面 (プロジェクト一覧) のループ

最後に実行した操作の rc (``last_rc``) を記憶し、中止時はそれを返すことで
``project up/down/rebuild`` の失敗が ``devbase list`` の終了コードへ伝搬する。
Expand All @@ -79,24 +93,33 @@ def _top_menu_loop(devbase_root: Path) -> int:
判定は必ず ``is`` 同一性で行う (rc=0 を ``None`` / ``MENU_BACK`` と誤マッチさせない)。
"""
last_rc = 0
projects_dir = Path(devbase_root) / "projects"
while True:
choice = menu.select(
"操作カテゴリを選択 (↑↓ 移動 / Enter 決定 / Esc・Ctrl-C 中止):",
list(TOP_CATEGORIES), back=False, search=False)
if choice is None:
rows = list_projects(projects_dir)
if not rows:
# プロジェクト未作成でもカテゴリ操作 (env/plugin/...) は使えるため
# 終了せず案内だけ出して一覧 (カテゴリのみ) を表示する。
logger.info("プロジェクトがありません (%s)。", projects_dir)

sel = _select_top(rows)
if sel is None:
# トップで Esc / Ctrl-C → これまでの実行 rc を返して終了
logger.info("中止しました。")
return last_rc

result = _route(choice, devbase_root)
if isinstance(sel, str):
result = _route(sel, devbase_root)
else:
result = actions_project.handle_row(devbase_root, rows[sel])

if result is None:
# カテゴリ内で Ctrl-C → 全体中止 (直近の実行 rc を返す)
# カテゴリ・サブメニュー内で Ctrl-C → 全体中止 (直近の実行 rc を返す)
logger.info("中止しました。")
return last_rc
if result is menu.MENU_BACK:
# 操作なしでトップへ戻り再表示 (rc は更新しない)
# 操作なしで一覧へ戻り再表示 (rc は更新しない)
continue
# int rc: 操作を実行した → rc を記憶してトップ再表示
# int rc: 操作を実行した → rc を記憶して一覧を再表示
last_rc = result


Expand All @@ -105,7 +128,7 @@ def run(devbase_root: Path, args) -> int:

- interactive=False / 非 TTY: 一覧テーブルのみ (従来挙動)。
- questionary 不在: 番号入力フォールバック (project up) へ縮退。
- それ以外: トップ階層メニューを開く
- それ以外: プロジェクト一覧トップの階層メニューを開く
"""
projects_dir = Path(devbase_root) / "projects"

Expand Down
32 changes: 26 additions & 6 deletions lib/devbase/tui/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,27 @@
# キーバインド (Esc / ←)
# ---------------------------------------------------------------------------

def _add_key_binding(question, key, handler):
"""生成済み ``Question.application`` にキーハンドラを後付けする共通処理。

select の application は素の ``KeyBindings`` を持つが、confirm/text/path は
``merge_key_bindings`` 済みの ``_MergedKeyBindings`` (``add`` を持たない) の
ため、直接 ``add`` せず新しい ``KeyBindings`` を作って再マージする。
"""
from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings

kb = KeyBindings()
kb.add(key)(handler)
existing = question.application.key_bindings
question.application.key_bindings = (
merge_key_bindings([existing, kb]) if existing is not None else kb)
return question


def _add_escape_binding(question, handler):
"""questionary の select に Esc 単独押下のハンドラを後付けする共通処理。
"""questionary の question に Esc 単独押下のハンドラを後付けする共通処理。

questionary 2.x の select は Ctrl-C / Ctrl-Q しか割り当てないため、生成済み
questionary 2.x は Ctrl-C / Ctrl-Q しか割り当てないため、生成済み
``Question.application`` の key_bindings に Escape ハンドラを足す。

Escape は矢印キー等のエスケープシーケンス (``\\x1b[A`` 等) の先頭バイトでも
Expand All @@ -59,8 +76,7 @@ def _add_escape_binding(question, handler):
"""
from prompt_toolkit.keys import Keys

question.application.key_bindings.add(Keys.Escape)(handler)
return question
return _add_key_binding(question, Keys.Escape, handler)


def with_escape_cancel(question):
Expand Down Expand Up @@ -102,11 +118,15 @@ def with_escape_back(question, *, bind_left: bool = True):
from prompt_toolkit.keys import Keys

def _back(event):
# 戻る操作で残る「質問行 (未回答のまま collapse した行)」は次のメニュー描画と
# 重なり 1 行ずれの原因になるため、exit 前に erase_when_done を立てて
# プロンプト描画ごと消去する (Enter での通常回答行は従来どおり残る)。
event.app.erase_when_done = True
event.app.exit(result=MENU_BACK)

_add_escape_binding(question, _back) # Esc(互換・低速)
_add_escape_binding(question, _back) # Esc(互換・低速)
if bind_left:
question.application.key_bindings.add(Keys.Left)(_back) # ←(即時)
_add_key_binding(question, Keys.Left, _back) # ←(即時)
return question


Expand Down
Loading
Loading