display.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. from __future__ import annotations
  2. import logging
  3. from pathlib import Path
  4. from typing import TYPE_CHECKING
  5. from rich.console import Console
  6. from rich.table import Table
  7. from rich.tree import Tree
  8. if TYPE_CHECKING:
  9. from .template import Template
  10. logger = logging.getLogger(__name__)
  11. console = Console()
  12. class DisplayManager:
  13. """Handles all rich rendering for the CLI."""
  14. def display_templates_table(
  15. self, templates: list, module_name: str, title: str
  16. ) -> None:
  17. """Display a table of templates.
  18. Args:
  19. templates: List of Template objects
  20. module_name: Name of the module
  21. title: Title for the table
  22. """
  23. if not templates:
  24. logger.info(f"No templates found for module '{module_name}'")
  25. return
  26. logger.info(f"Listing {len(templates)} templates for module '{module_name}'")
  27. table = Table(title=title)
  28. table.add_column("ID", style="bold", no_wrap=True)
  29. table.add_column("Name")
  30. table.add_column("Tags")
  31. table.add_column("Version", no_wrap=True)
  32. table.add_column("Library", no_wrap=True)
  33. for template in templates:
  34. name = template.metadata.name or "Unnamed Template"
  35. tags_list = template.metadata.tags or []
  36. tags = ", ".join(tags_list) if tags_list else "-"
  37. version = str(template.metadata.version) if template.metadata.version else ""
  38. library = template.metadata.library or ""
  39. table.add_row(template.id, name, tags, version, library)
  40. console.print(table)
  41. def display_template_details(self, template: Template, template_id: str) -> None:
  42. """Display template information panel and variables table."""
  43. self._display_template_header(template, template_id)
  44. self._display_file_tree(template)
  45. self._display_variables_table(template)
  46. def display_section_header(self, title: str, description: str | None) -> None:
  47. """Display a section header."""
  48. if description:
  49. console.print(f"\n[bold cyan]{title}[/bold cyan] [dim]- {description}[/dim]")
  50. else:
  51. console.print(f"\n[bold cyan]{title}[/bold cyan]")
  52. console.print("─" * 40, style="dim")
  53. def display_validation_error(self, message: str) -> None:
  54. """Display a validation error message."""
  55. console.print(f"[red]{message}[/red]")
  56. def _display_template_header(self, template: Template, template_id: str) -> None:
  57. """Display the header for a template."""
  58. template_name = template.metadata.name or "Unnamed Template"
  59. version = str(template.metadata.version) if template.metadata.version else "Not specified"
  60. description = template.metadata.description or "No description available"
  61. console.print(
  62. f"[bold blue]{template_name} ({template_id} - [cyan]{version}[/cyan])[/bold blue]"
  63. )
  64. console.print(description)
  65. def _display_file_tree(self, template: Template) -> None:
  66. """Display the file structure of a template."""
  67. # Preserve the heading, then use the template id as the root directory label
  68. console.print()
  69. console.print("[bold blue]Template File Structure:[/bold blue]")
  70. # Use the template id as the root directory label (folder glyph + white name)
  71. file_tree = Tree(f"\uf07b [white]{template.id}[/white]")
  72. tree_nodes = {Path("."): file_tree}
  73. for template_file in sorted(
  74. template.template_files, key=lambda f: f.relative_path
  75. ):
  76. parts = template_file.relative_path.parts
  77. current_path = Path(".")
  78. current_node = file_tree
  79. for part in parts[:-1]:
  80. current_path = current_path / part
  81. if current_path not in tree_nodes:
  82. new_node = current_node.add(f"\uf07b [white]{part}[/white]")
  83. tree_nodes[current_path] = new_node
  84. current_node = new_node
  85. else:
  86. current_node = tree_nodes[current_path]
  87. # Determine display name (use output_path to detect final filename)
  88. display_name = template_file.output_path.name if hasattr(template_file, 'output_path') else template_file.relative_path.name
  89. # Default icons: use Nerd Font private-use-area codepoints (PUA).
  90. # Docker (Font Awesome) is typically U+F308. Default file U+F15B.
  91. docker_icon = "\uf308"
  92. default_file_icon = "\uf15b"
  93. j2_icon = "\ue235"
  94. if template_file.file_type == "j2":
  95. # Detect common docker compose filenames from the resulting output path
  96. lower_name = display_name.lower()
  97. compose_names = {"docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"}
  98. if lower_name in compose_names or lower_name.startswith("docker-compose") or "compose" in lower_name:
  99. icon = docker_icon
  100. else:
  101. icon = j2_icon
  102. current_node.add(f"[white]{icon} {display_name}[/white]")
  103. elif template_file.file_type == "static":
  104. current_node.add(f"[white]{default_file_icon} {display_name}[/white]")
  105. if file_tree.children:
  106. console.print(file_tree)
  107. def _display_variables_table(self, template: Template) -> None:
  108. """Display a table of variables for a template."""
  109. if not (template.variables and template.variables.has_sections()):
  110. return
  111. console.print()
  112. console.print("[bold blue]Template Variables:[/bold blue]")
  113. variables_table = Table(show_header=True, header_style="bold blue")
  114. variables_table.add_column("Variable", style="cyan", no_wrap=True)
  115. variables_table.add_column("Type", style="magenta")
  116. variables_table.add_column("Default", style="green")
  117. variables_table.add_column("Description", style="white")
  118. variables_table.add_column("Origin", style="yellow")
  119. first_section = True
  120. for section in template.variables.get_sections().values():
  121. if not section.variables:
  122. continue
  123. if not first_section:
  124. variables_table.add_row("", "", "", "", "", style="dim")
  125. first_section = False
  126. # Check if section is enabled AND dependencies are satisfied
  127. is_enabled = section.is_enabled()
  128. dependencies_satisfied = template.variables.is_section_satisfied(section.key)
  129. is_dimmed = not (is_enabled and dependencies_satisfied)
  130. # Only show (disabled) if section has no dependencies (dependencies make it obvious)
  131. disabled_text = " (disabled)" if (is_dimmed and not section.needs) else ""
  132. required_text = " [yellow](required)[/yellow]" if section.required else ""
  133. # Add dependency information
  134. needs_text = ""
  135. if section.needs:
  136. needs_list = ", ".join(section.needs)
  137. needs_text = f" [dim](needs: {needs_list})[/dim]"
  138. header_text = f"[bold dim]{section.title}{required_text}{needs_text}{disabled_text}[/bold dim]" if is_dimmed else f"[bold]{section.title}{required_text}{needs_text}{disabled_text}[/bold]"
  139. variables_table.add_row(header_text, "", "", "", "")
  140. for var_name, variable in section.variables.items():
  141. row_style = "dim" if is_dimmed else None
  142. # Use variable's native get_display_value() method
  143. default_val = variable.get_display_value(mask_sensitive=True, max_length=30)
  144. # Add lock icon for sensitive variables
  145. sensitive_icon = " \uf084" if variable.sensitive else ""
  146. var_display = f" {var_name}{sensitive_icon}"
  147. variables_table.add_row(
  148. var_display,
  149. variable.type or "str",
  150. default_val,
  151. variable.description or "",
  152. variable.origin or "unknown",
  153. style=row_style,
  154. )
  155. console.print(variables_table)
  156. def display_file_generation_confirmation(
  157. self,
  158. output_dir: Path,
  159. files: dict[str, str],
  160. existing_files: list[Path] | None = None
  161. ) -> None:
  162. """Display files to be generated with confirmation prompt.
  163. Args:
  164. output_dir: The output directory path
  165. files: Dictionary of file paths to content
  166. existing_files: List of existing files that will be overwritten (if any)
  167. """
  168. console.print()
  169. console.print("[bold]Files to be generated:[/bold]")
  170. # Create a tree view of files
  171. file_tree = Tree(f"\uf07b [cyan]{output_dir.resolve()}[/cyan]")
  172. tree_nodes = {Path("."): file_tree}
  173. # Sort files for better display
  174. sorted_files = sorted(files.keys())
  175. for file_path_str in sorted_files:
  176. file_path = Path(file_path_str)
  177. parts = file_path.parts
  178. current_path = Path(".")
  179. current_node = file_tree
  180. # Build directory structure
  181. for part in parts[:-1]:
  182. current_path = current_path / part
  183. if current_path not in tree_nodes:
  184. new_node = current_node.add(f"\uf07b [white]{part}[/white]")
  185. tree_nodes[current_path] = new_node
  186. current_node = tree_nodes[current_path]
  187. # Add file with indicator if it will be overwritten
  188. file_name = parts[-1]
  189. full_path = output_dir / file_path
  190. if existing_files and full_path in existing_files:
  191. current_node.add(f"\uf15c [yellow]{file_name}[/yellow] [red](will overwrite)[/red]")
  192. else:
  193. current_node.add(f"\uf15c [green]{file_name}[/green]")
  194. console.print(file_tree)
  195. console.print()
  196. def display_config_tree(self, spec: dict, module_name: str, show_all: bool = False) -> None:
  197. """Display configuration spec as a tree view.
  198. Args:
  199. spec: The configuration spec dictionary
  200. module_name: Name of the module
  201. show_all: If True, show all details including descriptions
  202. """
  203. if not spec:
  204. console.print(f"[yellow]No configuration found for module '{module_name}'[/yellow]")
  205. return
  206. # Create root tree node
  207. tree = Tree(f"[bold blue]\ue5fc {str.capitalize(module_name)} Configuration[/bold blue]")
  208. for section_name, section_data in spec.items():
  209. if not isinstance(section_data, dict):
  210. continue
  211. # Determine if this is a section with variables
  212. # Guard against None from empty YAML sections
  213. section_vars = section_data.get("vars") or {}
  214. section_desc = section_data.get("description", "")
  215. section_required = section_data.get("required", False)
  216. section_toggle = section_data.get("toggle", None)
  217. section_needs = section_data.get("needs", None)
  218. # Build section label
  219. section_label = f"[cyan]{section_name}[/cyan]"
  220. if section_required:
  221. section_label += " [yellow](required)[/yellow]"
  222. if section_toggle:
  223. section_label += f" [dim](toggle: {section_toggle})[/dim]"
  224. if section_needs:
  225. needs_str = ", ".join(section_needs) if isinstance(section_needs, list) else section_needs
  226. section_label += f" [dim](needs: {needs_str})[/dim]"
  227. if show_all and section_desc:
  228. section_label += f"\n [dim]{section_desc}[/dim]"
  229. section_node = tree.add(section_label)
  230. # Add variables
  231. if section_vars:
  232. for var_name, var_data in section_vars.items():
  233. if isinstance(var_data, dict):
  234. var_type = var_data.get("type", "string")
  235. var_default = var_data.get("default", "")
  236. var_desc = var_data.get("description", "")
  237. var_sensitive = var_data.get("sensitive", False)
  238. # Build variable label
  239. var_label = f"[green]{var_name}[/green] [dim]({var_type})[/dim]"
  240. if var_default is not None and var_default != "":
  241. display_val = "********" if var_sensitive else str(var_default)
  242. if not var_sensitive and len(display_val) > 30:
  243. display_val = display_val[:27] + "..."
  244. var_label += f" = [yellow]{display_val}[/yellow]"
  245. if show_all and var_desc:
  246. var_label += f"\n [dim]{var_desc}[/dim]"
  247. section_node.add(var_label)
  248. else:
  249. # Simple key-value pair
  250. section_node.add(f"[green]{var_name}[/green] = [yellow]{var_data}[/yellow]")
  251. console.print(tree)
  252. def display_next_steps(self, next_steps: str, variable_values: dict) -> None:
  253. """Display next steps after template generation, rendering them as a Jinja2 template.
  254. Args:
  255. next_steps: The next_steps string from template metadata (may contain Jinja2 syntax)
  256. variable_values: Dictionary of variable values to use for rendering
  257. """
  258. if not next_steps:
  259. return
  260. console.print("\n[bold cyan]Next Steps:[/bold cyan]")
  261. try:
  262. from jinja2 import Template as Jinja2Template
  263. next_steps_template = Jinja2Template(next_steps)
  264. rendered_next_steps = next_steps_template.render(variable_values)
  265. console.print(rendered_next_steps)
  266. except Exception as e:
  267. logger.warning(f"Failed to render next_steps as template: {e}")
  268. # Fallback to plain text if rendering fails
  269. console.print(next_steps)