diff --git a/src/codeeditor/file_manager.py b/src/codeeditor/file_manager.py new file mode 100644 index 0000000..698363e --- /dev/null +++ b/src/codeeditor/file_manager.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""File management module for the codeeditor application.""" + +import logging +import os +import platform +import subprocess +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +logger = logging.getLogger(__name__) + + +class FileManager: + """Class for managing file operations in the code editor.""" + + def __init__(self, root_dir: Path): + """Initialize the FileManager with a root directory. + + Args: + root_dir: The root directory to manage files from. + """ + # If root_dir is empty or doesn't exist, use current directory + if not root_dir or not root_dir.exists(): + self.root_dir = Path.cwd() + else: + self.root_dir = root_dir + + self.code_extensions = [ + ".py", + ".js", + ".html", + ".css", + ".json", + ".md", + ".txt", + ".yaml", + ".yml", + ".toml", + ".sh", + ".bat", + ".c", + ".cpp", + ".h", + ".hpp", + ".java", + ".go", + ".rs", + ".ts", + ".tsx", + ".jsx", + ] + + # File extension categories for filtering + self.file_categories = { + "Python": [".py", ".pyw", ".pyc", ".pyd", ".pyo", ".pyi"], + "Web": [".html", ".css", ".js", ".ts", ".jsx", ".tsx", ".vue", ".svelte"], + "Data": [".json", ".csv", ".xml", ".yaml", ".yml", ".toml"], + "Documents": [".md", ".txt", ".pdf", ".docx", ".xlsx", ".pptx"], + "Images": [".jpg", ".jpeg", ".png", ".gif", ".svg", ".ico", ".webp"], + "All Files": ["*"], + } + + logger.info(f"FileManager initialized with root directory: {self.root_dir}") + + def list_files( + self, directory: Optional[Path] = None, filter_category: str = "All Files" + ) -> List[Tuple[str, bool]]: + """List all files in the given directory. + + Args: + directory: The directory to list files from. If None, uses root_dir. + filter_category: Filter files by category. + + Returns: + A list of tuples containing (file_path, is_directory). + """ + if directory is None: + directory = self.root_dir + + # If directory is empty string or doesn't exist, return empty list + if not directory or not directory.exists(): + return [] + + try: + file_list = [] + + # Walk through all directories and files recursively + for root, dirs, files in os.walk(directory): + # Skip hidden directories + dirs[:] = [d for d in dirs if not d.startswith(".")] + + # Process directories + for dir_name in dirs: + dir_path = Path(root) / dir_name + try: + rel_path = dir_path.relative_to(self.root_dir) + file_list.append((str(rel_path), True)) + except ValueError: + # If dir is not relative to root_dir (can happen with symlinks) + continue + + # Process files + for file_name in files: + # Skip hidden files except .env + if file_name.startswith(".") and file_name != ".env": + continue + + file_path = Path(root) / file_name + try: + rel_path = file_path.relative_to(self.root_dir) + + # Apply filter if not "All Files" + if filter_category != "All Files": + if not any( + file_name.endswith(ext) + for ext in self.file_categories.get(filter_category, []) + ): + continue + + file_list.append((str(rel_path), False)) + except ValueError: + # If file is not relative to root_dir (can happen with symlinks) + continue + + # Sort the list: directories first, then files, both alphabetically + file_list.sort(key=lambda x: (not x[1], x[0].lower())) + return file_list + + except Exception as e: + logger.error(f"Error listing files in {directory}: {e}") + return [] + + def get_file_tree( + self, expanded_folders: Set[str], filter_category: str = "All Files" + ) -> Dict: + """Get a hierarchical file tree structure. + + Args: + expanded_folders: Set of folder paths that are expanded in the UI. + filter_category: Filter files by category. + + Returns: + A dictionary representing the file tree structure. + """ + if not self.root_dir or not self.root_dir.exists(): + return {} + + file_tree = {} + + def add_to_tree(path_parts, current_dict, is_dir, full_path): + if not path_parts: + return + + part = path_parts[0] + if len(path_parts) == 1: + # Leaf node + if is_dir: + if part not in current_dict: + current_dict[part] = { + "__type": "directory", + "__path": full_path, + "__children": {}, + } + else: + current_dict[part] = {"__type": "file", "__path": full_path} + else: + # Directory node + if part not in current_dict: + current_dict[part] = { + "__type": "directory", + "__path": full_path, + "__children": {}, + } + add_to_tree( + path_parts[1:], current_dict[part]["__children"], is_dir, full_path + ) + + for file_path, is_dir in self.list_files(filter_category=filter_category): + path_parts = Path(file_path).parts + add_to_tree(path_parts, file_tree, is_dir, file_path) + + return file_tree + + def read_file(self, file_path: str) -> Optional[str]: + """Read the contents of a file. + + Args: + file_path: The path to the file to read, relative to root_dir. + + Returns: + The contents of the file as a string, or None if the file cannot be read. + """ + try: + full_path = self.root_dir / file_path + with open(full_path, "r", encoding="utf-8") as f: + content = f.read() + logger.info(f"Read file: {file_path}") + return content + except Exception as e: + logger.error(f"Error reading file {file_path}: {e}") + return None + + def save_file(self, file_path: str, content: str) -> bool: + """Save content to a file. + + Args: + file_path: The path to the file to save, relative to root_dir. + content: The content to save to the file. + + Returns: + True if the file was saved successfully, False otherwise. + """ + try: + full_path = self.root_dir / file_path + # Create parent directories if they don't exist + full_path.parent.mkdir(parents=True, exist_ok=True) + + with open(full_path, "w", encoding="utf-8") as f: + f.write(content) + logger.info(f"Saved file: {file_path}") + return True + except Exception as e: + logger.error(f"Error saving file {file_path}: {e}") + return False + + def create_directory(self, dir_path: str) -> bool: + """Create a new directory. + + Args: + dir_path: The path to the directory to create, relative to root_dir. + + Returns: + True if the directory was created successfully, False otherwise. + """ + try: + full_path = self.root_dir / dir_path + full_path.mkdir(parents=True, exist_ok=True) + logger.info(f"Created directory: {dir_path}") + return True + except Exception as e: + logger.error(f"Error creating directory {dir_path}: {e}") + return False + + def delete_item(self, item_path: str) -> bool: + """Delete a file or directory. + + Args: + item_path: The path to the item to delete, relative to root_dir. + + Returns: + True if the item was deleted successfully, False otherwise. + """ + try: + full_path = self.root_dir / item_path + if full_path.is_dir(): + # Remove directory and all contents + import shutil + + shutil.rmtree(full_path) + logger.info(f"Deleted directory: {item_path}") + else: + # Remove file + full_path.unlink() + logger.info(f"Deleted file: {item_path}") + return True + except Exception as e: + logger.error(f"Error deleting item {item_path}: {e}") + return False + + def is_code_file(self, file_path: str) -> bool: + """Check if a file is a code file based on its extension. + + Args: + file_path: The path to the file. + + Returns: + True if the file is a code file, False otherwise. + """ + return any(file_path.endswith(ext) for ext in self.code_extensions) + + def get_file_extension(self, file_path: str) -> str: + """Get the extension of a file. + + Args: + file_path: The path to the file. + + Returns: + The file extension. + """ + return os.path.splitext(file_path)[1].lower() + + def get_file_icon(self, file_path: str) -> str: + """Get an icon for a file based on its extension. + + Args: + file_path: The path to the file. + + Returns: + An emoji icon representing the file type. + """ + ext = self.get_file_extension(file_path) + + # Map file extensions to icons + icon_map = { + ".py": "🐍", # Python + ".js": "📜", # JavaScript + ".html": "🌐", # HTML + ".css": "🎨", # CSS + ".json": "📋", # JSON + ".md": "📝", # Markdown + ".txt": "📄", # Text + ".yml": "⚙️", # YAML + ".yaml": "⚙️", # YAML + ".toml": "⚙️", # TOML + ".sh": "🐚", # Shell + ".bat": "🐚", # Batch + ".c": "🔧", # C + ".cpp": "🔧", # C++ + ".h": "🔧", # Header + ".hpp": "🔧", # C++ Header + ".java": "☕", # Java + ".go": "🔵", # Go + ".rs": "🦀", # Rust + ".ts": "📘", # TypeScript + ".jsx": "⚛️", # React JSX + ".tsx": "⚛️", # React TSX + } + + return icon_map.get(ext, "📄") # Default to generic file icon + + def get_file_categories(self) -> Dict[str, List[str]]: + """Get file categories for filtering. + + Returns: + Dictionary of file categories and their extensions. + """ + return self.file_categories diff --git a/src/codeeditor/search_manager.py b/src/codeeditor/search_manager.py new file mode 100644 index 0000000..bfc06f5 --- /dev/null +++ b/src/codeeditor/search_manager.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Search management module for the codeeditor application.""" + +import logging +import os +from typing import Dict, List, Optional + +import requests +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +logger = logging.getLogger(__name__) + + +class SearchManager: + """Class for managing internet searches.""" + + def __init__(self): + """Initialize the SearchManager.""" + self.api_key = os.getenv("SEARCH_API_KEY") + self.search_engine_id = os.getenv("SEARCH_ENGINE_ID") + logger.info("SearchManager initialized") + + def search(self, query: str, num_results: int = 5) -> List[Dict[str, str]]: + """Perform an internet search for the given query. + + Args: + query: The search query. + num_results: The number of results to return. + + Returns: + A list of dictionaries containing search results. + """ + results = [] + + try: + # Use Google Custom Search API if API key is available + if self.api_key and self.search_engine_id: + results = self._google_search(query, num_results) + else: + # Fallback to a simple search simulation + logger.warning( + "Search API keys not found. Using simulated search results." + ) + results = self._simulated_search(query) + + logger.info(f"Search completed for query: {query}") + return results + + except Exception as e: + logger.error(f"Error performing search: {e}") + return [ + { + "title": "Search Error", + "link": "", + "snippet": f"Error performing search: {str(e)}", + } + ] + + def _google_search(self, query: str, num_results: int = 5) -> List[Dict[str, str]]: + """Perform a search using Google Custom Search API. + + Args: + query: The search query. + num_results: The number of results to return. + + Returns: + A list of dictionaries containing search results. + """ + url = "https://www.googleapis.com/customsearch/v1" + params = { + "key": self.api_key, + "cx": self.search_engine_id, + "q": query, + "num": min(num_results, 10), # API limit is 10 + } + + response = requests.get(url, params=params) + data = response.json() + + results = [] + if "items" in data: + for item in data["items"]: + results.append( + { + "title": item.get("title", ""), + "link": item.get("link", ""), + "snippet": item.get("snippet", ""), + } + ) + + return results + + def _simulated_search(self, query: str) -> List[Dict[str, str]]: + """Simulate search results when API keys are not available. + + Args: + query: The search query. + + Returns: + A list of dictionaries containing simulated search results. + """ + # This is a fallback method that returns simulated results + # In a real application, you might want to use a free alternative or web scraping + # with proper rate limiting and respect for robots.txt + + # Always include these informational results + results = [ + { + "title": "Search API Key Required", + "link": "https://developers.google.com/custom-search/v1/overview", + "snippet": "To enable real search functionality, please add SEARCH_API_KEY and SEARCH_ENGINE_ID to your .env file.", + }, + { + "title": f"Search Results for: {query}", + "link": f"https://www.google.com/search?q={query.replace(' ', '+')}", + "snippet": "Click this link to perform the search manually in your browser.", + }, + ] + + # Add some simulated results based on common programming queries + query_lower = query.lower() + + if "python" in query_lower: + results.extend( + [ + { + "title": "Python Documentation", + "link": "https://docs.python.org/3/", + "snippet": "Python 3 documentation. Welcome! This is the official documentation for Python 3. Parts of the documentation: What's new in Python 3? Or all 'What's new' documents since 2.0.", + }, + { + "title": "Python Tutorial - W3Schools", + "link": "https://www.w3schools.com/python/", + "snippet": "Python is a popular programming language. Python can be used on a server to create web applications. Start learning Python now.", + }, + { + "title": "The Python Standard Library", + "link": "https://docs.python.org/3/library/", + "snippet": "The Python Standard Library. While The Python Language Reference describes the exact syntax and semantics of the Python language, this library reference manual describes the standard library that is distributed with Python.", + }, + ] + ) + elif "javascript" in query_lower: + results.extend( + [ + { + "title": "JavaScript - MDN Web Docs", + "link": "https://developer.mozilla.org/en-US/docs/Web/JavaScript", + "snippet": "JavaScript (JS) is a lightweight, interpreted, or just-in-time compiled programming language with first-class functions.", + }, + ] + ) + elif "streamlit" in query_lower: + results.extend( + [ + { + "title": "Streamlit Documentation", + "link": "https://docs.streamlit.io/", + "snippet": "Streamlit is an open-source Python library that makes it easy to create and share beautiful, custom web apps for machine learning and data science.", + }, + ] + ) + + # Add a generic result for any query to ensure we always return something + if len(results) <= 2: + results.append( + { + "title": f"{query} - Programming Resources", + "link": f"https://github.com/search?q={query.replace(' ', '+')}", + "snippet": f"Find open-source projects and resources related to {query} on GitHub.", + } + ) + + return results diff --git a/src/codeeditor/system_prompter.py b/src/codeeditor/system_prompter.py new file mode 100644 index 0000000..725e04f --- /dev/null +++ b/src/codeeditor/system_prompter.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""System prompt generation module for the codeeditor application.""" + +import logging +import os +from pathlib import Path +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class SystemPrompter: + """Class for generating system prompts for the AI assistant.""" + + def __init__(self): + """Initialize the SystemPrompter.""" + self.base_prompt = ( + "You are an AI coding assistant helping with programming tasks. " + "You provide clear, concise, and accurate code suggestions, explanations, " + "and debugging help. Your responses should be helpful, informative, and " + "focused on solving the user's coding problems.\n\n" + "When providing code examples, always format them with markdown code blocks " + "with the appropriate language syntax highlighting. For example:\n" + "```python\n" + "def hello_world():\n" + " print('Hello, World!')\n" + "```\n\n" + "When suggesting changes to existing code, clearly indicate which parts should " + "be modified, added, or removed." + ) + logger.info("SystemPrompter initialized") + + def generate_prompt(self, file_context: Optional[str] = None) -> str: + """Generate a system prompt for the AI assistant. + + Args: + file_context: Optional context from the current file. + + Returns: + A system prompt string. + """ + prompt = self.base_prompt + + if file_context: + # Add file context to the prompt + file_path = file_context.split("\n", 1)[0].replace("File: ", "") + file_content = ( + file_context.split("\n", 1)[1] if "\n" in file_context else "" + ) + + prompt += ( + f"\n\nHere is the current file content that the user is working with:\n" + f"File: {file_path}\n" + ) + prompt += "```\n" + file_content + "\n```\n" + + # Add file type specific context + file_ext = Path(file_path).suffix.lower() + if file_ext: + language_context = self._get_language_context(file_ext) + if language_context: + prompt += f"\n{language_context}\n" + + prompt += "\nPlease consider this context when providing assistance." + + return prompt + + def _get_language_context(self, file_extension: str) -> str: + """Get language-specific context based on file extension. + + Args: + file_extension: The file extension including the dot. + + Returns: + Language-specific context string. + """ + language_contexts = { + ".py": ( + "This is a Python file. When suggesting improvements, consider Python best practices " + "such as PEP 8 style guidelines, type hints, docstrings, and efficient use of " + "Python's standard library and idioms." + ), + ".js": ( + "This is a JavaScript file. Consider modern JavaScript (ES6+) features, " + "asynchronous patterns, and clean code practices when providing suggestions." + ), + ".html": ( + "This is an HTML file. Consider semantic HTML5 elements, accessibility best practices, " + "and proper document structure when providing suggestions." + ), + ".css": ( + "This is a CSS file. Consider responsive design principles, browser compatibility, " + "and modern CSS features when providing suggestions." + ), + ".jsx": ( + "This is a React JSX file. Consider React best practices, component structure, " + "hooks usage, and state management when providing suggestions." + ), + ".tsx": ( + "This is a TypeScript React file. Consider TypeScript type safety, React best practices, " + "component structure, hooks usage, and state management when providing suggestions." + ), + } + + return language_contexts.get(file_extension, "") + + def generate_code_improvement_prompt( + self, code: str, file_path: Optional[str] = None + ) -> str: + """Generate a prompt for code improvement suggestions. + + Args: + code: The code to improve. + file_path: Optional path to the file. + + Returns: + A system prompt string for code improvement. + """ + prompt = self.base_prompt + prompt += "\n\nPlease review the following code and suggest improvements " + prompt += "for readability, efficiency, and best practices:\n" + + if file_path: + prompt += f"File: {file_path}\n" + + # Add language-specific context + file_ext = Path(file_path).suffix.lower() + if file_ext: + language_context = self._get_language_context(file_ext) + if language_context: + prompt += f"\n{language_context}\n" + + prompt += "```\n" + code + "\n```\n" + prompt += "\nPlease provide specific suggestions with examples." + + return prompt + + def generate_debugging_prompt( + self, code: str, error_message: str, file_path: Optional[str] = None + ) -> str: + """Generate a prompt for debugging assistance. + + Args: + code: The code to debug. + error_message: The error message to debug. + file_path: Optional path to the file. + + Returns: + A system prompt string for debugging. + """ + prompt = self.base_prompt + prompt += ( + "\n\nPlease help debug the following code that is producing an error:\n" + ) + + if file_path: + prompt += f"File: {file_path}\n" + + # Add language-specific context + file_ext = Path(file_path).suffix.lower() + if file_ext: + language_context = self._get_language_context(file_ext) + if language_context: + prompt += f"\n{language_context}\n" + + prompt += "```\n" + code + "\n```\n" + prompt += "\nError message:\n" + prompt += "```\n" + error_message + "\n```\n" + prompt += ( + "\nPlease explain what's causing the error and suggest a fix. " + "Include a corrected version of the code." + ) + + return prompt + + def generate_completion_prompt( + self, code: str, cursor_position: int, file_path: Optional[str] = None + ) -> str: + """Generate a prompt for code completion. + + Args: + code: The code to complete. + cursor_position: The position of the cursor in the code. + file_path: Optional path to the file. + + Returns: + A system prompt string for code completion. + """ + prompt = self.base_prompt + prompt += "\n\nPlease help complete the following code at the cursor position (marked by |):\n" + + if file_path: + prompt += f"File: {file_path}\n" + + # Add language-specific context + file_ext = Path(file_path).suffix.lower() + if file_ext: + language_context = self._get_language_context(file_ext) + if language_context: + prompt += f"\n{language_context}\n" + + # Insert cursor marker + code_with_cursor = code[:cursor_position] + "|" + code[cursor_position:] + + prompt += "```\n" + code_with_cursor + "\n```\n" + prompt += ( + "\nPlease suggest code completions that would make sense at the cursor position. " + "Provide a few different options if appropriate." + ) + + return prompt diff --git a/src/codeeditor/ui_components.py b/src/codeeditor/ui_components.py new file mode 100644 index 0000000..3d91ab4 --- /dev/null +++ b/src/codeeditor/ui_components.py @@ -0,0 +1,1842 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""UI components for the codeeditor application.""" + +import logging +import os +import shutil +import tempfile +import tkinter as tk +from pathlib import Path +from tkinter import filedialog +from typing import Any, Dict + +import pygments +import streamlit as st +from pygments.formatters import HtmlFormatter +from pygments.lexers import TextLexer, get_lexer_for_filename + +logger = logging.getLogger(__name__) + + +def setup_ui(): + """Set up the main UI components for the application.""" + # Configure page to use wide layout with sidebars + st.set_page_config( + page_title="AI Code Editor", + page_icon="💠", + layout="wide", + initial_sidebar_state="expanded", + ) + + # Apply custom CSS for better layout + apply_custom_css() + + # Create three columns layout with more compact sidebars + # Make sidebars wider for better content visibility + left_sidebar, main_content, right_sidebar = st.columns([1.5, 4.0, 1.5]) + + # Setup left sidebar for file explorer + with left_sidebar: + file_navigation_sidebar() + + # Setup main content area for code editor + with main_content: + if st.session_state.current_file: + code_editor_panel() + else: + welcome_screen() + + # Setup right sidebar for AI Assistant + with right_sidebar: + ai_assistant_panel() + + +def apply_custom_css(): + """Apply custom CSS to improve the UI.""" + st.markdown( + """ + + """, + unsafe_allow_html=True, + ) + + +def welcome_screen(): + """Display a welcome screen when no file is open.""" + st.markdown( + """ +
+

