import gradio as gr import os import subprocess import tempfile import shutil import zipfile import fnmatch from pathlib import Path from pygments.lexers import guess_lexer_for_filename from pygments.util import ClassNotFound import logging import time import math # Try importing pyperclip, provide instructions if missing try: import pyperclip PYPERCLIP_AVAILABLE = True except ImportError: PYPERCLIP_AVAILABLE = False logging.warning("pyperclip library not found. 'Copy to Clipboard' functionality will be disabled. Install with: pip install pyperclip") # --- Configuration --- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') DEFAULT_IGNORE_PATTERNS = [ # Version Control ".git/", ".gitignore", ".gitattributes", ".gitmodules", ".svn/", ".hg/", # IDE/Editor Files ".idea/", ".vscode/", "*.sublime-project", "*.sublime-workspace", ".project", ".classpath", "*.tmproj", ".DS_Store", "Thumbs.db", # Build Outputs & Dependencies "build/", "dist/", "bin/", "obj/", "out/", "target/", "*.o", "*.so", "*.dll", "*.exe", "*.class", "*.jar", "*.war", "*.ear", "node_modules/", "bower_components/", "vendor/", "*.egg-info/", "wheels/", "**/__pycache__/", "*.pyc", # Python Virtual Environments ".venv/", "venv/", "env/", ".env", "pip-cache/", # Logs & Temporary Files "*.log", "*.tmp", "*.temp", "*.swp", "*.swo", "*.bak", # OS Generated Files "._*", # Secrets (important!) "*.pem", "*.key", ".env*", "secrets.*", "credentials.*", # Common Framework/Tool cache/temp files ".pytest_cache/", ".tox/", ".mypy_cache/", ".ruff_cache/", "*.ipynb_checkpoints", # MACOS specific zip artifact "__MACOSX/", ] DEFAULT_MAX_FILE_SIZE_KB = 1024 # 1 MB limit for file content inclusion CLONE_TIMEOUT_SPARSE = 120 # seconds CLONE_TIMEOUT_STANDARD = 300 # seconds ZIP_EXTRACT_WARN_THRESHOLD = 1000 # Warn if ZIP contains more than this many files MAX_FILES_FOR_DETAILED_PROGRESS = 500 # Only show per-file progress if fewer than this many files # --- Core Logic --- def should_ignore(path_obj: Path, ignore_patterns: list[str], repo_root: Path) -> bool: """Checks if a file or directory path should be ignored based on gitignore-style patterns.""" try: relative_path = path_obj.relative_to(repo_root) # Use POSIX paths for consistent pattern matching regardless of OS relative_path_str = relative_path.as_posix() except ValueError: logging.warning(f"Path {path_obj} not relative to root {repo_root}, ignoring.") return True # Optimization: Check direct name match first for common ignores like '.git' if path_obj.name in ignore_patterns: return True for pattern in ignore_patterns: pattern = pattern.strip() if not pattern or pattern.startswith('#'): continue # Ensure pattern uses POSIX separators pattern_posix = pattern.replace(os.sep, '/') # Case 1: Pattern specifies a directory (ends with '/') if pattern_posix.endswith('/'): # Match if the relative path *is* this directory or starts with it # Example: pattern "build/", path "build" or "build/foo.txt" dir_pattern = pattern_posix.rstrip('/') if relative_path_str == dir_pattern or relative_path_str.startswith(dir_pattern + '/'): return True # Also match if a *directory component* matches the name (like ignoring 'node_modules' anywhere) # Example: pattern "node_modules/", path "src/my_lib/node_modules/some_dep" if path_obj.is_dir() and path_obj.name == dir_pattern: return True # Check parent directories as well for parent in relative_path.parents: if parent.name == dir_pattern: return True # Case 2: Pattern is a file or general pattern (using fnmatch) # Use fnmatchcase for potentially case-sensitive filesystems if needed, # but fnmatch is often sufficient and more git-like on Win/Mac. if fnmatch.fnmatch(relative_path_str, pattern_posix): return True # Also match just the filename part for patterns like "*.log" if fnmatch.fnmatch(path_obj.name, pattern_posix): return True return False def is_likely_binary(file_path: Path, chunk_size=1024) -> bool: """Checks if a file is likely binary by reading a chunk.""" try: with file_path.open('rb') as f: chunk = f.read(chunk_size) return b'\0' in chunk except OSError as e: logging.warning(f"Could not read file chunk for binary check {file_path}: {e}") return True def get_file_content(file_path: Path, max_size_bytes: int) -> tuple[str | None, str | None, str | None]: """Reads file content, detects language, handles size limits and encodings.""" try: file_size = file_path.stat().st_size if file_size > max_size_bytes: kb_limit = max_size_bytes / 1024 kb_actual = file_size / 1024 return None, None, f"[Content skipped: File size ({kb_actual:.1f} KB) exceeds limit ({kb_limit:.1f} KB)]" if file_size == 0: return "", "", None # Empty file if is_likely_binary(file_path): return None, None, "[Content skipped: Detected as likely binary file]" content = None detected_encoding = 'utf-8' try: with file_path.open('r', encoding='utf-8') as f: content = f.read() except UnicodeDecodeError: logging.warning(f"UTF-8 decoding failed for {file_path}, trying latin-1.") detected_encoding = 'latin-1' try: with file_path.open('r', encoding='latin-1') as f: content = f.read() except Exception as e_read: logging.error(f"Error reading file {file_path} even with latin-1: {e_read}") return None, None, f"[Content skipped: Error reading file - {e_read}]" except OSError as e_os: logging.error(f"OS Error reading file {file_path}: {e_os}") return None, None, f"[Content skipped: OS Error reading file - {e_os}]" language = "" try: lexer = guess_lexer_for_filename(file_path.name, content) language = lexer.aliases[0] if lexer.aliases else lexer.name except ClassNotFound: language = "" # Plain text except Exception as e_lexer: logging.warning(f"Could not guess lexer for {file_path}: {e_lexer}") language = "" # Fallback return content, language, None except OSError as e_os: logging.error(f"OS Error processing file {file_path}: {e_os}") return None, None, f"[Content skipped: Error accessing file properties - {e_os}]" except Exception as e: logging.error(f"Unexpected error processing file {file_path}: {e}", exc_info=True) return None, None, f"[Content skipped: Unexpected error processing file - {e}]" # --- MODIFIED: Function now uses yield for status updates --- def generate_markdown_for_repo(repo_path_str: str, ignore_patterns: list[str], max_file_size_kb: int, include_content: bool): """ Generates Markdown content for the repository structure and optionally files. Yields status updates during processing. """ repo_root = Path(repo_path_str).resolve() yield f"Status: Analysing repository at {repo_root}..." logging.info(f"Starting markdown generation for: {repo_root}") md_lines = ["# Repository Analysis\n"] structure_lines = [] content_lines = [] max_size_bytes = max_file_size_kb * 1024 files_to_process = [] # --- Pre-computation: Collect all files to potentially process --- yield "Status: Scanning file structure..." all_paths = [] for root, dirs, files in os.walk(repo_path_str, topdown=True): root_path = Path(root).resolve() # --- Filter ignored directories before adding paths --- # We need to check against the original dirs list before modifying it original_dirs = list(dirs) dirs[:] = [d for d in original_dirs if not should_ignore(root_path / d, ignore_patterns, repo_root)] # Add directories that are *not* ignored for d in dirs: # Add the non-ignored directory paths all_paths.append(root_path / d) # Add files that are *not* ignored for f in files: file_path = root_path / f if not should_ignore(file_path, ignore_patterns, repo_root): all_paths.append(file_path) # --- Pass 1: Build the directory structure visualization --- yield "Status: Generating directory structure..." structure_lines.append("## Directory Structure") structure_lines.append("```") structure_tree = [] processed_dirs_for_structure = set() def add_to_structure(path_obj: Path, depth: int): indent = " " * depth # 4 spaces indent prefix = "└── " if path_obj.is_dir(): # Add directory only if it hasn't been added via a parent walk already if path_obj not in processed_dirs_for_structure: structure_tree.append(f"{indent}{prefix}{path_obj.name}/") processed_dirs_for_structure.add(path_obj) # Recursively add children try: for item in sorted(path_obj.iterdir(), key=lambda p: (p.is_file(), p.name.lower())): if not should_ignore(item, ignore_patterns, repo_root): add_to_structure(item, depth + 1) except OSError as e: logging.warning(f"Could not access directory {path_obj}: {e}") structure_tree.append(f"{indent} └── [Error accessing directory: {e}]") elif path_obj.is_file(): structure_tree.append(f"{indent}{prefix}{path_obj.name}") # Start building the structure from the root structure_tree.append(f"{repo_root.name}/") processed_dirs_for_structure.add(repo_root) try: for item in sorted(repo_root.iterdir(), key=lambda p: (p.is_file(), p.name.lower())): if not should_ignore(item, ignore_patterns, repo_root): add_to_structure(item, 1) except OSError as e: logging.error(f"Could not access repository root {repo_root}: {e}") structure_tree.append(f" └── [Error accessing repository root: {e}]") structure_lines.extend(structure_tree) structure_lines.append("```\n") yield "Status: Directory structure generated." logging.info("Directory structure built.") # --- Pass 2: Process file contents (ONLY if requested) --- files_to_render = [p for p in all_paths if p.is_file()] total_files = len(files_to_render) if include_content and total_files > 0: yield f"Status: Processing content of {total_files} file(s)..." content_lines.append("## File Contents\n") start_time = time.time() show_detailed_progress = total_files <= MAX_FILES_FOR_DETAILED_PROGRESS for i, file_path in enumerate(files_to_render): if show_detailed_progress or (i % 50 == 0 and i > 0): # Update every 50 files if many files progress_percent = (i + 1) / total_files yield f"Status: Processing file {i+1}/{total_files}: {file_path.relative_to(repo_root).as_posix()} ({progress_percent:.0%})" try: relative_path_str = file_path.relative_to(repo_root).as_posix() content_lines.append(f"### `{relative_path_str}`\n") # Use POSIX path in Markdown content, language, error_msg = get_file_content(file_path, max_size_bytes) if error_msg: content_lines.append(f"```\n{error_msg}\n```\n") elif content is not None: lang_hint = language if language else "" content_lines.append(f"```{lang_hint}\n{content}\n```\n") else: # Should generally be covered by error_msg cases, but as a fallback content_lines.append("```\n[Content not available or file is binary/empty]\n```\n") except ValueError: logging.warning(f"Path {file_path} not relative to {repo_root}, skipping content.") continue except Exception as e: logging.error(f"Unexpected error processing content for {file_path}: {e}", exc_info=True) relative_path_str = file_path.name # Fallback name try: relative_path_str = file_path.relative_to(repo_root).as_posix() except ValueError: pass content_lines.append(f"### `{relative_path_str}`\n") content_lines.append(f"```\n[ERROR processing file content: {e}]\n```\n") end_time = time.time() yield f"Status: File content processing complete ({total_files} files in {end_time - start_time:.2f}s)." logging.info(f"File content processing complete. Processed {total_files} files in {end_time - start_time:.2f} seconds.") elif not include_content: yield "Status: Skipping file content inclusion as requested." logging.info("Skipping file content inclusion as requested.") else: # include_content is True but total_files is 0 yield "Status: No files found to include content for (after filtering)." logging.info("No files found to include content for (after filtering).") # Combine structure and content md_lines.extend(structure_lines) if include_content and content_lines: # Only add content section if requested and content exists md_lines.extend(content_lines) yield "Status: Markdown generation complete!" yield "".join(md_lines) # Final yield is the complete markdown # --- MODIFIED: Function is now a generator, yielding tuples for multiple outputs --- def repo_to_md_processor(input_type: str, repo_url: str | None, uploaded_zip: tempfile._TemporaryFileWrapper | None, git_branch: str | None, ignore_patterns_str: str, max_file_size_kb: int, include_content: bool): """ Main processing generator function called by Gradio interface. Yields tuples: (status_update, markdown_update, file_update) """ # Initialize updates for components status_update = "Status: Idle." md_update = gr.update() # Use gr.update() to signify no change initially file_update = gr.update(visible=False) # Keep file hidden initially temp_dir_obj = None repo_path = None output_md = "" output_file_path = None error_message = None start_time = time.time() try: status_update = "Status: Initializing..." yield (status_update, md_update, file_update) # Yield initial status user_patterns = {p.strip() for p in ignore_patterns_str.split(',') if p.strip()} default_patterns = set(DEFAULT_IGNORE_PATTERNS) combined_patterns = sorted(list(user_patterns.union(default_patterns))) logging.info(f"Using ignore patterns: {combined_patterns}") logging.info(f"Max file size for content: {max_file_size_kb} KB") logging.info(f"Include file content: {include_content}") if input_type == "URL" and git_branch: logging.info(f"Requested Git branch/tag: {git_branch}") # Reset output areas at the start of processing md_update = "*Processing started...*" file_update = gr.update(value=None, visible=False) yield (status_update, md_update, file_update) with tempfile.TemporaryDirectory(prefix="repo_md_") as temp_dir: logging.info(f"Created temporary directory: {temp_dir}") temp_dir_path = Path(temp_dir) if input_type == "URL": # --- URL Processing --- if not repo_url or not (repo_url.startswith("http://") or repo_url.startswith("https://") or repo_url.startswith("git@")): raise ValueError("Invalid Git URL. Must start with http(s):// or git@") status_update = f"Status: Processing URL: {repo_url}" + (f" (branch/tag: {git_branch})" if git_branch else "") yield (status_update, md_update, file_update) # Update status target_clone_path = temp_dir_path / "repo" target_clone_path.mkdir() repo_path_str = str(target_clone_path) branch_args = ["--branch", git_branch] if git_branch and git_branch.strip() else [] common_args = ["--depth", "1"] try: # Try sparse checkout first status_update = "Status: Attempting efficient Git clone (sparse)..." yield (status_update, md_update, file_update) # ... [rest of sparse clone commands] ... subprocess.run( ["git", "clone"] + common_args + ["--filter=blob:none", "--no-checkout"] + branch_args + [repo_url, repo_path_str], check=True, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=CLONE_TIMEOUT_SPARSE ) subprocess.run(["git", "sparse-checkout", "init", "--cone"], cwd=repo_path_str, check=True, capture_output=True, text=True) subprocess.run(["git", "checkout"], cwd=repo_path_str, check=True, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=CLONE_TIMEOUT_SPARSE) status_update = "Status: Efficient Git clone successful." yield (status_update, md_update, file_update) except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e_sparse: status_update = f"Status: Efficient clone failed ({type(e_sparse).__name__}), attempting standard clone..." yield (status_update, md_update, file_update) logging.warning(f"Sparse clone failed: {e_sparse}. Output: {e_sparse.stderr if hasattr(e_sparse, 'stderr') else 'N/A'}") shutil.rmtree(target_clone_path, ignore_errors=True) target_clone_path.mkdir() try: # Fallback to standard shallow clone status_update = "Status: Attempting standard shallow clone..." yield (status_update, md_update, file_update) # ... [rest of standard clone commands] ... subprocess.run( ["git", "clone"] + common_args + branch_args + [repo_url, repo_path_str], check=True, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=CLONE_TIMEOUT_STANDARD ) status_update = "Status: Standard shallow clone successful." yield (status_update, md_update, file_update) # ... [Error handling for standard clone] ... except FileNotFoundError: logging.error("Git command not found.") raise RuntimeError("Git command not found. Please install Git and ensure it's in your PATH.") except subprocess.CalledProcessError as e_std: error_detail = e_std.stderr or e_std.stdout or "No output captured." logging.error(f"Standard Git clone failed: {error_detail.strip()}") raise RuntimeError(f"Git clone failed:\n{error_detail.strip()}") except subprocess.TimeoutExpired: logging.error(f"Git clone timed out after {CLONE_TIMEOUT_STANDARD} seconds.") raise RuntimeError(f"Git clone timed out after {CLONE_TIMEOUT_STANDARD // 60} minutes.") repo_path = target_clone_path elif input_type == "Upload ZIP": # --- ZIP Processing --- if uploaded_zip is None or not hasattr(uploaded_zip, 'name'): raise ValueError("No ZIP file uploaded or invalid file object.") status_update = f"Status: Processing uploaded ZIP: {Path(uploaded_zip.name).name}" yield (status_update, md_update, file_update) target_extract_path = temp_dir_path / "extracted" target_extract_path.mkdir() try: with zipfile.ZipFile(uploaded_zip.name, 'r') as zip_ref: members = zip_ref.namelist() num_files = len(members) status_update = f"Status: Extracting {num_files} entries from ZIP..." yield (status_update, md_update, file_update) # ... [rest of zip extraction and checks] ... zip_ref.extractall(target_extract_path) status_update = "Status: ZIP extraction complete." yield (status_update, md_update, file_update) # ... [Error handling for zip extraction] ... except zipfile.BadZipFile: logging.error("Invalid or corrupted ZIP file uploaded.") raise ValueError("Invalid or corrupted ZIP file.") except Exception as e_extract: logging.error(f"Failed to extract ZIP file: {e_extract}", exc_info=True) raise RuntimeError(f"Failed to extract ZIP file: {e_extract}") # Determine repo root within extracted files # ... [Logic to find repo_path] ... extracted_items = list(target_extract_path.iterdir()) filtered_items = [item for item in extracted_items if item.name not in (".DS_Store", "__MACOSX")] if len(filtered_items) == 1 and filtered_items[0].is_dir(): repo_path = filtered_items[0] else: repo_path = target_extract_path logging.info(f"Using repo path: {repo_path}") else: # Should not happen with Radio button raise ValueError("Invalid input type selected.") if not repo_path or not repo_path.is_dir(): raise RuntimeError(f"Could not determine valid repository path.") status_update = f"Status: Repository path identified: {repo_path.name}" yield (status_update, md_update, file_update) # --- Generate Markdown (Generator within a Generator) --- # The inner generator yields status updates which we pass through markdown_generator = generate_markdown_for_repo(str(repo_path), combined_patterns, max_file_size_kb, include_content) while True: try: # Get next item from the inner generator status_or_result = next(markdown_generator) # Check if it's a status update or the final markdown if isinstance(status_or_result, str) and status_or_result.startswith("Status:"): status_update = status_or_result # Pass through status update yield (status_update, md_update, file_update) else: # It's the final markdown content output_md = status_or_result md_update = output_md # Prepare to update markdown output break # Exit the inner loop except StopIteration: logging.error("Markdown generator finished unexpectedly without yielding final result.") raise RuntimeError("Markdown generation failed internally.") # --- Save output file --- status_update = "Status: Saving output file..." yield (status_update, md_update, file_update) # Update status with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md", encoding='utf-8', prefix="repo_analysis_") as f: f.write(output_md) output_file_path = f.name logging.info(f"Markdown saved to temporary file: {output_file_path}") # --- Final Success Yield --- status_update = f"Status: Analysis complete! Output ready." file_update = gr.File(value=output_file_path, visible=True) # Make file downloadable yield (status_update, md_update, file_update) except Exception as e: logging.error(f"An error occurred during processing: {e}", exc_info=True) error_message = f"An error occurred: {e}" # --- Final Error Yield --- status_update = f"Status: Error - {error_message}" md_update = f"### Operation Failed\n\nAn error occurred during processing:\n\n```\n{e}\n```" # Update markdown with error file_update = gr.update(value=None, visible=False) # Hide file component yield (status_update, md_update, file_update) finally: # Cleanup is handled by TemporaryDirectory context manager end_time = time.time() logging.info(f"Total processing time: {end_time - start_time:.2f} seconds.") # The generator stops here. The last yield sent the final state. # --- Gradio Interface --- css = """ body { font-family: sans-serif; } #md_output_panel { /* Style the output panel */ max-height: 80vh; } #md_output { max-height: 70vh; /* Adjust max height for content */ overflow: auto; border: 1px solid #ccc; border-radius: 5px; padding: 15px; background-color: #f9f9f9; } #md_output h1 { font-size: 1.6em; border-bottom: 1px solid #eee; padding-bottom: 5px; margin-top: 0;} #md_output h2 { font-size: 1.3em; border-bottom: 1px solid #eee; padding-bottom: 5px; margin-top: 20px; } #md_output h3 { font-size: 1.1em; margin-top: 15px; margin-bottom: 5px; color: #333; } #md_output code { background-color: #eee; padding: 2px 4px; border-radius: 3px; font-size: 0.9em; } #md_output pre { background-color: #fff; padding: 10px; border-radius: 4px; border: 1px solid #ddd; white-space: pre-wrap; word-wrap: break-word; } #md_output pre > code { display: block; padding: 0; background-color: transparent; border: none; font-size: 0.9em;} /* Better code block styling */ #status_box { font-size: 0.9em; color: #555; padding: 8px; border: 1px dashed #ddd; background-color: #fafafa; border-radius: 4px; min-height: 3em; /* Ensure it's visible even when short messages */ margin-top: 10px; } #copy_button { /* Style the copy button */ margin-left: 10px; min-width: 100px; /* Give it a bit more width */ } #download_output { margin-top: 15px; } footer { display: none !important; } .gradio-container { max-width: 1360px !important; margin: auto !important; } """ # --- Helper function for Copy Button --- def copy_to_clipboard(text): if PYPERCLIP_AVAILABLE and text: try: pyperclip.copy(text) logging.info("Copied output to clipboard.") return gr.update(value="Copied!", variant="secondary") # Temporary feedback except Exception as e: logging.error(f"Failed to copy to clipboard: {e}") return gr.update(value="Copy Failed", variant="stop") elif not PYPERCLIP_AVAILABLE: logging.warning("Copy attempt failed: pyperclip not installed.") return gr.update(value="Install Pyperclip", variant="stop") else: # No text to copy return gr.update(value="Nothing to Copy", variant="secondary") def reset_copy_button(): # Short delay before resetting button appearance time.sleep(1.5) return gr.update(value="Copy Markdown", variant="secondary") with gr.Blocks(css=css, title="Repo Analyzer", theme=gr.themes.Soft()) as demo: gr.Markdown("# Repository Analyzer") gr.Markdown( "Enter a public Git repository URL or upload a local project folder (as a `.zip` archive) " "to generate a single Markdown file containing its structure and optionally file contents. " "Provides real-time status updates." ) with gr.Row(): # --- Input Column --- with gr.Column(scale=1): gr.Markdown("### Input Source & Options") input_type = gr.Radio( ["URL", "Upload ZIP"], label="Input Source", value="URL" ) # URL Specific Inputs (conditionally visible) url_input = gr.Textbox( label="Git Repository URL", placeholder="e.g., https://github.com/gradio-app/gradio.git or git@github.com:user/repo.git", visible=True, interactive=True, elem_id="url-input" ) git_branch_input = gr.Textbox( label="Branch / Tag (Optional)", placeholder="e.g., main, develop, v1.2.3 (leave empty for default)", visible=True, interactive=True, elem_id="git-branch-input" ) # ZIP Specific Inputs (conditionally visible) zip_input = gr.File( label="Upload Local Folder (as .zip)", file_types=[".zip"], visible=False, interactive=True, elem_id="zip-input" ) # --- Common Options in Accordion --- with gr.Accordion("Configuration Options", open=False): include_content_checkbox = gr.Checkbox( label="Include File Content in Output", value=True, info="Generate structure only if unchecked." ) max_size_input = gr.Number( label="Max File Size for Content (KB)", value=DEFAULT_MAX_FILE_SIZE_KB, minimum=0, step=64, precision=0, info="Files larger than this won't have content included (if enabled). 0 disables content.", ) ignore_input = gr.Textbox( label="Ignore Patterns (comma-separated, gitignore style)", value=", ".join(DEFAULT_IGNORE_PATTERNS), placeholder="e.g., .git/, *.log, node_modules/", info="Uses gitignore syntax. Add `/` for directories. Defaults provided.", lines=5, max_lines=15 ) submit_btn = gr.Button("Analyze Repository", variant="primary") gr.Markdown("### Status Updates") status_output = gr.Textbox(label="Current Status", value="Idle.", interactive=False, lines=3, elem_id="status_box") # --- Output Column --- with gr.Column(scale=2): gr.Markdown("### Generated Output") with gr.Row(elem_id="output_header_row"): copy_button = gr.Button("Copy Markdown", variant="secondary", elem_id="copy_button", visible=PYPERCLIP_AVAILABLE) # Hide if pyperclip missing download_output = gr.File(label="Download .md File", interactive=False, visible=False, elem_id="download_output", scale=1) # Take less space initially md_output = gr.Markdown(value="*Awaiting analysis results...*", elem_id="md_output", visible=True) # --- Event Handlers --- # Update visibility based on input type choice def update_input_visibility(choice): is_url = choice == "URL" return { url_input: gr.update(visible=is_url), git_branch_input: gr.update(visible=is_url), zip_input: gr.update(visible=not is_url) } input_type.change( fn=update_input_visibility, inputs=input_type, outputs=[url_input, git_branch_input, zip_input], queue=False # UI only change ) # Main processing logic on submit submit_btn.click( fn=repo_to_md_processor, # The generator function inputs=[ input_type, url_input, zip_input, git_branch_input, ignore_input, max_size_input, include_content_checkbox, ], # Outputs map to yielded values: status strings, final markdown, final file path outputs=[ status_output, md_output, download_output ], api_name="repo_to_md" ) # Copy button functionality if PYPERCLIP_AVAILABLE: copy_button.click( fn=copy_to_clipboard, inputs=[md_output], # Takes the current markdown content outputs=[copy_button], # Updates its own text/appearance queue=False ).then( fn=reset_copy_button, # Function to reset button after a delay inputs=None, outputs=[copy_button], queue=False # Don't queue the reset visual change ) # Launch the interface if __name__ == "__main__": demo.queue().launch(server_name="0.0.0.0", show_error=True, debug=True) # Enable queue & debug for better testing