xcad 4 ماه پیش
والد
کامیت
c480ad3c3c
4فایلهای تغییر یافته به همراه268 افزوده شده و 190 حذف شده
  1. 2 0
      AGENTS.md
  2. 164 0
      cli/core/display.py
  3. 98 185
      cli/core/module.py
  4. 4 5
      cli/core/prompt.py

+ 2 - 0
AGENTS.md

@@ -58,6 +58,8 @@ The CLI application is built with a modular and extensible architecture.
 
 - **`cli/core/prompt.py`**: The `PromptHandler` provides the interactive CLI experience. It uses the `rich` library to prompt the user for variable values, organized by the sections defined in the `VariableCollection`.
 
+- **`cli/core/display.py`**: The `DisplayManager` handles all output rendering using `rich`. It provides consistent and visually appealing displays for lists, search results, variable summaries, and error messages.
+
 ### Template Format
 
 Templates are directory-based. Each template is a directory containing all the necessary files and subdirectories for the boilerplate.

+ 164 - 0
cli/core/display.py

@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from rich.console import Console
+from rich.table import Table
+from rich.tree import Tree
+
+if TYPE_CHECKING:
+    from .template import Template
+
+logger = logging.getLogger(__name__)
+console = Console()
+
+
+class DisplayManager:
+    """Handles all rich rendering for the CLI."""
+
+    def display_templates_table(
+        self, templates: list[dict], module_name: str, title: str
+    ) -> None:
+        """Display a table of templates."""
+        if not templates:
+            logger.info(f"No templates found for module '{module_name}'")
+            return
+
+        logger.info(f"Listing {len(templates)} templates for module '{module_name}'")
+        table = Table(title=title)
+        table.add_column("ID", style="bold", no_wrap=True)
+        table.add_column("Name")
+        table.add_column("Description")
+        table.add_column("Version", no_wrap=True)
+        table.add_column("Library", no_wrap=True)
+
+        for template_info in templates:
+            template = template_info["template"]
+            indent = template_info["indent"]
+            name = template.metadata.name or "Unnamed Template"
+            desc = template.metadata.description or "No description available"
+            version = template.metadata.version or ""
+            library = template.metadata.library or ""
+
+            template_id = f"{indent}{template.id}"
+            table.add_row(template_id, name, desc, version, library)
+
+        console.print(table)
+
+    def display_template_details(self, template: Template, template_id: str) -> None:
+        """Display template information panel and variables table."""
+        self._display_template_header(template, template_id)
+        self._display_file_tree(template)
+        self._display_variables_table(template)
+
+    def display_section_header(self, title: str, description: str | None) -> None:
+        """Display a section header."""
+        console.print(f"\n[bold cyan]{title}[/bold cyan]")
+        if description:
+            console.print(f"[dim]{description}[/dim]")
+        console.print("─" * 40, style="dim")
+
+    def display_validation_error(self, message: str) -> None:
+        """Display a validation error message."""
+        console.print(f"[red]{message}[/red]")
+
+    def _display_template_header(self, template: Template, template_id: str) -> None:
+        """Display the header for a template."""
+        template_name = template.metadata.name or "Unnamed Template"
+        version = template.metadata.version or "Not specified"
+        description = template.metadata.description or "No description available"
+
+        console.print(
+            f"[bold blue]{template_name} ({template_id} - [cyan]{version}[/cyan])[/bold blue]"
+        )
+        console.print(description)
+
+    def _display_file_tree(self, template: Template) -> None:
+        """Display the file structure of a template."""
+        file_tree = Tree("[bold blue]Template File Structure:[/bold blue]")
+        tree_nodes = {Path("."): file_tree}
+
+        for template_file in sorted(
+            template.template_files, key=lambda f: f.relative_path
+        ):
+            parts = template_file.relative_path.parts
+            current_path = Path(".")
+            current_node = file_tree
+
+            for part in parts[:-1]:
+                current_path = current_path / part
+                if current_path not in tree_nodes:
+                    new_node = current_node.add(f"uf07b [bold blue]{part}[/bold blue]")
+                    tree_nodes[current_path] = new_node
+                    current_node = new_node
+                else:
+                    current_node = tree_nodes[current_path]
+
+            if template_file.file_type == "j2":
+                current_node.add(
+                    f"[green]ue235 {template_file.relative_path.name}[/green]"
+                )
+            elif template_file.file_type == "static":
+                current_node.add(
+                    f"[yellow]uf15b {template_file.relative_path.name}[/yellow]"
+                )
+
+        if file_tree.children:
+            console.print()
+            console.print(file_tree)
+
+    def _display_variables_table(self, template: Template) -> None:
+        """Display a table of variables for a template."""
+        if not (template.variables and template.variables.has_sections()):
+            return
+
+        console.print()
+        console.print("[bold blue]Template Variables:[/bold blue]")
+
+        variables_table = Table(show_header=True, header_style="bold blue")
+        variables_table.add_column("Variable", style="cyan", no_wrap=True)
+        variables_table.add_column("Type", style="magenta")
+        variables_table.add_column("Default", style="green")
+        variables_table.add_column("Description", style="white")
+        variables_table.add_column("Origin", style="yellow")
+
+        first_section = True
+        for section in template.variables.get_sections().values():
+            if not section.variables:
+                continue
+
+            if not first_section:
+                variables_table.add_row("", "", "", "", "", style="dim")
+            first_section = False
+
+            is_dimmed = False
+            if section.toggle:
+                toggle_var = section.variables.get(section.toggle)
+                if toggle_var and not toggle_var.get_typed_value():
+                    is_dimmed = True
+
+            disabled_text = " (disabled)" if is_dimmed else ""
+            required_text = " [yellow](required)[/yellow]" if section.required else ""
+            header_text = f"[bold dim]{section.title}{required_text}{disabled_text}[/bold dim]" if is_dimmed else f"[bold]{section.title}{required_text}{disabled_text}[/bold]"
+            variables_table.add_row(header_text, "", "", "", "")
+
+            for var_name, variable in section.variables.items():
+                row_style = "dim" if is_dimmed else None
+                default_val = str(variable.value) if variable.value is not None else ""
+                if variable.sensitive:
+                    default_val = "********"
+                elif len(default_val) > 30:
+                    default_val = default_val[:27] + "..."
+
+                variables_table.add_row(
+                    f"  {var_name}",
+                    variable.type or "str",
+                    default_val,
+                    variable.description or "",
+                    variable.origin or "unknown",
+                    style=row_style,
+                )
+
+        console.print(variables_table)