AI Code Editor

+

Select a file or create a new project

+
+ """, + unsafe_allow_html=True, + ) + + # Quick start options + col1, col2 = st.columns(2) + + with col1: + if st.button("📁 Open Folder", use_container_width=True): + folder_path = select_folder_dialog() + if folder_path: + st.session_state.file_manager.root_dir = Path(folder_path) + st.session_state.workspace_path = folder_path + st.rerun() + + with col2: + if st.button("🆕 New Project", use_container_width=True): + # Create a temporary folder for the new project + temp_dir = tempfile.mkdtemp(prefix="codeeditor_project_") + + # Create some initial files + with open(os.path.join(temp_dir, "main.py"), "w") as f: + f.write('print("Hello, World!")\n') + + with open(os.path.join(temp_dir, "README.md"), "w") as f: + f.write("# My Project\n\nA new coding project.\n") + + # Set the new folder as the current workspace + st.session_state.file_manager.root_dir = Path(temp_dir) + st.session_state.workspace_path = temp_dir + st.rerun() + + # Recent projects section (placeholder) + st.markdown("### Recent Projects") + st.info("Recent projects will be shown here in future versions.") + + +def select_folder_dialog() -> str: + """Open a folder selection dialog. + + Returns: + Selected folder path as string or empty string if canceled. + """ + try: + # Create a temporary tkinter window + root = tk.Tk() + root.withdraw() # Hide the main window + root.attributes("-topmost", True) # Bring dialog to front + + # Open the folder selection dialog + folder_path = filedialog.askdirectory(title="Select Project Folder") + + # Clean up + root.destroy() + + return folder_path + except Exception as e: + logger.error(f"Error opening folder dialog: {e}") + return "" + + +def file_navigation_sidebar(): + """Display the file navigation sidebar in a VS Code style.""" + # Add container with full-width class + st.markdown('
', unsafe_allow_html=True) + + # VS Code-like explorer header with minimal height + st.markdown( + '
', + unsafe_allow_html=True, + ) + st.markdown( + 'EXPLORER', + unsafe_allow_html=True, + ) + st.markdown("
", unsafe_allow_html=True) + + # Show workspace name in VS Code style - more compact + if st.session_state.workspace_path: + current_dir = str(st.session_state.file_manager.root_dir) + workspace_name = os.path.basename(current_dir) + st.markdown( + f'
{workspace_name}
', + unsafe_allow_html=True, + ) + else: + st.info("No folder opened") + # File operations in a compact row - VS Code style + col1, col2 = st.columns(2) + with col1: + if st.button( + "📂", key="browse_button", help="Open Folder", use_container_width=True + ): + folder_path = select_folder_dialog() + if folder_path: + st.session_state.file_manager.root_dir = Path(folder_path) + st.session_state.workspace_path = folder_path + st.session_state.current_file = None + st.session_state.expanded_folders = set() + st.rerun() + with col2: + if st.button( + "🆕", + key="new_project_button", + help="New Project", + use_container_width=True, + ): + # Create a temporary folder for the new project + temp_dir = tempfile.mkdtemp(prefix="codeeditor_project_") + + # Create some initial files + with open(os.path.join(temp_dir, "main.py"), "w") as f: + f.write('print("Hello, World!")\n') + + with open(os.path.join(temp_dir, "README.md"), "w") as f: + f.write("# My Project\n\nA new coding project.\n") + + # Set the new folder as the current workspace + st.session_state.file_manager.root_dir = Path(temp_dir) + st.session_state.workspace_path = temp_dir + st.rerun() + return + + # All action buttons at the top in a single compact row + st.markdown( + '
', + unsafe_allow_html=True, + ) + + # File operations buttons with smaller icons in a single row using columns + col1, col2, col3, col4 = st.columns(4) + + with col1: + if st.button( + "📄", key="new_file_button", help="New File", use_container_width=True + ): + st.session_state.show_new_file_dialog = True + + with col2: + if st.button( + "📁", key="new_folder_button", help="New Folder", use_container_width=True + ): + st.session_state.show_new_folder_dialog = True + + with col3: + if st.button( + "🔄", key="refresh_button", help="Refresh", use_container_width=True + ): + st.rerun() + + with col4: + if st.button( + "📂", key="browse_button", help="Open Folder", use_container_width=True + ): + folder_path = select_folder_dialog() + if folder_path: + st.session_state.file_manager.root_dir = Path(folder_path) + st.session_state.workspace_path = folder_path + st.session_state.current_file = None + st.session_state.expanded_folders = set() + st.rerun() + + st.markdown("
", unsafe_allow_html=True) + + # New file dialog + if st.session_state.get("show_new_file_dialog", False): + with st.form("new_file_form", clear_on_submit=True): + new_file_name = st.text_input("File name") + col1, col2 = st.columns(2) + with col1: + submit = st.form_submit_button("Create") + with col2: + cancel = st.form_submit_button("Cancel") + + if submit and new_file_name: + if st.session_state.file_manager.save_file(new_file_name, ""): + st.session_state.current_file = new_file_name + st.session_state.show_new_file_dialog = False + st.success(f"Created: {new_file_name}") + st.rerun() + + if cancel: + st.session_state.show_new_file_dialog = False + st.rerun() + + # New folder dialog + if st.session_state.get("show_new_folder_dialog", False): + with st.form("new_folder_form", clear_on_submit=True): + new_folder_name = st.text_input("Folder name") + col1, col2 = st.columns(2) + with col1: + submit = st.form_submit_button("Create") + with col2: + cancel = st.form_submit_button("Cancel") + + if submit and new_folder_name: + if st.session_state.file_manager.create_directory(new_folder_name): + st.session_state.show_new_folder_dialog = False + st.success(f"Created: {new_folder_name}") + st.rerun() + else: + st.error("Failed to create folder") + + if cancel: + st.session_state.show_new_folder_dialog = False + st.rerun() + + # Get the file tree without filtering + file_tree = st.session_state.file_manager.get_file_tree( + st.session_state.expanded_folders, "All Files" # Always show all files + ) + + # Render the file tree in VS Code style + with st.container(): + st.markdown('
', unsafe_allow_html=True) + render_file_tree(file_tree) + st.markdown("
", unsafe_allow_html=True) + + # Close the file-navigation-sidebar div + st.markdown("
", unsafe_allow_html=True) + + +def render_file_tree(tree: Dict[str, Any], prefix: str = "", indent_level: int = 0): + """Render a file tree recursively in a VS Code style. + + Args: + tree: Dictionary representing the file tree. + prefix: Path prefix for the current level. + indent_level: Current indentation level. + """ + if not tree: + st.info("No files in this directory.") + return + + # Sort items: directories first, then files, both alphabetically + sorted_items = sorted( + tree.items(), + key=lambda x: (0 if x[1].get("__type") == "directory" else 1, x[0].lower()), + ) + + # First render all directories + for name, item in [i for i in sorted_items if i[1].get("__type") == "directory"]: + item_path = item.get("__path", "") + full_path = f"{prefix}/{name}" if prefix else name + + # Directory item with proper styling + is_expanded = full_path in st.session_state.expanded_folders + expand_icon = "▼" if is_expanded else "▶" + + # Use a container for folder styling + with st.container(): + # Add root-folder-item class for top-level folders + folder_class = "folder-item" + if indent_level == 0: + folder_class += " root-folder-item" + else: + # Add indent level class (up to level 3) + if indent_level <= 3: + folder_class += f" indent-level-{indent_level}" + else: + folder_class += " indent-level-3" # Max indent level + + st.markdown(f'
', unsafe_allow_html=True) + + # Create button with folder name left, arrow absolutely right + folder_prefix = "" + if indent_level > 0: + # Use a simple prefix that won't interfere with button styling + folder_prefix = "└ " + + # Use only plain text for the button label (no HTML span) + folder_button_content = f"📁 {folder_prefix}{name} {expand_icon}" + if st.button( + folder_button_content, + key=f"dir_{full_path}", + use_container_width=True, + help=None, + disabled=False, + args=None, + kwargs=None, + ): + if is_expanded: + st.session_state.expanded_folders.remove(full_path) + else: + st.session_state.expanded_folders.add(full_path) + st.rerun() + st.markdown("
", unsafe_allow_html=True) + + # If expanded, show children with proper indentation + if is_expanded and "__children" in item: + # Create a container with VS Code-like indentation + with st.container(): + # Apply VS Code-like indentation using a class instead of inline style + st.markdown( + f'
', + unsafe_allow_html=True, + ) + render_file_tree(item["__children"], full_path, indent_level + 1) + st.markdown("
", unsafe_allow_html=True) + + # Then render all files + for name, item in [i for i in sorted_items if i[1].get("__type") != "directory"]: + item_path = item.get("__path", "") + full_path = f"{prefix}/{name}" if prefix else name + + # File item with VS Code-like styling + file_icon = st.session_state.file_manager.get_file_icon(name) + + # Check if this is the current file to highlight it + button_class = " selected" if item_path == st.session_state.current_file else "" + + # Add root-file-item class for top-level files + file_class = "file-item" + if indent_level == 0: + file_class += " root-file-item" + else: + file_class += " nested-file" + # Add indent level class (up to level 3) + if indent_level <= 3: + file_class += f" indent-level-{indent_level}" + else: + file_class += " indent-level-3" # Max indent level + + # Add file separator for better visual grouping + if indent_level == 0: + file_class += " file-separator" + + # Use HTML to add a custom class for styling + st.markdown( + f"""
""", + unsafe_allow_html=True, + ) + + # Create button with plain text (no HTML) + # Add visual indent for nested files + file_prefix = "" + if indent_level > 0: + # Use a simple prefix that won't interfere with button styling + file_prefix = "└ " + + if st.button( + f"{file_icon} {file_prefix}{name}", + key=f"file_{full_path}", + use_container_width=True, + ): + st.session_state.current_file = item_path + st.rerun() + + st.markdown(f"""
""", unsafe_allow_html=True) + + +def code_editor_panel(): + """Display the code editor panel.""" + file_path = st.session_state.current_file + + # Read file content + content = st.session_state.file_manager.read_file(file_path) + if content is None: + st.error(f"Failed to read file: {file_path}") + return + + # Create a compact header with file path and action buttons + col1, col2, col3, col4 = st.columns([5, 1, 1, 1]) + with col1: + # Show file path at the top of the editor in a compact way + st.caption(f"{file_path}") + + with col2: + # Add save button in the editor header + if st.button("💾 Save", key="editor_save_button", help="Save file"): + if st.session_state.file_manager.save_file( + file_path, st.session_state.get("code_editor", "") + ): + st.success("Saved!") + else: + st.error("Save failed") + + with col3: + # Add delete button in the editor header + if st.button("🗑️ Delete", key="editor_delete_button", help="Delete file"): + if st.session_state.file_manager.delete_item(file_path): + st.session_state.current_file = None + st.success("Deleted") + st.rerun() + else: + st.error("Delete failed") + + with col4: + # Add run button in the editor header for Python files + if file_path.endswith(".py"): + if st.button("▶️ Run", key="editor_run_button", help="Run code"): + code_content = st.session_state.get("code_editor", "") + with st.spinner("Running..."): + result = st.session_state.execution_engine.run_code(code_content) + + # Store results in session state for display + st.session_state.code_output = result.get("output", "") + st.session_state.code_error = result.get("error", "") + st.session_state.show_code_results = True + st.rerun() # Force rerun to update UI immediately + + # Create a container for the editor with custom styling + with st.container(): + # Display code editor + (col1,) = st.columns(1) + with col1: + edited_content = st.text_area( + label="Code Editor", + value=content, + height=700, # Increased height for better default fit + key="code_editor", + label_visibility="collapsed", + ) + + # Display code execution results if available - immediately after the editor + if st.session_state.get("show_code_results", False): + # Use custom HTML to create a seamless output area + st.markdown( + """ +
+
OUTPUT:
+
+ """, + unsafe_allow_html=True, + ) + + # Display output if available + if st.session_state.code_output: + st.markdown( + f'
{st.session_state.code_output}
', + unsafe_allow_html=True, + ) + + # Display error if available + if st.session_state.code_error: + st.markdown( + '
Error:
', + unsafe_allow_html=True, + ) + st.markdown( + f'
{st.session_state.code_error}
', + unsafe_allow_html=True, + ) + + # Offer to send error to AI for debugging + if st.button("Ask AI for help", key="ask_ai_help_button"): + code_content = st.session_state.get("code_editor", "") + prompt = f"Help me debug this Python code that's giving an error:\n\nCode:\n```python\n{code_content}\n```\n\nError:\n```\n{st.session_state.code_error}\n```" + st.session_state.chat_history.append( + {"role": "user", "content": prompt} + ) + st.rerun() + + # Close the custom HTML containers + st.markdown("
", unsafe_allow_html=True) + + # Update the chat manager with the current file + st.session_state.chat_manager.set_current_file(file_path, edited_content) + + +def ai_assistant_panel(): + """Display the AI assistant panel with prompt at top and chat history below.""" + # Add container with full-width class + st.markdown('
', unsafe_allow_html=True) + + # Title with more prominent styling + st.markdown( + '
AI Assistant
', unsafe_allow_html=True + ) + + # Add some space after the title + st.markdown('
', unsafe_allow_html=True) + + # Synchronize chat history from chat_manager to session_state + st.session_state.chat_history = st.session_state.chat_manager.get_history() + + # Add custom CSS for the layout - more aggressive spacing reduction + st.markdown( + """ + + """, + unsafe_allow_html=True, + ) + + # Initialize session state for search popup + if "show_search_popup" not in st.session_state: + st.session_state.show_search_popup = False + + if "search_results" not in st.session_state: + st.session_state.search_results = [] + + if "search_query" not in st.session_state: + st.session_state.search_query = "" + + if "show_search_results" not in st.session_state: + st.session_state.show_search_results = False + + # Function to copy result to chat input + def copy_to_chat_input(text): + st.session_state.chat_input = text + st.session_state.show_search_results = False + + # Create a single container for the entire AI assistant panel + with st.container(): + # Input field + st.text_input( + "Chat input", + key="chat_input", + placeholder="Ask AI...", + label_visibility="collapsed", + ) + + # Use columns for buttons + button_col1, button_col2, button_col3 = st.columns(3) + + with button_col1: + st.button( + "Send", + key="send_button", + on_click=send_message, + use_container_width=True, + ) + + with button_col2: + if st.button("Clear", key="clear_chat_button", use_container_width=True): + st.session_state.chat_history = [] + st.session_state.chat_manager.clear_history() + + with button_col3: + if st.button("Search", key="search_web_button", use_container_width=True): + if st.session_state.chat_input: + # Get search query + search_query = st.session_state.chat_input + + # Get search results using the SearchManager + search_results = st.session_state.search_manager.search( + search_query + ) + + # Store in session state + st.session_state.search_results = search_results + st.session_state.search_query = search_query + st.session_state.show_search_results = True + + # Log search results + logger.info( + f"Search query: {search_query}, found {len(search_results)} results" + ) + + # Quick action buttons if a file is open + if st.session_state.current_file: + button_col1, button_col2 = st.columns(2) + with button_col1: + if st.button( + "🔍 Explain", key="explain_code_button", use_container_width=True + ): + # Add the command to chat history + st.session_state.chat_history.append( + {"role": "user", "content": "/explain"} + ) + + # Get response + response = st.session_state.chat_manager._handle_special_command( + "/explain" + ) + + # Add response to chat history + st.session_state.chat_history.append( + {"role": "assistant", "content": response} + ) + + with button_col2: + if st.button( + "✨ Improve", key="improve_code_button", use_container_width=True + ): + # Add the command to chat history + st.session_state.chat_history.append( + {"role": "user", "content": "/improve"} + ) + + # Get response + response = st.session_state.chat_manager._handle_special_command( + "/improve" + ) + + # Add response to chat history + st.session_state.chat_history.append( + {"role": "assistant", "content": response} + ) + + # Display chat history in proper conversation pairs + # Each pair consists of exactly one question followed by its answer + + # Display search results if available + if st.session_state.get("show_search_results", False): + # Debug output + logger.info( + f"Displaying search results: {st.session_state.search_query}, {len(st.session_state.search_results)} results" + ) + + with st.expander( + f"Search Results for: '{st.session_state.search_query}'", expanded=True + ): + result_count = len(st.session_state.search_results) + st.write( + f"Found {result_count} {'result' if result_count == 1 else 'results'}" + ) + + if result_count > 0: + for i, result in enumerate(st.session_state.search_results): + st.markdown(f"### {result['title']}") + st.markdown(f"[{result['link']}]({result['link']})") + st.markdown(f"{result['snippet']}") + + col1, col2 = st.columns([1, 1]) + with col1: + if st.button( + f"Copy Title", + key=f"copy_title_{i}", + on_click=copy_to_chat_input, + args=(result["title"],), + ): + pass + with col2: + if st.button( + f"Copy Link", + key=f"copy_link_{i}", + on_click=copy_to_chat_input, + args=(result["link"],), + ): + pass + + st.markdown("---") + else: + st.info("No results found. Try a different search query.") + + if st.button("Close Results", key="close_search_results"): + st.session_state.show_search_results = False + st.experimental_rerun() + + messages = list(st.session_state.chat_history) + + # Track which messages we've seen by content to avoid duplicates + seen_user_contents = set() + conversations = [] + + # Process messages from newest to oldest to prioritize recent conversations + i = len(messages) - 1 + while i >= 0: + # Look for user message (question) + if messages[i]["role"] == "user": + user_content = messages[i]["content"] + + # Skip if we've already seen this content + if user_content in seen_user_contents: + i -= 1 + continue + + # Mark this content as seen + seen_user_contents.add(user_content) + + # Find the most recent AI response to this question + ai_msg = None + for j in range(i + 1, len(messages)): + if messages[j]["role"] == "assistant": + ai_msg = messages[j] + break + + # Add this conversation pair + conversations.append((messages[i], ai_msg)) + + i -= 1 + + # Display conversations in reverse order (newest first) + for user_msg, ai_msg in conversations: + # Display the user message (question) + st.markdown( + f"""
{user_msg["content"]} +
""", + unsafe_allow_html=True, + ) + + # Display the AI response if available + if ai_msg: + st.markdown( + f"""
{ai_msg["content"]} +
""", + unsafe_allow_html=True, + ) + + # Close the ai-assistant-panel div + st.markdown("
", unsafe_allow_html=True) + + +# Function to handle sending messages +def send_message(): + if st.session_state.chat_input: + # Get current file context if a file is open + file_context = None + if st.session_state.current_file: + file_content = st.session_state.file_manager.read_file( + st.session_state.current_file + ) + if file_content: + file_context = ( + f"File: {st.session_state.current_file}\n\n{file_content}" + ) + + # Store the input + message = st.session_state.chat_input + + # Clear the input immediately + st.session_state.chat_input = "" + + # Check if this exact message is already the last user message + is_duplicate = False + if st.session_state.chat_history: + last_messages = [ + msg + for msg in st.session_state.chat_history[-2:] + if msg["role"] == "user" + ] + if last_messages and last_messages[-1]["content"] == message: + is_duplicate = True + + # Only add to history if not a duplicate + if not is_duplicate: + st.session_state.chat_history.append({"role": "user", "content": message}) + + # Send message to AI + response = st.session_state.chat_manager.send_message(message, file_context)