Organized
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Modern TUI for redmine_tickets using prompt_toolkit + rich.
|
||||
|
||||
Goal: btop / mynav / htop-style experience while keeping the same
|
||||
workflow:
|
||||
- Set a template once (status, assignee, category, priority, private note, tags, etc.)
|
||||
- Paint with SPACE/Enter
|
||||
- A = one-confirm batch queue + auto-apply (with cache refresh)
|
||||
- R = live refresh of current ticket
|
||||
- Good columns (contact, tags, updated, priority, related, etc.)
|
||||
- Proper split panes, mouse, resize, theming
|
||||
- Reuses existing domain logic (drafts, apply, cache refresh, select, meta)
|
||||
|
||||
This is the future implementation path. The curses version in redmine_ticket_tui.py
|
||||
remains the current stable tool while we build this out.
|
||||
|
||||
Run (once wired):
|
||||
./bin/rtl --allow-prod-writes tui --modern ...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# --- Rich (rendering) ---
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich.layout import Layout
|
||||
from rich.live import Live
|
||||
from rich.box import ROUNDED
|
||||
except ImportError:
|
||||
print("rich is required for the modern TUI. (it should already be installed)", file=sys.stderr)
|
||||
raise
|
||||
|
||||
# --- prompt_toolkit (input + app) ---
|
||||
try:
|
||||
from prompt_toolkit import Application
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.layout import Layout as PTKLayout, Window, HSplit, VSplit
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
from prompt_toolkit.layout.containers import Window
|
||||
from prompt_toolkit.widgets import TextArea, Frame
|
||||
from prompt_toolkit.application import get_app
|
||||
except ImportError:
|
||||
print("prompt_toolkit is required for the modern TUI.", file=sys.stderr)
|
||||
raise
|
||||
|
||||
# --- Reuse existing domain code where possible ---
|
||||
try:
|
||||
from redmine_tickets import (
|
||||
TicketCache,
|
||||
select_cached_issues_for_cli,
|
||||
refresh_cached_issue,
|
||||
)
|
||||
except Exception:
|
||||
TicketCache = None # type: ignore
|
||||
select_cached_issues_for_cli = None # type: ignore
|
||||
refresh_cached_issue = None # type: ignore
|
||||
|
||||
try:
|
||||
from redmine_ticket_drafts import (
|
||||
add_draft,
|
||||
load_draft,
|
||||
mark_draft_applied,
|
||||
mark_draft_error,
|
||||
assert_write_target_allowed,
|
||||
KIND_FIELDS,
|
||||
KIND_INTERNAL_NOTE,
|
||||
)
|
||||
except Exception:
|
||||
add_draft = None # type: ignore
|
||||
# ... (fallbacks handled at runtime)
|
||||
|
||||
try:
|
||||
from redmine_ticket_meta import load_meta_lists
|
||||
except Exception:
|
||||
load_meta_lists = None # type: ignore
|
||||
|
||||
try:
|
||||
from redmine_ticket_control_tags import add_tag as control_tag_add
|
||||
except Exception:
|
||||
control_tag_add = None # type: ignore
|
||||
|
||||
|
||||
@dataclass
|
||||
class Template:
|
||||
status_id: Optional[int] = None
|
||||
status_name: Optional[str] = None
|
||||
assigned_to_id: Optional[int] = None
|
||||
assigned_to_name: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
category_name: Optional[str] = None
|
||||
priority_id: Optional[int] = None
|
||||
priority_name: Optional[str] = None
|
||||
project_id: Optional[int] = None
|
||||
project_name: Optional[str] = None
|
||||
local_control_tag: Optional[str] = None
|
||||
private_note: Optional[str] = None
|
||||
private: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingChanges:
|
||||
issue_id: int
|
||||
fields: dict[str, Any] = field(default_factory=dict)
|
||||
private_note: Optional[str] = None
|
||||
private: bool = True
|
||||
control_tags: list[str] = field(default_factory=list)
|
||||
contact_tags: list[str] = field(default_factory=list)
|
||||
entity_extract: bool = False
|
||||
nmi_brief: bool = False
|
||||
contact_id: Optional[int] = None
|
||||
|
||||
|
||||
class ModernRedmineTUI:
|
||||
"""Prompt-toolkit + Rich based TUI.
|
||||
|
||||
This is intentionally a work-in-progress skeleton that mirrors the
|
||||
established workflow while giving us proper panes, mouse, live updates,
|
||||
and nice visuals.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
issues: list[dict[str, Any]],
|
||||
*,
|
||||
cache_dir: Path,
|
||||
project: str,
|
||||
base_url: str,
|
||||
allow_prod_writes: bool = False,
|
||||
auto_apply: bool = False,
|
||||
):
|
||||
self.issues = issues
|
||||
self.cache_dir = cache_dir
|
||||
self.project = project
|
||||
self.base_url = base_url
|
||||
self.allow_prod_writes = allow_prod_writes
|
||||
self.auto_apply = auto_apply
|
||||
|
||||
self.template = Template()
|
||||
self.pending: dict[int, PendingChanges] = {}
|
||||
self.filter_text = ""
|
||||
self.filtered_indices: list[int] = list(range(len(issues)))
|
||||
self.current_idx = 0
|
||||
self.status_msg = "Ready. SPACE = paint template. A = commit+apply (1 confirm). R = live refresh."
|
||||
|
||||
self.console = Console()
|
||||
self._meta = {}
|
||||
|
||||
def _current_issue(self) -> Optional[dict[str, Any]]:
|
||||
if not self.filtered_indices:
|
||||
return None
|
||||
idx = min(self.current_idx, len(self.filtered_indices) - 1)
|
||||
real = self.filtered_indices[idx]
|
||||
return self.issues[real]
|
||||
|
||||
def apply_template_to_current(self) -> None:
|
||||
issue = self._current_issue()
|
||||
if not issue:
|
||||
return
|
||||
iid = int(issue["id"])
|
||||
ch = self.pending.get(iid) or PendingChanges(issue_id=iid)
|
||||
|
||||
if self.template.status_id is not None:
|
||||
ch.fields["status_id"] = self.template.status_id
|
||||
if self.template.assigned_to_id is not None:
|
||||
ch.fields["assigned_to_id"] = self.template.assigned_to_id
|
||||
if self.template.category_id is not None:
|
||||
ch.fields["category_id"] = self.template.category_id
|
||||
if self.template.priority_id is not None:
|
||||
ch.fields["priority_id"] = self.template.priority_id
|
||||
if self.template.project_id is not None:
|
||||
ch.fields["project_id"] = self.template.project_id
|
||||
if self.template.local_control_tag:
|
||||
ch.control_tags = [self.template.local_control_tag]
|
||||
if self.template.private_note:
|
||||
ch.private_note = self.template.private_note
|
||||
ch.private = self.template.private
|
||||
|
||||
self.pending[iid] = ch
|
||||
self.status_msg = f"Painted template on #{iid}"
|
||||
|
||||
def _refresh_list_view(self) -> None:
|
||||
# In a real ptk+rich version we would trigger a Live update here.
|
||||
pass
|
||||
|
||||
def run(self) -> int:
|
||||
"""Very early skeleton. Real keyboard + layout comes next."""
|
||||
self.console.print(
|
||||
Panel.fit(
|
||||
"[bold]Modern Redmine TUI (prompt_toolkit + rich)[/bold]\n"
|
||||
"Skeleton active. Full keyboard layout + panes coming in next iterations.\n"
|
||||
"For now use the stable curses TUI (remove --modern when wired).\n\n"
|
||||
"Current tickets in view: " + str(len(self.issues))
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# --- Wiring helpers (will be called from redmine_tickets.py later) ---
|
||||
|
||||
def add_modern_tui_parser(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser(
|
||||
"tui-modern",
|
||||
help="EXPERIMENTAL: prompt_toolkit + rich TUI (btop-style).",
|
||||
)
|
||||
# Reuse the same global flags
|
||||
p.add_argument("--allow-prod-writes", action="store_true", help=argparse.SUPPRESS)
|
||||
p.add_argument("--apply", action="store_true", help=argparse.SUPPRESS)
|
||||
p.add_argument("--where", action="append", default=[], help="Local property filter.")
|
||||
p.add_argument("--max-tickets", type=int, default=100)
|
||||
p.add_argument("--open-only", action="store_true")
|
||||
p.add_argument("--impersonate-user", default="rebot")
|
||||
|
||||
|
||||
def run_modern_tui(
|
||||
issues: list[dict[str, Any]],
|
||||
*,
|
||||
cache_dir: Path,
|
||||
project: str,
|
||||
base_url: str,
|
||||
allow_prod_writes: bool = False,
|
||||
auto_apply: bool = False,
|
||||
) -> int:
|
||||
tui = ModernRedmineTUI(
|
||||
issues,
|
||||
cache_dir=cache_dir,
|
||||
project=project,
|
||||
base_url=base_url,
|
||||
allow_prod_writes=allow_prod_writes,
|
||||
auto_apply=auto_apply,
|
||||
)
|
||||
return tui.run()
|
||||
|
||||
|
||||
def main_cli(args: argparse.Namespace, cache: "TicketCache") -> int: # type: ignore
|
||||
if getattr(args, "cache_dir", None) in (None, ""):
|
||||
args.cache_dir = getattr(cache, "cache_dir", None) or Path(".cache/redmine_tickets")
|
||||
if getattr(args, "project", None) in (None, ""):
|
||||
args.project = getattr(cache, "project", None) or "customer-service"
|
||||
if getattr(args, "base_url", None) in (None, ""):
|
||||
args.base_url = os.environ.get("REDMINE_BASE_URL", "https://rm.ldrprep.com")
|
||||
|
||||
effective_allow = bool(
|
||||
getattr(args, "allow_prod_writes", False)
|
||||
or getattr(args, "apply", False)
|
||||
or os.environ.get("REDMINE_ALLOW_PROD_WRITES", "").lower() in ("1", "true", "yes")
|
||||
)
|
||||
|
||||
matches = select_cached_issues_for_cli(cache, args) if select_cached_issues_for_cli else []
|
||||
if not matches:
|
||||
print("No matching tickets for modern TUI.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
return run_modern_tui(
|
||||
matches,
|
||||
cache_dir=Path(args.cache_dir),
|
||||
project=args.project,
|
||||
base_url=args.base_url,
|
||||
allow_prod_writes=effective_allow,
|
||||
auto_apply=getattr(args, "apply", False),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Run via the main redmine_tickets.py / bin/rtl once the subcommand is registered.")
|
||||
sys.exit(0)
|
||||
Reference in New Issue
Block a user