+ 98 - 185
cli/core/module.py

@@ -8,10 +8,9 @@ from typing import Any, Optional
 from rich.console import Console
 from rich.panel import Panel
 from rich.prompt import Confirm
-from rich.table import Table
-from rich.tree import Tree
 from typer import Argument, Context, Option, Typer
 
+from .display import DisplayManager
 from .library import LibraryManager
 from .prompt import PromptHandler
 from .template import Template
@@ -72,6 +71,7 @@ class Module(ABC):
     logger.info(f"Initializing module '{self.name}'")
     logger.debug(f"Module '{self.name}' configuration: description='{self.description}'")
     self.libraries = LibraryManager()
+    self.display = DisplayManager()
 
   # --------------------------
   # SECTION: Public Commands
@@ -79,11 +79,10 @@ class Module(ABC):
 
   def list(
     self, 
-    filter_name: Optional[str] = Argument(None, help="Filter templates by name (e.g., 'traefik' shows traefik.*)"),
     all_templates: bool = Option(False, "--all", "-a", help="Show all templates including sub-templates")
   ) -> list[Template]:
-    """List templates with optional filtering."""
-    logger.debug(f"Listing templates for module '{self.name}' with filter='{filter_name}', all={all_templates}")
+    """List all templates."""
+    logger.debug(f"Listing templates for module '{self.name}' with all={all_templates}")
     templates = []
 
     entries = self.libraries.find(self.name, sort_results=True)
@@ -96,39 +95,60 @@ class Module(ABC):
         continue
     
     # Apply filtering logic
-    filtered_templates = self._filter_templates(templates, filter_name, all_templates)
+    filtered_templates = self._filter_templates(templates, None, all_templates)
     
     if filtered_templates:
       # Group templates for hierarchical display
       grouped_templates = self._group_templates(filtered_templates)
       
-      logger.info(f"Listing {len(filtered_templates)} templates for module '{self.name}'")
-      table = Table(title=f"{self.name.capitalize()} templates")
-      table.add_column("ID", style="bold", no_wrap=True)
-      table.add_column("Name")
-      table.add_column("Description")
-      table.add_column("Version", no_wrap=True)
-      table.add_column("Library", no_wrap=True)
-
-      for template_info in grouped_templates:
-        template = template_info['template']
-        indent = template_info['indent']
-        name = template.metadata.name or 'Unnamed Template'
-        desc = template.metadata.description or 'No description available'
-        version = template.metadata.version or ''
-        library = template.metadata.library or ''
-
-        # Add indentation for sub-templates
-        template_id = f"{indent}{template.id}"
-        table.add_row(template_id, name, desc, version, library)
-
-      console.print(table)
+      self.display.display_templates_table(
+        grouped_templates,
+        self.name,
+        f"{self.name.capitalize()} templates"
+      )
     else:
-      filter_msg = f" matching '{filter_name}'" if filter_name else ""
-      logger.info(f"No templates found for module '{self.name}'{filter_msg}")
+      logger.info(f"No templates found for module '{self.name}'")
 
     return filtered_templates
 
+  def search(
+    self,
+    query: str = Argument(..., help="Search string to filter templates by ID"),
+    all_templates: bool = Option(False, "--all", "-a", help="Show all templates including sub-templates")
+  ) -> list[Template]:
+    """Search for templates by ID containing the search string."""
+    logger.debug(f"Searching templates for module '{self.name}' with query='{query}', all={all_templates}")
+    templates = []
+
+    entries = self.libraries.find(self.name, sort_results=True)
+    for template_dir, library_name in entries:
+      try:
+        template = Template(template_dir, library_name=library_name)
+        templates.append(template)
+      except Exception as exc:
+        logger.error(f"Failed to load template from {template_dir}: {exc}")
+        continue
+    
+    # Apply search filtering
+    filtered_templates = self._search_templates(templates, query, all_templates)
+    
+    if filtered_templates:
+      # Group templates for hierarchical display
+      grouped_templates = self._group_templates(filtered_templates)
+      
+      logger.info(f"Found {len(filtered_templates)} templates matching '{query}' for module '{self.name}'")
+      self.display.display_templates_table(
+        grouped_templates,
+        self.name,
+        f"{self.name.capitalize()} templates matching '{query}'"
+      )
+    else:
+      logger.info(f"No templates found matching '{query}' for module '{self.name}'")
+      console.print(f"[yellow]No templates found matching '{query}' for module '{self.name}'[/yellow]")
+
+    return filtered_templates
+
+
   def show(
     self,
     id: str,
@@ -240,6 +260,7 @@ class Module(ABC):
     module_app = Typer(help=cls.description)
     
     module_app.command("list")(module_instance.list)
+    module_app.command("search")(module_instance.search)
     module_app.command("show")(module_instance.show)
     
     module_app.command(
@@ -264,20 +285,28 @@ class Module(ABC):
       template_id = template.id
       is_sub_template = '.' in template_id
       
-      # If we have a filter, apply it
-      if filter_name:
-        if is_sub_template:
-          # For sub-templates, check if they start with filter_name.
-          if template_id.startswith(f"{filter_name}."):
-            filtered.append(template)
-        else:
-          # For main templates, exact match
-          if template_id == filter_name:
-            filtered.append(template)
-      else:
-        # No filter - include based on all_templates flag
-        if not all_templates and is_sub_template:
-          continue
+      # No filter - include based on all_templates flag
+      if not all_templates and is_sub_template:
+        continue
+      filtered.append(template)
+    
+    return filtered
+  
+  def _search_templates(self, templates: list[Template], query: str, all_templates: bool) -> list[Template]:
+    """Search templates by ID containing the query string."""
+    filtered = []
+    query_lower = query.lower()
+    
+    for template in templates:
+      template_id = template.id
+      is_sub_template = '.' in template_id
+      
+      # Skip sub-templates if not showing all
+      if not all_templates and is_sub_template:
+        continue
+      
+      # Check if query is contained in the template ID
+      if query_lower in template_id.lower():
         filtered.append(template)
     
     return filtered
@@ -303,33 +332,38 @@ class Module(ABC):
     # Sort sub-templates by parent
     sub_templates.sort(key=lambda t: t.id)
     
-    # Insert sub-templates after their parents
+    # Group sub-templates by parent for proper indentation
+    sub_by_parent = {}
     for sub_template in sub_templates:
       parent_name = sub_template.id.split('.')[0]
-      
-      # Find where to insert this sub-template
+      if parent_name not in sub_by_parent:
+        sub_by_parent[parent_name] = []
+      sub_by_parent[parent_name].append(sub_template)
+    
+    # Insert sub-templates after their parents with proper indentation
+    for parent_name, parent_subs in sub_by_parent.items():
+      # Find the parent in the grouped list
       insert_index = -1
       for i, item in enumerate(grouped):
         if item['template'].id == parent_name:
-          # Find the last sub-template for this parent
-          j = i + 1
-          while j < len(grouped) and not grouped[j]['is_main']:
-            j += 1
-          insert_index = j
+          insert_index = i + 1
           break
       
-      sub_name = sub_template.id.split('.', 1)[1]  # Get part after first dot
-      sub_template_info = {
-        'template': sub_template,
-        'indent': '├─ ' if insert_index < len(grouped) - 1 else '└─ ',
-        'is_main': False
-      }
-      
-      if insert_index >= 0:
-        grouped.insert(insert_index, sub_template_info)
-      else:
-        # Parent not found, add at end
-        grouped.append(sub_template_info)
+      # Add each sub-template with proper indentation
+      for idx, sub_template in enumerate(parent_subs):
+        is_last = (idx == len(parent_subs) - 1)
+        sub_template_info = {
+          'template': sub_template,
+          'indent': '└─ ' if is_last else '├─ ',
+          'is_main': False
+        }
+        
+        if insert_index >= 0:
+          grouped.insert(insert_index, sub_template_info)
+          insert_index += 1
+        else:
+          # Parent not found, add at end
+          grouped.append(sub_template_info)
     
     return grouped
 
@@ -357,127 +391,6 @@ class Module(ABC):
 
   def _display_template_details(self, template: Template, template_id: str) -> None:
     """Display template information panel and variables table."""
-    
-    # Build metadata info text
-    info_lines = []
-    info_lines.append(f"{template.metadata.description or 'No description available'}")
-    info_lines.append("")  # Empty line
-    
-    # Print template information with simple heading
-    template_name = template.metadata.name or 'Unnamed Template'
-    console.print(f"[bold blue]{template_name} ({template_id} - [cyan]{template.metadata.version or 'Not specified'}[/cyan])[/bold blue]")
-    for line in info_lines:
-      console.print(line)
-    
-    # Build the file structure tree
-    file_tree = Tree("[bold blue]Template File Structure:[/bold blue]")
-    
-    # Create a dictionary to hold the tree nodes for directories
-    # This will allow us to build a proper tree structure
-    tree_nodes = {Path('.'): file_tree} # Root of the template directory
-
-    for template_file in sorted(template.template_files, key=lambda f: f.relative_path):
-        parts = template_file.relative_path.parts
-        current_path = Path('.')
-        current_node = file_tree
-
-        # Build the directory path in the tree
-        for part in parts[:-1]: # Iterate through directories
-            current_path = current_path / part
-            if current_path not in tree_nodes:
-                new_node = current_node.add(f"\uf07b [bold blue]{part}[/bold blue]") # Folder icon
-                tree_nodes[current_path] = new_node
-                current_node = new_node
-            else:
-                current_node = tree_nodes[current_path]
-
-        # Add the file to the appropriate directory node
-        if template_file.file_type == 'j2':
-            current_node.add(f"[green]\ue235 {template_file.relative_path.name}[/green]") # Jinja2 file icon
-        elif template_file.file_type == 'static':
-            current_node.add(f"[yellow]\uf15b {template_file.relative_path.name}[/yellow]") # Generic file icon
-            
-    # Print the file tree separately if it has content
-    if file_tree.children: # Check if any files were added to the branches
-        console.print() # Add spacing
-        console.print(file_tree) # Print the Tree object directly
-
-    if template.variables and template.variables.has_sections():
-      console.print()  # Add spacing
-      
-      # Print variables heading
-      console.print(f"[bold blue]Template Variables:[/bold blue]")
-      
-      # Create variables table
-      variables_table = Table(show_header=True, header_style="bold blue")
-      variables_table.add_column("Variable", style="cyan", no_wrap=True)
-      variables_table.add_column("Type", style="magenta")
-      variables_table.add_column("Default", style="green")
-      variables_table.add_column("Description", style="white")
-      variables_table.add_column("Origin", style="yellow")
-      
-      # Add variables grouped by section
-      first_section = True
-      for section_key, section in template.variables.get_sections().items():
-        if section.variables:
-          # Add spacing between sections (except before first section)
-          if not first_section:
-            variables_table.add_row("", "", "", "", "", style="dim")
-          first_section = False
-          
-          # Check if section should be dimmed (toggle is False)
-          is_dimmed = False
-          
-          if section.toggle:
-            toggle_var = section.variables.get(section.toggle)
-            if toggle_var:
-              # Get the actual typed value and check if it's falsy
-              try:
-                toggle_value = toggle_var.get_typed_value()
-                if not toggle_value:
-                  is_dimmed = True
-              except Exception as e:
-                # Fallback to raw value check
-                if not toggle_var.value:
-                  is_dimmed = True
-              
-          # Add section header row with proper styling
-          disabled_text = " (disabled)" if is_dimmed else ""
-          required_text = " [yellow](required)[/yellow]" if section.required else ""
-          
-          if is_dimmed:
-            # Use Rich markup for dimmed bold text
-            header_text = f"[bold dim]{section.title}{required_text}{disabled_text}[/bold dim]"
-          else:
-            # Use Rich markup for bold text
-            header_text = f"[bold]{section.title}{required_text}{disabled_text}[/bold]"
-          
-          variables_table.add_row(
-            header_text,
-            "", "", "", ""
-          )
-          
-          # Add variables in this section
-          for var_name, variable in section.variables.items():
-            # Apply dim style to ALL variables if section toggle is False
-            row_style = "dim" if is_dimmed else None
-            
-            # Format default value
-            default_val = str(variable.value) if variable.value is not None else ""
-            if variable.sensitive:
-              default_val = "********"
-            elif len(default_val) > 30:
-              default_val = default_val[:27] + "..."
-            
-            variables_table.add_row(
-              f"  {var_name}",
-              variable.type or "str",
-              default_val,
-              variable.description or "",
-              variable.origin or "unknown",
-              style=row_style
-            )
-      
-      console.print(variables_table)
+    self.display.display_template_details(template, template_id)
 
 # !SECTION

+ 4 - 5
cli/core/prompt.py

@@ -6,6 +6,7 @@ from rich.console import Console
 from rich.prompt import Prompt, Confirm, IntPrompt
 from rich.table import Table
 
+from .display import DisplayManager
 from .variables import Variable, VariableCollection
 
 logger = logging.getLogger(__name__)
@@ -20,6 +21,7 @@ class PromptHandler:
 
   def __init__(self) -> None:
     self.console = Console()
+    self.display = DisplayManager()
 
   # --------------------------
   # SECTION: Public Methods
@@ -46,10 +48,7 @@ class PromptHandler:
         continue
 
       # Always show section header first
-      self.console.print(f"\n[bold cyan]{section.title}[/bold cyan]")
-      if section.description:
-        self.console.print(f"[dim]{section.description}[/dim]")
-      self.console.print("─" * 40, style="dim")
+      self.display.display_section_header(section.title, section.description)
 
       # Handle section toggle - skip for required sections
       if section.required:
@@ -131,7 +130,7 @@ class PromptHandler:
 
   def _show_validation_error(self, message: str) -> None:
     """Display validation feedback consistently."""
-    self.console.print(f"[red]{message}[/red]")
+    self.display.display_validation_error(message)
 
   def _prompt_string(self, prompt_text: str, default: Any = None, is_sensitive: bool = False) -> str:
     value = Prompt.ask(