diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fce597 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +repomix-output.txt +__pycache__/ +codeselect.llm +codemcp.toml diff --git a/README.md b/README.md index a604953..a066df8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-![CodeSelect Logo](https://img.shields.io/badge/CodeSelect-1.0.0-blue) +![CodeSelect Logo](https://img.shields.io/badge/CodeSelect-1.1.0-blue) **Easily select and share code with AI assistants** @@ -20,6 +20,7 @@ curl -sSL https://raw.githubusercontent.com/maynetee/codeselect/main/install.sh ## ✨ Features - **Visual File Selection**: Interactive UI to easily select files with checkboxes +- **Vim-style Navigation & Search**: Use `/` to search files and j/k/h/l for navigation - **Intelligent Code Analysis**: Automatically detects imports and relationships between files - **Multi-language Support**: Works with Python, C/C++, JavaScript, Java, Go, Ruby, PHP, Rust, Swift and more - **Zero Dependencies**: Works with standard Python libraries only @@ -45,14 +46,17 @@ codeselect --help ## 🖥️ Interface Controls -- **↑/↓**: Navigate between files +- **↑/↓** or **j/k**: Navigate between files - **Space**: Toggle selection of file/directory -- **←/→**: Collapse/expand directories +- **←/→** or **h/l**: Collapse/expand directories +- **/**: Enter search mode (supports regex patterns) +- **^**: Toggle case sensitivity in search mode +- **ESC**: Exit search mode or clear search results - **A**: Select all files - **N**: Deselect all files - **C**: Toggle clipboard copy - **D** or **Enter**: Complete selection and export -- **X** or **Esc**: Exit without saving +- **X**: Exit without saving ## 📄 Output Formats @@ -75,7 +79,7 @@ codeselect --format llm ``` usage: codeselect [-h] [-o OUTPUT] [--format {txt,md,llm}] [--skip-selection] [--no-clipboard] [--version] [directory] -CodeSelect v1.0.0 - Select files to share with AI assistants +CodeSelect v1.1.0 - Select files to share with AI assistants positional arguments: directory Directory to scan (default: current directory) @@ -112,3 +116,4 @@ To remove CodeSelect from your system: ```bash # One-line uninstallation curl -sSL https://raw.githubusercontent.com/maynetee/codeselect/main/uninstall.sh | bash +``` \ No newline at end of file diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..bb1f90d --- /dev/null +++ b/cli.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +CodeSelect - CLI module + +명령행 인터페이스(CLI) 관련 기능을 담당하는 모듈입니다. +사용자의 명령행 인수를 처리하고 적절한 함수를 호출합니다. +""" + +import os +import sys +import argparse + +# 다른 모듈 임포트 +import utils +import filetree +import selector +import output +import dependency + +__version__ = "1.0.0" + +def parse_arguments(): + """ + Parses command-line arguments. + + Returns: + argparse.Namespace: the parsed command line arguments. + """ + parser = argparse.ArgumentParser( + description=f"CodeSelect v{__version__} - Select files to share with AI assistants" + ) + parser.add_argument( + "directory", + nargs="?", + default=".", + help="Directory to scan (default: current directory)" + ) + parser.add_argument( + "-o", "--output", + help="Output file path (default: based on directory name)" + ) + parser.add_argument( + "--format", + choices=["txt", "md", "llm"], + default="llm", + help="Output format (default: llm - optimized for LLMs)" + ) + parser.add_argument( + "--skip-selection", + action="store_true", + help="Skip the selection interface and include all files" + ) + parser.add_argument( + "--no-clipboard", + action="store_true", + help="Disable automatic copy to clipboard" + ) + parser.add_argument( + "--version", + action="store_true", + help="Show version information" + ) + + return parser.parse_args() + +def main(): + """ + Main Function - The entry point for the CodeSelect program. + + Returns: + int: the programme exit code (0: normal exit, 1: error). + """ + args = parse_arguments() + + # 버전 정보 표시 + if args.version: + print(f"CodeSelect v{__version__}") + return 0 + + # 디렉토리 경로 확인 + root_path = os.path.abspath(args.directory) + if not os.path.isdir(root_path): + print(f"Error: {root_path} is not a valid directory") + return 1 + + # 출력 파일 이름 생성 + if not args.output: + args.output = utils.generate_output_filename(root_path, args.format) + + # 디렉토리 스캔 + print(f"Scanning directory: {root_path}") + root_node = filetree.build_file_tree(root_path) + + # 파일 선택 처리 + proceed = True + if not args.skip_selection: + # 대화형 선택 인터페이스 실행 + try: + proceed = selector.interactive_selection(root_node) + if not proceed: + print("Selection cancelled. Exiting without saving.") + return 0 + except Exception as e: + print(f"Error in selection interface: {e}") + return 1 + + # 선택된 파일 수 확인 + selected_count = filetree.count_selected_files(root_node) + print(f"\nSelected files: {selected_count}") + + # 선택된 파일이 없으면 종료 + if selected_count == 0: + print("No files selected. Exiting.") + return 0 + + # 선택된 파일 내용 수집 + file_contents = filetree.collect_selected_content(root_node, root_path) + print(f"Collected content from {len(file_contents)} files.") + + # 의존성 분석 (LLM 형식인 경우) + if args.format == 'llm': + print("Analyzing file relationships...") + all_files = filetree.collect_all_content(root_node, root_path) + dependencies = dependency.analyze_dependencies(root_path, all_files) + + # 의존성 정보와 함께 출력 작성 + output_path = output.write_output_file( + args.output, root_path, root_node, file_contents, args.format, dependencies + ) + else: + # 의존성 정보 없이 출력 작성 + output_path = output.write_output_file( + args.output, root_path, root_node, file_contents, args.format + ) + + print(f"\nOutput written to: {output_path}") + + # 클립보드 복사 (활성화된 경우) + if not args.no_clipboard: + try: + with open(output_path, 'r', encoding='utf-8') as f: + content = f.read() + + if utils.try_copy_to_clipboard(content): + print("Content copied to clipboard.") + else: + print("Could not copy to clipboard (missing dependencies).") + except Exception as e: + print(f"Error copying to clipboard: {e}") + + return 0 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/codeselect.py b/codeselect.py index caece12..94603bc 100644 --- a/codeselect.py +++ b/codeselect.py @@ -1,1031 +1,14 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -CodeSelect - Easily select files to share with AI assistants +CodeSelect - Main script -A simple tool that generates a file tree and extracts the content of selected files -to share with AI assistants like Claude or ChatGPT. +A tool for easily selecting and exporting files to share with the AI assistant. +The file simply serves as an entry point to call the main function of the CLI module. """ -import os import sys -import re -import argparse -import fnmatch -import curses -import shutil -import tempfile -import subprocess -from pathlib import Path -import datetime - -__version__ = "1.0.0" - -# Structure to represent a node in the file tree -class Node: - def __init__(self, name, is_dir, parent=None): - self.name = name - self.is_dir = is_dir - self.children = {} if is_dir else None - self.parent = parent - self.selected = True # Selected by default - self.expanded = True # Folders expanded by default - - @property - def path(self): - """Get the full path of the node.""" - if self.parent is None: - return self.name - parent_path = self.parent.path - if parent_path.endswith(os.sep): - return parent_path + self.name - return parent_path + os.sep + self.name - -def build_file_tree(root_path, ignore_patterns=None): - """Build a tree representing the file structure.""" - if ignore_patterns is None: - ignore_patterns = ['.git', '__pycache__', '*.pyc', '.DS_Store', '.idea', '.vscode'] - - def should_ignore(path): - for pattern in ignore_patterns: - if fnmatch.fnmatch(os.path.basename(path), pattern): - return True - return False - - root_name = os.path.basename(root_path.rstrip(os.sep)) - if not root_name: # Case for root directory - root_name = root_path - - root_node = Node(root_name, True) - root_node.full_path = root_path # Store absolute path for root - - def add_path(current_node, path_parts, full_path): - if not path_parts: - return - - part = path_parts[0] - remaining = path_parts[1:] - - if should_ignore(os.path.join(full_path, part)): - return - - # Check if part already exists - if part in current_node.children: - child = current_node.children[part] - else: - is_dir = os.path.isdir(os.path.join(full_path, part)) - child = Node(part, is_dir, current_node) - current_node.children[part] = child - - # If there are remaining parts, continue recursively - if remaining: - next_path = os.path.join(full_path, part) - add_path(child, remaining, next_path) - - # Walk the directory structure - for dirpath, dirnames, filenames in os.walk(root_path): - # Skip filtered directories - dirnames[:] = [d for d in dirnames if not should_ignore(os.path.join(dirpath, d))] - - rel_path = os.path.relpath(dirpath, root_path) - if rel_path == '.': - # Add files in root - for filename in filenames: - if filename not in root_node.children and not should_ignore(filename): - file_node = Node(filename, False, root_node) - root_node.children[filename] = file_node - else: - # Add the directory - path_parts = rel_path.split(os.sep) - add_path(root_node, path_parts, root_path) - - # Add files in this directory - current = root_node - for part in path_parts: - if part in current.children: - current = current.children[part] - else: - # Skip if directory was filtered - break - else: - for filename in filenames: - if not should_ignore(filename) and filename not in current.children: - file_node = Node(filename, False, current) - current.children[filename] = file_node - - return root_node - -def flatten_tree(node, visible_only=True): - """Flatten the tree into a list of nodes for navigation.""" - flat_nodes = [] - - def _traverse(node, level=0): - if node.parent is not None: # Skip root node - flat_nodes.append((node, level)) - - if node.is_dir and node.children and (not visible_only or node.expanded): - # Sort directories first, then files, then alphabetically - items = sorted(node.children.items(), - key=lambda x: (not x[1].is_dir, x[0].lower())) - - for _, child in items: - _traverse(child, level + 1) - - _traverse(node) - return flat_nodes - -def count_selected_files(node): - """Count the number of selected files (not directories).""" - count = 0 - if not node.is_dir and node.selected: - count = 1 - elif node.is_dir and node.children: - for child in node.children.values(): - count += count_selected_files(child) - return count - -def collect_selected_content(node, root_path): - """Collect content from selected files.""" - results = [] - - if not node.is_dir and node.selected: - file_path = node.path - - # FIX: Ensure we're not duplicating the root path - if node.parent and node.parent.parent is None: - # If the node is directly under root, use just the filename - full_path = os.path.join(root_path, node.name) - else: - # For nested files, construct proper relative path - rel_path = file_path - if file_path.startswith(os.path.basename(root_path) + os.sep): - rel_path = file_path[len(os.path.basename(root_path) + os.sep):] - full_path = os.path.join(root_path, rel_path) - - try: - with open(full_path, 'r', encoding='utf-8') as f: - content = f.read() - results.append((file_path, content)) - except UnicodeDecodeError: - print(f"Ignoring binary file: {file_path}") - except Exception as e: - print(f"Error reading {full_path}: {e}") - elif node.is_dir and node.children: - for child in node.children.values(): - results.extend(collect_selected_content(child, root_path)) - - return results - -def collect_all_content(node, root_path): - """Collect content from all files (for analysis).""" - results = [] - - if not node.is_dir: - file_path = node.path - - # FIX: Apply the same path fixes as in collect_selected_content - if node.parent and node.parent.parent is None: - full_path = os.path.join(root_path, node.name) - else: - rel_path = file_path - if file_path.startswith(os.path.basename(root_path) + os.sep): - rel_path = file_path[len(os.path.basename(root_path) + os.sep):] - full_path = os.path.join(root_path, rel_path) - - try: - with open(full_path, 'r', encoding='utf-8') as f: - content = f.read() - results.append((file_path, content)) - except UnicodeDecodeError: - pass # Silently ignore binary files - except Exception: - pass # Silently ignore errors - elif node.is_dir and node.children: - for child in node.children.values(): - results.extend(collect_all_content(child, root_path)) - - return results - -def analyze_dependencies(root_path, file_contents): - """Analyze relationships between project files. - - Detects dependencies for multiple programming languages - by analyzing imports, includes, references, etc. - """ - dependencies = {} - imports = {} - - # Define detection patterns for different languages - language_patterns = { - # Python - '.py': [ - r'^from\s+([\w.]+)\s+import', - r'^import\s+([\w.]+)', - ], - # C/C++ - '.c': [r'#include\s+[<"]([^>"]+)[>"]'], - '.h': [r'#include\s+[<"]([^>"]+)[>"]'], - '.cpp': [r'#include\s+[<"]([^>"]+)[>"]'], - '.hpp': [r'#include\s+[<"]([^>"]+)[>"]'], - # JavaScript/TypeScript - '.js': [ - r'(?:import|require)\s*\(?[\'"]([@\w\-./]+)[\'"]', - r'from\s+[\'"]([@\w\-./]+)[\'"]', - ], - '.ts': [ - r'(?:import|require)\s*\(?[\'"]([@\w\-./]+)[\'"]', - r'from\s+[\'"]([@\w\-./]+)[\'"]', - ], - # Java - '.java': [ - r'import\s+([\w.]+)', - ], - # Go - '.go': [ - r'import\s+\(\s*(?:[_\w]*\s+)?["]([^"]+)["]', - r'import\s+(?:[_\w]*\s+)?["]([^"]+)["]', - ], - # Ruby - '.rb': [ - r'require\s+[\'"]([^\'"]+)[\'"]', - r'require_relative\s+[\'"]([^\'"]+)[\'"]', - ], - # PHP - '.php': [ - r'(?:require|include)(?:_once)?\s*\(?[\'"]([^\'"]+)[\'"]', - r'use\s+([\w\\]+)', - ], - # Rust - '.rs': [ - r'use\s+([\w:]+)', - r'extern\s+crate\s+([\w]+)', - ], - # Swift - '.swift': [ - r'import\s+(\w+)', - ], - # Shell scripts - '.sh': [ - r'source\s+[\'"]?([^\'"]+)[\'"]?', - r'\.\s+[\'"]?([^\'"]+)[\'"]?', - ], - # Makefile - 'Makefile': [ - r'include\s+([^\s]+)', - ], - } - - # First pass: collect all imports - for file_path, content in file_contents: - dependencies[file_path] = set() - imports[file_path] = set() - - ext = os.path.splitext(file_path)[1].lower() - basename = os.path.basename(file_path) - - # Select appropriate patterns - patterns = [] - if ext in language_patterns: - patterns = language_patterns[ext] - elif basename in language_patterns: - patterns = language_patterns[basename] - - # Apply all relevant patterns - for pattern in patterns: - matches = re.findall(pattern, content, re.MULTILINE) - for match in matches: - imports[file_path].add(match) - - # Second pass: resolve references between files - file_mapping = {} # Create mapping of possible names to file paths - - for file_path, _ in file_contents: - basename = os.path.basename(file_path) - name_without_ext = os.path.splitext(basename)[0] - - # Add different forms of file name - file_mapping[basename] = file_path - file_mapping[name_without_ext] = file_path - file_mapping[file_path] = file_path - - # For paths with folders, also add relative variants - if os.path.dirname(file_path): - rel_path = file_path - while '/' in rel_path: - rel_path = rel_path[rel_path.find('/')+1:] - file_mapping[rel_path] = file_path - file_mapping[os.path.splitext(rel_path)[0]] = file_path - - # Resolve imports to file dependencies - for file_path, imported in imports.items(): - for imp in imported: - # Try to match import with a known file - matched = False - - # Try variations of the import to find a match - import_variations = [ - imp, - os.path.basename(imp), - os.path.splitext(imp)[0], - imp.replace('.', '/'), - imp.replace('.', '/') + '.py', # For Python - imp + '.h', # For C - imp + '.hpp', # For C++ - imp + '.js', # For JS - ] - - for var in import_variations: - if var in file_mapping: - dependencies[file_path].add(file_mapping[var]) - matched = True - break - - # If no match found, keep the import as is - if not matched: - dependencies[file_path].add(imp) - - return dependencies - -def write_llm_optimized_output(output_path, root_path, root_node, file_contents, dependencies): - """Write output in a format optimized for LLM analysis.""" - with open(output_path, 'w', encoding='utf-8') as f: - # Header and overview - f.write("# PROJECT ANALYSIS FOR AI ASSISTANT\n\n") - - # General project information - total_files = sum(1 for node, _ in flatten_tree(root_node) if not node.is_dir) - selected_files = count_selected_files(root_node) - f.write("## 📦 GENERAL INFORMATION\n\n") - f.write(f"- **Project path**: `{root_path}`\n") - f.write(f"- **Total files**: {total_files}\n") - f.write(f"- **Files included in this analysis**: {selected_files}\n") - - # Detect languages used - languages = {} - for path, _ in file_contents: - ext = os.path.splitext(path)[1].lower() - if ext: - ext = ext[1:] # Remove the dot - languages[ext] = languages.get(ext, 0) + 1 - - if languages: - f.write("- **Main languages used**:\n") - for ext, count in sorted(languages.items(), key=lambda x: x[1], reverse=True)[:5]: - lang_name = get_language_name(ext) - f.write(f" - {lang_name} ({count} files)\n") - f.write("\n") - - # Project structure - f.write("## 🗂️ PROJECT STRUCTURE\n\n") - f.write("```\n") - f.write(f"{root_path}\n") - f.write(write_file_tree_to_string(root_node)) - f.write("```\n\n") - - # Main directories and components - main_dirs = [node for node, level in flatten_tree(root_node, False) - if node.is_dir and level == 1] - - if main_dirs: - f.write("### 📂 Main Components\n\n") - for dir_node in main_dirs: - dir_files = [p for p, _ in file_contents if p.startswith(f"{dir_node.name}/")] - f.write(f"- **`{dir_node.name}/`** - ") - if dir_files: - f.write(f"Contains {len(dir_files)} files") - - # Languages in this directory - dir_exts = {} - for path in dir_files: - ext = os.path.splitext(path)[1].lower() - if ext: - ext = ext[1:] - dir_exts[ext] = dir_exts.get(ext, 0) + 1 - - if dir_exts: - main_langs = [get_language_name(ext) for ext, _ in - sorted(dir_exts.items(), key=lambda x: x[1], reverse=True)[:2]] - f.write(f" mainly in {', '.join(main_langs)}") - - f.write("\n") - f.write("\n") - - # File relationship graph - f.write("## 🔄 FILE RELATIONSHIPS\n\n") - - # Find most referenced files - referenced_by = {} - for file, deps in dependencies.items(): - for dep in deps: - if isinstance(dep, str) and os.path.sep in dep: # It's a file path - if dep not in referenced_by: - referenced_by[dep] = [] - referenced_by[dep].append(file) - - # Display important relationships - if referenced_by: - f.write("### Core Files (most referenced)\n\n") - for file, refs in sorted(referenced_by.items(), key=lambda x: len(x[1]), reverse=True)[:10]: - if len(refs) > 1: # Only files referenced multiple times - f.write(f"- **`{file}`** is imported by {len(refs)} files\n") - f.write("\n") - - # Display dependencies per file - f.write("### Dependencies by File\n\n") - for file, deps in sorted(dependencies.items()): - if deps: - internal_deps = [d for d in deps if isinstance(d, str) and os.path.sep in d] - external_deps = [d for d in deps if d not in internal_deps] - - f.write(f"- **`{file}`**:\n") - - if internal_deps: - f.write(f" - *Internal dependencies*: ") - f.write(", ".join(f"`{d}`" for d in sorted(internal_deps)[:5])) - if len(internal_deps) > 5: - f.write(f" and {len(internal_deps)-5} more") - f.write("\n") - - if external_deps: - f.write(f" - *External dependencies*: ") - f.write(", ".join(f"`{d}`" for d in sorted(external_deps)[:5])) - if len(external_deps) > 5: - f.write(f" and {len(external_deps)-5} more") - f.write("\n") - f.write("\n") - - # File contents - f.write("## 📄 FILE CONTENTS\n\n") - f.write("*Note: The content below includes only selected files.*\n\n") - - for path, content in file_contents: - f.write(f"### {path}\n\n") - - # Add file info if available - file_deps = dependencies.get(path, set()) - if file_deps: - internal_deps = [d for d in file_deps if isinstance(d, str) and os.path.sep in d] - external_deps = [d for d in file_deps if d not in internal_deps] - - if internal_deps or external_deps: - f.write("**Dependencies:**\n") - - if internal_deps: - f.write("- Internal: " + ", ".join(f"`{d}`" for d in sorted(internal_deps)[:3])) - if len(internal_deps) > 3: - f.write(f" and {len(internal_deps)-3} more") - f.write("\n") - - if external_deps: - f.write("- External: " + ", ".join(f"`{d}`" for d in sorted(external_deps)[:3])) - if len(external_deps) > 3: - f.write(f" and {len(external_deps)-3} more") - f.write("\n") - - f.write("\n") - - # Syntax highlighting based on extension - ext = os.path.splitext(path)[1][1:].lower() - f.write(f"```{ext}\n") - f.write(content) - if not content.endswith('\n'): - f.write('\n') - f.write("```\n\n") - -def get_language_name(extension): - """Convert a file extension to a language name.""" - language_map = { - 'py': 'Python', - 'c': 'C', - 'cpp': 'C++', - 'h': 'C/C++ Header', - 'hpp': 'C++ Header', - 'js': 'JavaScript', - 'ts': 'TypeScript', - 'java': 'Java', - 'html': 'HTML', - 'css': 'CSS', - 'php': 'PHP', - 'rb': 'Ruby', - 'go': 'Go', - 'rs': 'Rust', - 'swift': 'Swift', - 'kt': 'Kotlin', - 'sh': 'Shell', - 'md': 'Markdown', - 'json': 'JSON', - 'xml': 'XML', - 'yaml': 'YAML', - 'yml': 'YAML', - 'sql': 'SQL', - 'r': 'R', - } - return language_map.get(extension, extension.upper()) - -def try_copy_to_clipboard(text): - """Attempt to copy text to clipboard with graceful fallback.""" - try: - # Try platform-specific methods - if sys.platform == 'darwin': # macOS - try: - process = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE) - process.communicate(text.encode('utf-8')) - return True - except: - pass - elif sys.platform == 'win32': # Windows - try: - process = subprocess.Popen(['clip'], stdin=subprocess.PIPE) - process.communicate(text.encode('utf-8')) - return True - except: - pass - elif sys.platform.startswith('linux'): # Linux - for cmd in ['xclip -selection clipboard', 'xsel -ib']: - try: - process = subprocess.Popen(cmd.split(), stdin=subprocess.PIPE) - process.communicate(text.encode('utf-8')) - return True - except: - continue - - # If all else fails, try to create a file in the home directory - fallback_path = os.path.expanduser("~/codeselect_output.txt") - with open(fallback_path, 'w', encoding='utf-8') as f: - f.write(text) - print(f"Clipboard copy failed. Output saved to: {fallback_path}") - return False - except: - print("Could not copy to clipboard or save to file.") - return False - -class FileSelector: - def __init__(self, root_node, stdscr): - self.root_node = root_node - self.stdscr = stdscr - self.current_index = 0 - self.scroll_offset = 0 - self.visible_nodes = flatten_tree(root_node) - self.max_visible = 0 - self.height, self.width = 0, 0 - self.copy_to_clipboard = True # Default: copy to clipboard enabled - self.initialize_curses() - - def initialize_curses(self): - """Initialize curses settings.""" - curses.start_color() - curses.use_default_colors() - # Define color pairs - curses.init_pair(1, curses.COLOR_GREEN, -1) # Selected files - curses.init_pair(2, curses.COLOR_BLUE, -1) # Directories - curses.init_pair(3, curses.COLOR_YELLOW, -1) # Selected directories - curses.init_pair(4, curses.COLOR_WHITE, -1) # Unselected files - curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE) # Current selection - curses.init_pair(6, curses.COLOR_RED, -1) # Help message - - # Hide cursor - curses.curs_set(0) - - # Enable special keys - self.stdscr.keypad(True) - - # Get screen dimensions - self.update_dimensions() - - def update_dimensions(self): - """Update screen dimensions.""" - self.height, self.width = self.stdscr.getmaxyx() - self.max_visible = self.height - 6 # One more line for stats at top - - def expand_all(self, expand=True): - """Expand or collapse all directories.""" - def _set_expanded(node, expand): - if node.is_dir and node.children: - node.expanded = expand - for child in node.children.values(): - _set_expanded(child, expand) - - _set_expanded(self.root_node, expand) - self.visible_nodes = flatten_tree(self.root_node) - - def toggle_current_dir_selection(self): - """Toggle selection of files in current directory only (no subdirectories).""" - if self.current_index < len(self.visible_nodes): - current_node, _ = self.visible_nodes[self.current_index] - - # If current node is a directory, toggle selection of its immediate children only - if current_node.is_dir and current_node.children: - # Check if majority of children are selected to determine action - selected_count = sum(1 for child in current_node.children.values() if child.selected) - select_all = selected_count <= len(current_node.children) / 2 - - # Set all immediate children to the new selection state - for child in current_node.children.values(): - child.selected = select_all - # If current node is a file, just toggle its selection - else: - current_node.selected = not current_node.selected - - def draw_tree(self): - """Draw the file tree.""" - self.stdscr.clear() - self.update_dimensions() - - # Update visible nodes list - self.visible_nodes = flatten_tree(self.root_node) - - # Check bounds - if self.current_index >= len(self.visible_nodes): - self.current_index = len(self.visible_nodes) - 1 - if self.current_index < 0: - self.current_index = 0 - - # Adjust scroll if needed - if self.current_index < self.scroll_offset: - self.scroll_offset = self.current_index - elif self.current_index >= self.scroll_offset + self.max_visible: - self.scroll_offset = self.current_index - self.max_visible + 1 - - # Display statistics on line 1 (not line 0) to avoid hiding first item - selected_count = count_selected_files(self.root_node) - total_count = sum(1 for node, _ in self.visible_nodes if not node.is_dir) - self.stdscr.addstr(0, 0, f"Selected files: {selected_count}/{total_count}", curses.A_BOLD) - - # Draw visible nodes starting from line 1 - for i, (node, level) in enumerate(self.visible_nodes[self.scroll_offset:self.scroll_offset + self.max_visible]): - y = i + 1 # Start from line 1 (below stats) - if y >= self.max_visible + 1: - break - - # Determine color based on type and selection state - if i + self.scroll_offset == self.current_index: - # Active node (highlighted) - attr = curses.color_pair(5) - elif node.is_dir: - # Directory - attr = curses.color_pair(3) if node.selected else curses.color_pair(2) - else: - # File - attr = curses.color_pair(1) if node.selected else curses.color_pair(4) - - # Prepare line to display - indent = " " * level - if node.is_dir: - prefix = "+ " if node.expanded else "- " - else: - prefix = "✓ " if node.selected else "☐ " - - # Truncate name if too long - name_space = self.width - len(indent) - len(prefix) - 2 - name_display = node.name[:name_space] + ("..." if len(node.name) > name_space else "") - - # Display the line - self.stdscr.addstr(y, 0, f"{indent}{prefix}{name_display}", attr) - - # Display help at bottom of screen - help_y = self.height - 5 - self.stdscr.addstr(help_y, 0, "━" * self.width) - help_y += 1 - self.stdscr.addstr(help_y, 0, "↑/↓: Navigate SPACE: Select ←/→: Close/Open folder", curses.color_pair(6)) - help_y += 1 - self.stdscr.addstr(help_y, 0, "T: Toggle dir only E: Expand all C: Collapse all", curses.color_pair(6)) - help_y += 1 - clip_status = "ON" if self.copy_to_clipboard else "OFF" - self.stdscr.addstr(help_y, 0, f"A: Select All N: Select None B: Clipboard ({clip_status}) X: Exit D: Done", curses.color_pair(6)) - - self.stdscr.refresh() - - def toggle_selection(self, node): - """Toggle selection of a node and its children if it's a directory.""" - node.selected = not node.selected - - if node.is_dir and node.children: - for child in node.children.values(): - child.selected = node.selected - if child.is_dir: - self.toggle_selection(child) - - def toggle_expand(self, node): - """Expand or collapse a directory.""" - if node.is_dir: - node.expanded = not node.expanded - # Update the visible nodes list - self.visible_nodes = flatten_tree(self.root_node) - - def select_all(self, select=True): - """Select or deselect all nodes.""" - def _select_recursive(node): - node.selected = select - if node.is_dir and node.children: - for child in node.children.values(): - _select_recursive(child) - - _select_recursive(self.root_node) - - def run(self): - """Run the selection interface.""" - while True: - self.draw_tree() - key = self.stdscr.getch() - - if key == curses.KEY_UP: - # Move up - self.current_index = max(0, self.current_index - 1) - - elif key == curses.KEY_DOWN: - # Move down - self.current_index = min(len(self.visible_nodes) - 1, self.current_index + 1) - - elif key == curses.KEY_RIGHT: - # Open a directory - if self.current_index < len(self.visible_nodes): - node, _ = self.visible_nodes[self.current_index] - if node.is_dir and not node.expanded: - self.toggle_expand(node) - - elif key == curses.KEY_LEFT: - # Close a directory - if self.current_index < len(self.visible_nodes): - node, _ = self.visible_nodes[self.current_index] - if node.is_dir and node.expanded: - self.toggle_expand(node) - elif node.parent and node.parent.parent: # Go to parent (except root) - # Find parent's index - for i, (n, _) in enumerate(self.visible_nodes): - if n == node.parent: - self.current_index = i - break - - elif key == ord(' '): - # Toggle selection - if self.current_index < len(self.visible_nodes): - node, _ = self.visible_nodes[self.current_index] - self.toggle_selection(node) - - elif key in [ord('a'), ord('A')]: - # Select all - self.select_all(True) - - elif key in [ord('n'), ord('N')]: - # Select none - self.select_all(False) - - elif key in [ord('e'), ord('E')]: - # Expand all - self.expand_all(True) - - elif key in [ord('c'), ord('C')]: - # Collapse all - self.expand_all(False) - - elif key in [ord('t'), ord('T')]: - # Toggle selection of current directory only - self.toggle_current_dir_selection() - - elif key in [ord('b'), ord('B')]: # Changed from 'c' to 'b' for clipboard - # Toggle clipboard - self.copy_to_clipboard = not self.copy_to_clipboard - - elif key in [ord('x'), ord('X'), 27]: # 27 = ESC - # Exit without saving - return False - - elif key in [ord('d'), ord('D'), 10, 13]: # 10, 13 = Enter - # Done - return True - - elif key == curses.KEY_RESIZE: - # Handle window resize - self.update_dimensions() - - return True - -def interactive_selection(root_node): - """Launch the interactive file selection interface.""" - return curses.wrapper(lambda stdscr: FileSelector(root_node, stdscr).run()) - -def write_file_tree_to_string(node, prefix='', is_last=True): - """Write the file tree as a string.""" - result = "" - - if node.parent is not None: # Skip root node - branch = "└── " if is_last else "├── " - result += f"{prefix}{branch}{node.name}\n" - - if node.is_dir and node.children: - items = sorted(node.children.items(), - key=lambda x: (not x[1].is_dir, x[0].lower())) - - for i, (_, child) in enumerate(items): - is_last_child = i == len(items) - 1 - new_prefix = prefix + (' ' if is_last else '│ ') - result += write_file_tree_to_string(child, new_prefix, is_last_child) - - return result - -def generate_output_filename(directory_path, output_format='txt'): - """Generate a unique output filename based on the directory name.""" - base_name = os.path.basename(os.path.abspath(directory_path)) - extension = f".{output_format}" - - # Start with the base name - output_name = f"{base_name}{extension}" - counter = 1 - - # If file exists, add a counter - while os.path.exists(output_name): - output_name = f"{base_name}({counter}){extension}" - counter += 1 - - return output_name - -def write_output_file(output_path, root_path, root_node, file_contents, output_format='txt', dependencies=None): - """ - Write the file tree and selected content to an output file. - - Formats: - - txt: Simple text format with and sections - - md: GitHub-compatible markdown - - llm: Format optimized for LLMs - """ - if output_format == 'md': - write_markdown_output(output_path, root_path, root_node, file_contents) - elif output_format == 'llm': - if dependencies is None: - # Collect all files for analysis - all_files = collect_all_content(root_node, root_path) - dependencies = analyze_dependencies(root_path, all_files) - write_llm_optimized_output(output_path, root_path, root_node, file_contents, dependencies) - else: - # Default txt format - with open(output_path, 'w', encoding='utf-8') as f: - # Write file tree - f.write("\n") - f.write(f"{root_path}\n") - - tree_str = write_file_tree_to_string(root_node) - f.write(tree_str) - - f.write("\n\n") - - # Write file contents - f.write("\n") - for path, content in file_contents: - f.write(f"File: {path}\n") - f.write("```") - - # Determine extension for syntax highlighting - ext = os.path.splitext(path)[1][1:].lower() - if ext: - f.write(ext) - - f.write("\n") - f.write(content) - if not content.endswith('\n'): - f.write('\n') - f.write("```\n\n") - - f.write("\n") - - return output_path - -def write_markdown_output(output_path, root_path, root_node, file_contents): - """Write output in GitHub-compatible markdown format.""" - with open(output_path, 'w', encoding='utf-8') as f: - # Write header - f.write(f"# Project Files: `{root_path}`\n\n") - - # Write file structure section - f.write("## 📁 File Structure\n\n") - f.write("```\n") - f.write(f"{root_path}\n") - f.write(write_file_tree_to_string(root_node)) - f.write("```\n\n") - - # Write file contents section - f.write("## 📄 File Contents\n\n") - - for path, content in file_contents: - f.write(f"### {path}\n\n") - - # Add syntax highlighting based on extension - ext = os.path.splitext(path)[1][1:].lower() - f.write(f"```{ext}\n") - f.write(content) - if not content.endswith('\n'): - f.write('\n') - f.write("```\n\n") - -def main(): - """Main entry point for the script.""" - parser = argparse.ArgumentParser( - description=f"CodeSelect v{__version__} - Select files to share with AI assistants" - ) - parser.add_argument( - "directory", - nargs="?", - default=".", - help="Directory to scan (default: current directory)" - ) - parser.add_argument( - "-o", "--output", - help="Output file path (default: based on directory name)" - ) - parser.add_argument( - "--format", - choices=["txt", "md", "llm"], - default="llm", - help="Output format (default: llm - optimized for LLMs)" - ) - parser.add_argument( - "--skip-selection", - action="store_true", - help="Skip the selection interface and include all files" - ) - parser.add_argument( - "--no-clipboard", - action="store_true", - help="Disable automatic copy to clipboard" - ) - parser.add_argument( - "--version", - action="store_true", - help="Show version information" - ) - - args = parser.parse_args() - - # Handle version display - if args.version: - print(f"CodeSelect v{__version__}") - sys.exit(0) - - # Resolve directory path - root_path = os.path.abspath(args.directory) - if not os.path.isdir(root_path): - print(f"Error: {root_path} is not a valid directory") - return 1 - - # Generate output filename if not specified - if not args.output: - args.output = generate_output_filename(root_path, args.format) - - print(f"Scanning directory: {root_path}") - root_node = build_file_tree(root_path) - - proceed = True - if not args.skip_selection: - # Launch interactive selection interface - try: - proceed = interactive_selection(root_node) - if not proceed: - print("Selection cancelled. Exiting without saving.") - return 0 - except Exception as e: - print(f"Error in selection interface: {e}") - return 1 - - # Count selected files - selected_count = count_selected_files(root_node) - print(f"\nSelected files: {selected_count}") - - # Exit if no files selected - if selected_count == 0: - print("No files selected. Exiting.") - return 0 - - # Collect content from selected files - file_contents = collect_selected_content(root_node, root_path) - print(f"Collected content from {len(file_contents)} files.") - - # Analyze dependencies if using LLM format - if args.format == 'llm': - print("Analyzing file relationships...") - all_files = collect_all_content(root_node, root_path) - dependencies = analyze_dependencies(root_path, all_files) - - # Write output with dependencies - output_path = write_output_file(args.output, root_path, root_node, file_contents, args.format, dependencies) - else: - # Write output without dependencies - output_path = write_output_file(args.output, root_path, root_node, file_contents, args.format) - - print(f"\nOutput written to: {output_path}") - - # Copy to clipboard if enabled - if not args.no_clipboard: - try: - with open(output_path, 'r', encoding='utf-8') as f: - content = f.read() - - if try_copy_to_clipboard(content): - print("Content copied to clipboard.") - else: - print("Could not copy to clipboard (missing dependencies).") - except Exception as e: - print(f"Error copying to clipboard: {e}") - - return 0 +from cli import main if __name__ == "__main__": - sys.exit(main()) + sys.exit(main()) \ No newline at end of file diff --git a/dependency.py b/dependency.py new file mode 100644 index 0000000..8ba05f6 --- /dev/null +++ b/dependency.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +CodeSelect - Dependency module + +이 모듈은 프로젝트 파일 간의 의존성 분석 기능을 제공합니다. +다양한 프로그래밍 언어의 import, include, require 등의 패턴을 인식하여 +파일 간의 참조 관계를 분석합니다. +""" + +import re +import os + +def analyze_dependencies(root_path, file_contents): + """ + Analyse dependency relationships between project files. + + Recognise patterns such as import, include, and require in various programming languages to analyse dependencies between + Analyses dependencies between files. + + Args: + root_path (str): Project root path. + file_contents (list): List of file contents [(path, contents), ...] + + Returns: + dict: file-specific dependency information {filepath: {dependency1, dependency2, ...}, ...} + """ + dependencies = {} + imports = {} + + # 다양한 언어별 패턴 정의 + language_patterns = { + # Python + '.py': [ + r'^from\s+([\w.]+)\s+import', + r'^import\s+([\w.]+)', + ], + # C/C++ + '.c': [r'#include\s+[<"]([^>"]+)[>"]'], + '.h': [r'#include\s+[<"]([^>"]+)[>"]'], + '.cpp': [r'#include\s+[<"]([^>"]+)[>"]'], + '.hpp': [r'#include\s+[<"]([^>"]+)[>"]'], + # JavaScript/TypeScript + '.js': [ + r'(?:import|require)\s*\(?[\'"]([@\w\-./]+)[\'"]', + r'from\s+[\'"]([@\w\-./]+)[\'"]', + r'import\s+{[^}]*\b(\w+)\b[^}]*}\s+from', # Destructured imports + ], + '.ts': [ + r'(?:import|require)\s*\(?[\'"]([@\w\-./]+)[\'"]', + r'from\s+[\'"]([@\w\-./]+)[\'"]', + r'import\s+{[^}]*\b(\w+)\b[^}]*}\s+from', # Destructured imports + ], + # Java + '.java': [ + r'import\s+([\w.]+)', + ], + # Go + '.go': [ + r'import\s+\(\s*(?:[_\w]*\s+)?["]([^"]+)["]', + r'import\s+(?:[_\w]*\s+)?["]([^"]+)["]', + ], + # Ruby + '.rb': [ + r'require\s+[\'"]([^\'"]+)[\'"]', + r'require_relative\s+[\'"]([^\'"]+)[\'"]', + ], + # PHP + '.php': [ + r'(?:require|include)(?:_once)?\s*\(?[\'"]([^\'"]+)[\'"]', + r'use\s+([\w\\]+)', + ], + # Rust + '.rs': [ + r'use\s+([\w:]+)', + r'extern\s+crate\s+([\w]+)', + ], + # Swift + '.swift': [ + r'import\s+(\w+)', + ], + # Shell scripts + '.sh': [ + r'source\s+[\'"]?([^\'"]+)[\'"]?', + r'\.\s+[\'"]?([^\'"]+)[\'"]?', + ], + # Makefile + 'Makefile': [ + r'include\s+([^\s]+)', + ], + } + + # 첫 번째 단계: 모든 import 수집 + for file_path, content in file_contents: + dependencies[file_path] = set() + imports[file_path] = set() + + ext = os.path.splitext(file_path)[1].lower() + basename = os.path.basename(file_path) + + # 적절한 패턴 선택 + patterns = [] + if ext in language_patterns: + patterns = language_patterns[ext] + elif basename in language_patterns: + patterns = language_patterns[basename] + + # 모든 관련 패턴 적용 + for pattern in patterns: + matches = re.findall(pattern, content, re.MULTILINE) + for match in matches: + imports[file_path].add(match) + + # 두 번째 단계: 파일 간 참조 관계 해결 + file_mapping = {} # 가능한 이름과 파일 경로 간의 매핑 생성 + + for file_path, _ in file_contents: + basename = os.path.basename(file_path) + name_without_ext = os.path.splitext(basename)[0] + + # 파일 이름의 다양한 형태 추가 + file_mapping[basename] = file_path + file_mapping[name_without_ext] = file_path + file_mapping[file_path] = file_path + + # 폴더가 있는 경로의 경우 상대 경로 변형도 추가 + if os.path.dirname(file_path): + rel_path = file_path + while '/' in rel_path: + rel_path = rel_path[rel_path.find('/')+1:] + file_mapping[rel_path] = file_path + file_mapping[os.path.splitext(rel_path)[0]] = file_path + + # import를 파일 의존성으로 변환 + for file_path, imported in imports.items(): + for imp in imported: + # 알려진 파일과 import 일치 시도 + matched = False + + # import의 변형을 시도하여 매칭 찾기 + import_variations = [ + imp, + os.path.basename(imp), + os.path.splitext(imp)[0], + imp.replace('.', '/'), + imp.replace('.', '/') + '.py', # Python용 + imp + '.h', # C용 + imp + '.hpp', # C++용 + imp + '.js', # JS용 + ] + + for var in import_variations: + if var in file_mapping: + dependencies[file_path].add(file_mapping[var]) + matched = True + break + + # 일치하는 항목이 없으면 import를 그대로 유지 + if not matched: + dependencies[file_path].add(imp) + + return dependencies \ No newline at end of file diff --git a/docs/diagram/flow.md b/docs/diagram/flow.md new file mode 100644 index 0000000..f3d032a --- /dev/null +++ b/docs/diagram/flow.md @@ -0,0 +1,34 @@ +```mermaid +graph TD; + A[User executes `codeselect`] -->|CLI initializes| B[Parse CLI arguments] + + B --> C{File selection mode} + C -->|Interactive selection| D[Launch curses UI] + C -->|Auto-select all files| E[Include all files automatically] + + D --> F[User selects files] + F --> G[Retrieve selected files] + E --> G + + G --> H{Perform dependency analysis?} + H -->|Yes| I[Analyze dependencies] + I --> J[Store dependency results] + H -->|No| J + + J --> K{Generate output format} + K -->|TXT| L[Save output as `.txt`] + K -->|Markdown| M[Save output as `.md`] + K -->|LLM-optimized| N[Save output as `.llm`] + + L --> O[Output processing complete] + M --> O + N --> O + + O --> P{Copy to clipboard?} + P -->|Yes| Q[Copy output content] + Q --> R[Notify user: Output copied] + P -->|No| R + + R --> S[Display final output path] + S --> T[Program exits] +``` \ No newline at end of file diff --git a/docs/diagram/sequence.md b/docs/diagram/sequence.md new file mode 100644 index 0000000..fe2791c --- /dev/null +++ b/docs/diagram/sequence.md @@ -0,0 +1,27 @@ +```mermaid +sequenceDiagram + participant User + participant CLI + participant FileTree + participant Selector + participant Dependency + participant Output + participant Clipboard + + User->>CLI: Execute `codeselect` + CLI->>FileTree: Request file tree structure + FileTree-->>CLI: Return file list + + CLI->>Selector: Launch curses-based file selection UI + User->>Selector: Navigate and select files + Selector-->>CLI: Return selected files + + CLI->>Dependency: Analyze dependencies of selected files + Dependency-->>CLI: Return dependency results + + CLI->>Output: Process selected files and generate output + Output-->>CLI: Save output in `txt`, `md`, or `llm` format + CLI->>Clipboard: Copy output to clipboard (if enabled) + Clipboard-->>User: Notify output copied + CLI-->>User: Display final output path +``` \ No newline at end of file diff --git a/docs/en/TODO.md b/docs/en/TODO.md new file mode 100644 index 0000000..f231d40 --- /dev/null +++ b/docs/en/TODO.md @@ -0,0 +1,110 @@ +# 📌 TODO list + +~~✅ **Support for more sophisticated `.gitignore` and filtering**~~ (Done) +- Automatically reflect `.gitignore` to determine which files to ignore~~ (DONE) +- Added `--include` and `--exclude` CLI options (e.g. `--include ‘*.py’ --exclude ‘tests/’`) + +✅ **Support for project-specific configuration files (`.codeselectrc`) +- Save `.codeselectrc` file in project root to auto-load settings +- JSON/YAML support (e.g. `include=[‘*.py’], exclude=[‘node_modules/’]`) + +--- + +## 🛠 Performance optimisations and UI improvements +✅ **Navigation speed optimisation +- Change `os.walk()` → `scandir()` to speed things up +- Consider introducing multi-threaded or asynchronous processing (to support large projects) + +✅ **Instant selection when searching**. +- Select files with `Enter` directly after searching `/` +- Parallel support with the existing `Space` selection method + +✅ Highlight selected files during navigation +- Fixed or separate colour highlighting of the currently selected file at the top + +History of recently used files/directories +- Save `.codeselect_history` file to keep recently selected files + +✅ **Optimise file tree navigation +- Improved performance by utilising `os.scandir()` instead of `os.walk()`. +- Improved speed of `.gitignore` and filtering + +File tree asynchronous processing +- Considered introducing `asyncio`-based asynchronous directory traversal +- Quickly build file trees, even for large projects + +✅ **Flexible filtering support +- Improved `.gitignore‘ to allow additional filtering settings in `.codeselectrc’ in addition to `.gitignore' + +--- +## 🚀 Improved CLI options +✅ **Automatic execution mode (`--auto-select`) +- Automatically select specific files and run them without UI (`codeselect --auto-select ‘*.py’`) + +✅ **Preview results (`--preview`)** +- Adds the ability to preview the contents of selected files + +✅ **Extended output format +- Currently support `txt`, `md`, `llm` → add support for `json`, `yaml` + +✅ **Automatically copy clipboard option**. +- Added `--no-clipboard` option to turn off auto-copy function + +--- + +## 📄 Documentation +✅ Created `project_structure.md` (describes project structure) +✅ Create `design_overview.md` (describe the design overview) +✅ Create `usage_guide.md` (usage guide) +✅ Create `file_selection.md` (describes file selection logic) +✅ Create `dependency_analysis.md` (dependency analysis document) +✅ Create `output_formats.md` (describes output data formats) + +---] +### 🏁 **Prioritise** +~~🚀 **Add `1️⃣ Vim-style `/` search function** (top priority)~~ (done) +~~📌 **Improve and modularise code structure of 2️⃣ (`codeselect.py` → split into multiple files)~~ (Done) +~~🔍 **Added **3️⃣ `.gitignore` support** (improved file filtering)~~ (Done) +⚡ **4️⃣ navigation speed optimisation and UI improvements** (Done) +📦 **5️⃣ `.codeselectrc` configuration file support** (improved filtering) +📜 **6️⃣ output format extended (added `json`, `yaml` support)** + + +--- + +# Completed tasks + +~~## 🏗 Improved code structure~~. +✅ **Separate and modularise code** (`codeselect.py` single file → multiple modules) +- `codeselect.py` is too bloated → split into functional modules +- 📂 **New module structure** ✅ **New module structure + - `filetree.py`: file tree and navigation + - `selector.py`: curses-based file selection UI + - `output.py`: Saving to various formats (txt, md, llm) + - cli.py`: Handles CLI commands and options + - `dependency.py`: Analyses dependencies between files in a project + +~~## 🔧 Support for `.gitignore`-based file filtering~~ +✅ **Automatically parse and filter `.gitignore` files**. +- Automatic detection of `.gitignore` files in the project root +- Support for different pattern types: + - Wildcard pattern (`*.log`) + - Directory-specific pattern (`ignored_dir/`) + - Exclusion pattern (`!important.log`) +- Added pattern loading and parsing functionality to `utils.py` +- Improved file path matching algorithm +- Added tests (pattern loading, file filtering) + +~~## 🔍 Added Vim-style file searching~~ +✅ **Implemented Vim-style search (search after `/` input)**. +- Enter search mode with `/` key, type search term and hit enter to execute search +- Support for regular expressions (e.g. `/.*\.py$` → search only .py files) +- Case-sensitive toggle function (using the `^` key) +- Preserves tree structure in search results + +Implemented **Vim-style navigation**. +- Move up/down with `j/k` keys +- Close/open folders with `h/l` keys +- Restore full list with ESC key in search mode + +--- \ No newline at end of file diff --git a/docs/en/change_log.md b/docs/en/change_log.md new file mode 100644 index 0000000..29fb1b6 --- /dev/null +++ b/docs/en/change_log.md @@ -0,0 +1,95 @@ +# Change Log + +## v1.3.0 (2025-03-12) + +### 🚀 Added `.gitignore` support +- Implemented automatic recognition of `.gitignore` files and pattern processing +- Supports various `.gitignore` patterns + - Wildcard pattern (`*.log`) + - Directory-specific pattern (`ignored_dir/`) + - Exclusion pattern (`!important.log`) +- Integration of .gitignore patterns into existing hardcoded ignore lists + +### 💻 Improved file filtering +- Improved file path comparison algorithm +- Support pattern matching both full paths and base names +- Improved filtering accuracy for files in subdirectories + +### 🧪 Testing +- Added `.gitignore` related unit tests +- Tested pattern loading functionality +- Test file filtering accuracy + +## v1.2.0 (2025-03-12) + +### 🏗 Code structure improvements +- Split `selector.py` module into three modules to improve readability and maintainability + - `selector_actions.py`: functions related to file selection, search, and expand/collapse actions + - `selector_ui.py`: user interface related `FileSelector` classes + - `selector.py`: `interactive_selection` function in the role of external interface + +### 💻 Refactoring benefits +- Separation of concerns: clear separation between UI code and behavioural logic +- Ease of testing: each module can be tested independently +- Extensibility: Easier to add new behaviours or UI elements + +### 🧪 Testing +- Add unit tests for all separated modules +- Ensure compatibility with existing functionality + +### 📖 Documentation +- Update project structure documentation +- Reflect module separation in design overview documentation + +## v1.1.0 (12-03-2024) + +### 🔍 Added Vim-style search functionality +- Support for search mode via `/` keys (Vim style) +- full support for regular expression search (e.g. `/.*\.py$`, `/test_.*`) +- Case-sensitive toggle functionality (using the `^` key) +- Maintain tree structure in search results - show directory hierarchy +- Ability to restore full list with ESC key after searching +- Ability to select/deselect files from search results + +### 🚀 Added Vim-style navigation +- Move up and down with `j` / `k` keys +- Close/open folders (and go to parent directory) with `h` / `l` keys +- Parallel support with existing arrow key navigation + +### 🎨 UI improvements +- improved search result status display (currently displayed files / total number of files) +- change status bar in search mode +- Show notification when there are no search results + +### 💻 Quality improvements +- Improved tree structure maintenance algorithm +- Optimised status management when cancelling/completing a search +- Improved error handling (display error when incorrect regex is entered) + +## v1.0.0 (11-03-2024) + +### 🏗 Code Structure Improvements +- CodeSelect has been modularized for better maintainability and future extensibility +- Separated monolithic codeselect.py into focused modules: + - `utils.py`: Common utility functions + - `filetree.py`: File tree structure management + - `selector.py`: Interactive file selection UI + - `output.py`: Output format management + - `dependency.py`: Project dependency analysis + - `cli.py`: Command line interface + - `codeselect.py`: Simple entry point script + +### 🔧 Refactoring +- Improved code organisation with proper separation of concerns +- Better isolation of functionality into single-responsibility modules +- Enhanced readability through clear module boundaries +- No functional changes to existing behaviour + +### 🧪 Testing +- Added unit tests for all new modules +- Test coverage for core functionality + +### 📖 Documentation +- Updated project_structure.md to reflect new modular architecture +- Added detailed documentation to each module +- Included Korean comments for core functionality \ No newline at end of file diff --git a/docs/en/design_overview.md b/docs/en/design_overview.md new file mode 100644 index 0000000..1246603 --- /dev/null +++ b/docs/en/design_overview.md @@ -0,0 +1,83 @@ +# Design overview + +## 🎯 Key design principles +1. Simplicity: Users should be able to select and easily share files within a project with a single command. +2. **Interactivity**: Provide a Curses-based UI to make file selection intuitive. +3. Extensibility: Design to allow for the addition of different file selection methods and output formats. +4. Minimal Dependencies: Uses only standard libraries to run without additional installations. +5. Familiar UX: Borrow controls from popular tools like Vim to minimise the learning curve. +6. **Separation of Concerns**: Improve maintainability and scalability with a clear separation of responsibilities between modules. + +## 🏛 System Architecture +CodeSelect consists of three main modules: **File Tree Generator**, **Interactive File Selector**, and **Output Generator**. + +### 📂 Main Modules +1. file tree generator (`build_file_tree`) + - Scans the project directory to generate a file tree. + - Automatically parse `.gitignore` files to extract patterns and filter out unnecessary files. + - Interprets and applies various `.gitignore` patterns (wildcard, directory-specific, exclusion patterns). + - Uses a combination of hardcoded default ignore patterns (`.git`, `__pycache__`, etc.) and `.gitignore` patterns. + - Internally utilises `os.walk()` to traverse the directory structure. + +2. Interactive file selectors + - File Selection Interface (`selector.py`) + - Initialise and run the selection interface via the `interactive_selection` function. + - UI component (`selector_ui.py`) + - Implement a curses-based UI with the `FileSelector` class + - Includes screen drawing, keystroke handling, and user interface logic + - Manage actions (`selector_actions.py`) + - Provides action-related functions for file selection, searching, expanding/contracting, etc. + - Implements core functions such as `toggle_selection`, `apply_search_filter`, etc. + +3. output generator (`write_output_file`) + - Converts selected files to a specified format (`txt`, `md`, `llm`) and saves them. + - Analyses dependencies between files and structures them in a way that is easy for LLM to understand. + - Automatically copies them to the clipboard if necessary for quick sharing. + +## 🔄 Data flow +``` +User launch → Scan directory → File selection UI → Collect selected files → Save and output files +``` +1. **Run user**: Execute `codeselect` command +2. **Directory Scan**: Analyses the entire list of files in the project +3. file selection UI: user selects files in curses UI (browse, search, filter) +4. collect selected files: Collect the required files via `collect_selected_content`. +5. save and output files: convert and save selected files or copy to clipboard + +## 🔍 Search and filtering design +The search function is designed with the following flow + +1. **Enter search mode**: Activate search mode via `/` key +2. support for regular expressions: process user input into regular expressions to support powerful filtering +3. preserves tree structure: shows directory hierarchy even in search results + - All parent directories of matched files are displayed +4. unfiltering: restore to full list via ESC key + +## 🔄 Interaction between modules +Interaction between the file selection modules is done as follows: + +1. **External interface (`selector.py`)**. + - UI initialisation and execution via `interactive_selection` function + - Setting up the terminal environment using `curses.wrapper`. + +2. the UI module (`selector_ui.py`) + - The `FileSelector` class is in charge of interacting with the user + - It takes keystrokes and calls the appropriate action function + +3. actions module (`selector_actions.py`) + - Performs the actions requested by the UI module (select, search, expand/collapse) + - Returns the result of the action to the UI module and reflects it on the screen + +## ⚙️ Design considerations +- Performance optimisation: Optimise `os.walk()` for fast file navigation even in large projects. +- Extensibility: Maintain a modularised structure to support different project structures in the future. +- Improved user experience: intuitive UI and automatic filtering of unnecessary files. +- Vim-friendly: borrowing keybindings from the popular Vim to lower the learning curve. +- Separation of concerns: Improved code readability and maintainability by separating UI and behavioural logic. + +## 🔍 Future improvements +- Add advanced filtering options: support for including/excluding certain extensions +- Deepen project dependency analysis**: More accurate analysis of `import` and `require` relationships, etc. +- Support for multiple output formats: consider additional support for JSON, YAML, etc. +- Search history management: support for storing and easily accessing previous searches +- Plugin system: consider introducing a plugin architecture to add custom behaviour \ No newline at end of file diff --git a/docs/en/project_structure.md b/docs/en/project_structure.md new file mode 100644 index 0000000..c1e3292 --- /dev/null +++ b/docs/en/project_structure.md @@ -0,0 +1,90 @@ +# 📂 **Project Structure (`codeselect`)** + +## 🏗️ **Folder & File Overview** +``` +codeselect/ + ├── codeselect.py # Main execution script (CLI entry point) + ├── cli.py # CLI command processing & execution flow control + ├── filetree.py # File tree exploration & hierarchical structure management + ├── selector.py # File selection interface (entry point) + ├── selector_ui.py # curses-based UI implementation (`FileSelector` class) + ├── selector_actions.py # Functions related to file selection actions + ├── output.py # Outputs selected files (supports txt, md, llm formats) + ├── dependency.py # Analyzes file dependencies (import/include detection) + ├── utils.py # Utility functions (path handling, clipboard copy, etc.) + ├── install.sh # Project installation script + ├── uninstall.sh # Project uninstallation script + ├── tests/ # Unit test folder + │ ├── test_filetree.py # Tests for file tree generation + │ ├── test_selector.py # Tests for file selection interface + │ ├── test_selector_actions.py # Tests for file selection actions + │ ├── test_selector_ui.py # Tests for UI components + │ └── test_dependency.py # Tests for dependency analysis + ├── docs/ # Documentation folder (architecture, usage guide, etc.) +``` + +--- + +## 🛠️ **Core Modules Explanation** + +### 1️⃣ `codeselect.py` (Program Execution Entry Point) +- Calls `cli.py` to run the program. +- Uses `argparse` to parse CLI options, then: + - Calls `filetree.py` to explore files. + - Runs `selector.py` for interactive selection. + +### 2️⃣ `cli.py` (CLI Commands & Execution Flow Management) +- Processes CLI arguments (`--format`, `--skip-selection`, etc.). +- Calls `filetree.build_file_tree()` to generate the file list. +- Runs `selector.interactive_selection()` to open the UI for file selection. +- Calls `dependency.analyze_dependencies()` for dependency analysis. +- Saves the results using `output.write_output_file()`. + +### 3️⃣ `filetree.py` (File Tree Exploration & Management) +- `build_file_tree(root_path)`: Analyzes directories and files to build a hierarchical tree. +- `flatten_tree(node)`: Converts the tree into a list for easier UI navigation. + +### 4️⃣ File Selection Modules (Split into Three Files) +#### a. `selector.py` (External Interface) +- `interactive_selection(root_node)`: Initializes the curses environment and runs `FileSelector`. +- Acts as a simple entry point interface for external modules. + +#### b. `selector_ui.py` (UI Components) +- `FileSelector` class: Implements a curses-based interactive UI. +- Handles screen rendering, key inputs, and user interface logic. +- Key functions: + - `run()`: Runs the selection interface loop. + - `draw_tree()`: Displays the file tree structure. + - `process_key()`: Handles user key inputs. + +#### c. `selector_actions.py` (Action Functions) +- `toggle_selection(node)`: Toggles file/folder selection. +- `toggle_expand(node)`: Expands/collapses directories. +- `apply_search_filter()`: Applies search filtering. +- `select_all()`: Selects/deselects all files. +- `toggle_current_dir_selection()`: Selects/deselects files only in the current directory. + +### 5️⃣ `dependency.py` (Dependency Analysis) +- `analyze_dependencies(root_path, file_contents)`: Analyzes `import`, `require`, and `include` patterns to extract file dependency relationships. +- Supports multiple languages: Python, JavaScript, C/C++, etc. + +### 6️⃣ `output.py` (Output File Handling) +- `write_output_file(output_path, format)`: Saves selected files in various formats (`txt`, `md`, `llm`). +- The `llm` format structures the data for AI model compatibility. + +### 7️⃣ `utils.py` (Utility Functions) +- `generate_output_filename(root_path, format)`: Automatically generates output file names. +- `try_copy_to_clipboard(content)`: Copies selected file content to the clipboard. +- `load_gitignore_patterns(directory)`: Loads and parses `.gitignore` patterns. +- `should_ignore_path(path, ignore_patterns)`: Checks if a file path matches ignore patterns. + +--- + +## 🚀 **Execution Flow Summary** +1️⃣ `codeselect.py` runs → `cli.py` parses arguments. +2️⃣ `filetree.py` generates the file tree. +3️⃣ `selector.py` initializes the curses environment. +4️⃣ `selector_ui.py` runs `FileSelector` for interactive selection. +5️⃣ `selector_actions.py` processes user actions. +6️⃣ `dependency.py` analyzes file dependencies. +7️⃣ `output.py` saves selected files and copies content to the clipboard. \ No newline at end of file diff --git a/docs/kr/TODO.md b/docs/kr/TODO.md new file mode 100644 index 0000000..6b56acc --- /dev/null +++ b/docs/kr/TODO.md @@ -0,0 +1,112 @@ +# 📌 TODO 목록 + +~~✅ **더 정교한 `.gitignore` 및 필터링 지원**~~ +- ~~`.gitignore` 자동 반영하여 무시할 파일 결정~~ (완료) +- `--include` 및 `--exclude` CLI 옵션 추가 (예: `--include "*.py" --exclude "tests/"`) + +✅ **프로젝트별 설정 파일 (`.codeselectrc`) 지원** +- 프로젝트 루트에 `.codeselectrc` 파일 저장하여 설정 자동 로드 +- JSON/YAML 지원 (예: `include=["*.py"], exclude=["node_modules/"]`) + +--- + +## 🛠 성능 최적화 및 UI 개선 +✅ **탐색 속도 최적화** +- `os.walk()` → `scandir()`로 변경하여 속도 향상 +- 다중 스레드 또는 비동기 처리 도입 고려 (대형 프로젝트 지원) + +✅ **탐색 시 즉시 선택 기능** +- `/` 검색 후 바로 `Enter`로 파일 선택 가능 +- 기존 `Space` 선택 방식과 병행 지원 + +✅ **탐색 중 선택한 파일을 강조 표시** +- 현재 선택된 파일을 상단에 고정 또는 별도 컬러 강조 + +✅ **최근 사용한 파일/디렉터리 기록** +- `.codeselect_history` 파일을 저장하여 최근 선택된 파일 유지 + +✅ **파일 트리 탐색 최적화** +- `os.walk()` 대신 `os.scandir()`를 활용하여 성능 향상 +- `.gitignore` 및 필터링 속도 개선 + +✅ **파일 트리 비동기 처리** +- `asyncio` 기반 비동기 디렉토리 탐색 도입 검토 +- 대규모 프로젝트에서도 빠르게 파일 트리 구축 가능 + +✅ **유연한 필터링 지원** +- `.gitignore` 외에 `.codeselectrc`에서 추가적인 필터링 설정 가능하도록 개선 + +--- + +## 🚀 CLI 옵션 개선 +✅ **자동 실행 모드 (`--auto-select`)** +- 특정 파일을 자동으로 선택하여 UI 없이 실행 (`codeselect --auto-select "*.py"`) + +✅ **결과 미리보기 (`--preview`)** +- 선택된 파일의 내용을 미리 보기 기능 추가 + +✅ **출력 포맷 확장** +- 현재 `txt`, `md`, `llm` 지원 → `json`, `yaml` 추가 지원 + +✅ **클립보드 자동 복사 옵션** +- `--no-clipboard` 옵션 추가하여 자동 복사 기능 끄기 + +--- + +## 📄 문서화 작업 +✅ `project_structure.md` 작성 (프로젝트 구조 설명) +✅ `design_overview.md` 작성 (설계 개요 설명) +✅ `usage_guide.md` 작성 (사용법 가이드) +✅ `file_selection.md` 작성 (파일 선택 로직 설명) +✅ `dependency_analysis.md` 작성 (의존성 분석 문서) +✅ `output_formats.md` 작성 (출력 데이터 형식 설명) + +--- + +### 🏁 **우선순위 정리** +~~🚀 **1️⃣ Vim 스타일 `/` 검색 기능 추가** (최우선)~~ (완료) +~~📌 **2️⃣ 코드 구조 개선 및 모듈화** (`codeselect.py` → 여러 파일로 분리)~~ (완료) +~~🔍 **3️⃣ `.gitignore` 지원 기능 추가** (파일 필터링 개선)~~ (완료) +⚡ **4️⃣ 탐색 속도 최적화 및 UI 개선** +📦 **5️⃣ `.codeselectrc` 설정 파일 지원** +📜 **6️⃣ 출력 포맷 확장 (`json`, `yaml` 지원 추가)** + + +--- + +# 완료된 작업 + +~~## 🏗 코드 구조 개선~~ +✅ **코드 분리 및 모듈화** (`codeselect.py` 단일 파일 → 다중 모듈) +- `codeselect.py`가 너무 비대함 → 기능별 모듈로 분리 +- 📂 **새로운 모듈 구조** + - `filetree.py`: 파일 트리 및 탐색 기능 + - `selector.py`: curses 기반 파일 선택 UI + - `output.py`: 다양한 포맷(txt, md, llm)으로 저장 기능 + - `cli.py`: CLI 명령어 및 옵션 처리 + - `dependency.py`: 프로젝트 내 파일 간 의존성 분석 + +~~## 🔧 `.gitignore` 기반 파일 필터링 지원~~ +✅ **`.gitignore` 파일 자동 파싱 및 필터링** +- 프로젝트 루트의 `.gitignore` 파일 자동 감지 +- 다양한 패턴 타입 지원: + - 와일드카드 패턴 (`*.log`) + - 디렉토리 특정 패턴 (`ignored_dir/`) + - 제외 패턴 (`!important.log`) +- `utils.py`에 패턴 로딩 및 파싱 기능 추가 +- 파일 경로 매칭 알고리즘 개선 +- 테스트 추가 (패턴 로딩, 파일 필터링) + +~~## 🔍 Vim 스타일 파일 검색 기능 추가~~ +✅ **Vim 스타일 검색 구현 (`/` 입력 후 검색)** +- `/` 키로 검색 모드 진입, 검색어 입력 후 Enter로 검색 실행 +- 정규 표현식 지원 (예: `/.*\.py$` → .py 파일만 검색) +- 대소문자 구분 토글 기능 (`^` 키 사용) +- 검색 결과에서 트리 구조 유지 + +✅ **Vim 스타일 네비게이션 구현** +- `j/k` 키로 위/아래 이동 +- `h/l` 키로 폴더 닫기/열기 +- 검색 모드에서 ESC 키로 전체 목록 복원 + +--- diff --git a/docs/kr/change_log.md b/docs/kr/change_log.md new file mode 100644 index 0000000..85a63ad --- /dev/null +++ b/docs/kr/change_log.md @@ -0,0 +1,95 @@ +# Change Log + +## v1.3.0 (2025-03-12) + +### 🚀 `.gitignore` 지원 기능 추가 +- `.gitignore` 파일 자동 인식 및 패턴 처리 기능 구현 +- 다양한 `.gitignore` 패턴 지원 + - 와일드카드 패턴 (`*.log`) + - 디렉토리 특정 패턴 (`ignored_dir/`) + - 제외 패턴 (`!important.log`) +- 기존 하드코딩 된 무시 목록에 .gitignore 패턴 통합 + +### 💻 파일 필터링 개선 +- 파일 경로 비교 알고리즘 향상 +- 전체 경로와 기본 이름 모두 패턴 매칭 지원 +- 하위 디렉토리 내 파일에 대한 필터링 정확도 향상 + +### 🧪 테스트 +- `.gitignore` 관련 단위 테스트 추가 +- 패턴 로딩 기능 테스트 +- 파일 필터링 정확도 테스트 + +## v1.2.0 (2025-03-12) + +### 🏗 코드 구조 개선 +- `selector.py` 모듈을 세 개의 모듈로 분리하여 가독성 및 유지보수성 향상 + - `selector_actions.py`: 파일 선택, 검색, 확장/축소 동작 관련 함수들 + - `selector_ui.py`: 사용자 인터페이스 관련 `FileSelector` 클래스 + - `selector.py`: 외부 인터페이스 역할의 `interactive_selection` 함수 + +### 💻 리팩토링 이점 +- 관심사 분리: UI 코드와 동작 로직 간의 명확한 분리 +- 테스트 용이성: 각 모듈을 독립적으로 테스트 가능 +- 확장성: 새로운 동작이나 UI 요소를 더 쉽게 추가 가능 + +### 🧪 테스트 +- 분리된 모든 모듈에 대한 단위 테스트 추가 +- 기존 기능과의 호환성 유지 확인 + +### 📖 문서화 +- 프로젝트 구조 문서 업데이트 +- 설계 개요 문서에 모듈 분리 내용 반영 + +## v1.1.0 (2024-03-12) + +### 🔍 Vim 스타일 검색 기능 추가 +- `/` 키를 통한 검색 모드 지원 (Vim 스타일) +- 정규 표현식 검색 완벽 지원 (예: `/.*\.py$`, `/test_.*`) +- 대소문자 구분 토글 기능 (`^` 키 사용) +- 검색 결과에서 트리 구조 유지 - 디렉토리 계층 표시 +- 검색 후 ESC 키로 전체 목록 복원 기능 +- 검색 결과에서 파일 선택/해제 기능 + +### 🚀 Vim 스타일 네비게이션 추가 +- `j` / `k` 키로 위아래 이동 +- `h` / `l` 키로 폴더 닫기/열기 (및 부모 디렉토리로 이동) +- 기존 화살표 키 네비게이션과 병행 지원 + +### 🎨 UI 개선 +- 검색 결과 상태 표시 개선 (현재 표시된 파일 수/전체 파일 수) +- 검색 모드에서 상태 표시줄 변경 +- 검색 결과가 없을 경우 알림 표시 + +### 💻 품질 개선 +- 트리 구조 유지 알고리즘 개선 +- 검색 취소/완료 시 상태 관리 최적화 +- 오류 처리 강화 (잘못된 정규식 입력 시 에러 표시) + +## v1.0.0 (2024-03-11) + +### 🏗 Code Structure Improvements +- CodeSelect has been modularized for better maintainability and future extensibility +- Separated monolithic codeselect.py into focused modules: + - `utils.py`: Common utility functions + - `filetree.py`: File tree structure management + - `selector.py`: Interactive file selection UI + - `output.py`: Output format management + - `dependency.py`: Project dependency analysis + - `cli.py`: Command line interface + - `codeselect.py`: Simple entry point script + +### 🔧 Refactoring +- Improved code organization with proper separation of concerns +- Better isolation of functionality into single-responsibility modules +- Enhanced readability through clear module boundaries +- No functional changes to existing behavior + +### 🧪 Testing +- Added unit tests for all new modules +- Test coverage for core functionality + +### 📖 Documentation +- Updated project_structure.md to reflect new modular architecture +- Added detailed documentation to each module +- Included Korean comments for core functionality \ No newline at end of file diff --git a/docs/kr/design_overview.md b/docs/kr/design_overview.md new file mode 100644 index 0000000..98c357c --- /dev/null +++ b/docs/kr/design_overview.md @@ -0,0 +1,83 @@ +# 설계 개요 + +## 🎯 핵심 설계 원칙 +1. **간결성(Simplicity)**: 사용자가 명령어 하나로 프로젝트 내 파일을 선택하고 쉽게 공유할 수 있어야 합니다. +2. **직관성(Interactivity)**: Curses 기반 UI를 제공하여 파일 선택을 직관적으로 수행할 수 있도록 합니다. +3. **확장성(Extensibility)**: 다양한 파일 선택 방식 및 출력 포맷을 추가할 수 있도록 설계합니다. +4. **최소 의존성(Minimal Dependencies)**: 표준 라이브러리만을 사용하여 추가 설치 없이 실행 가능하도록 합니다. +5. **친숙한 UX(Familiar UX)**: Vim과 같은 널리 알려진 도구의 조작 방식을 차용하여 학습 곡선을 최소화합니다. +6. **관심사 분리(Separation of Concerns)**: 모듈 간 명확한 책임 분리를 통해 유지보수성과 확장성을 향상시킵니다. + +## 🏛 시스템 아키텍처 +CodeSelect는 크게 **파일 트리 생성기**, **인터랙티브 파일 선택기**, **출력 생성기** 세 가지 주요 모듈로 구성됩니다. + +### 📂 주요 모듈 +1. **파일 트리 생성기 (`build_file_tree`)** + - 프로젝트 디렉터리를 스캔하여 파일 트리를 생성합니다. + - `.gitignore` 파일을 자동으로 파싱하여 패턴을 추출하고 불필요한 파일을 필터링합니다. + - 다양한 `.gitignore` 패턴(와일드카드, 디렉토리 특정, 제외 패턴)을 해석하여 적용합니다. + - 하드코딩 된 기본 무시 패턴(`.git`, `__pycache__` 등)과 `.gitignore` 패턴을 결합하여 사용합니다. + - 내부적으로 `os.walk()`를 활용하여 디렉터리 구조를 순회합니다. + +2. **인터랙티브 파일 선택기** + - **파일 선택 인터페이스 (`selector.py`)** + - `interactive_selection` 함수를 통해 선택 인터페이스 초기화 및 실행 + - **UI 컴포넌트 (`selector_ui.py`)** + - `FileSelector` 클래스로 curses 기반 UI 구현 + - 화면 그리기, 키 입력 처리, 사용자 인터페이스 로직 포함 + - **동작 관리 (`selector_actions.py`)** + - 파일 선택, 검색, 확장/축소 등의 동작 관련 함수 제공 + - `toggle_selection`, `apply_search_filter` 등의 핵심 기능 구현 + +3. **출력 생성기 (`write_output_file`)** + - 선택된 파일을 지정된 형식(`txt`, `md`, `llm`)으로 변환하여 저장합니다. + - 파일 간의 종속성을 분석하여 LLM이 이해하기 쉬운 방식으로 구조화합니다. + - 필요할 경우 클립보드에 자동으로 복사하여 빠르게 공유할 수 있도록 합니다. + +## 🔄 데이터 흐름 +``` +사용자 실행 → 디렉터리 스캔 → 파일 선택 UI → 선택된 파일 수집 → 파일 저장 및 출력 +``` +1. **사용자 실행**: `codeselect` 명령어 실행 +2. **디렉터리 스캔**: 프로젝트의 전체 파일 목록을 분석 +3. **파일 선택 UI**: 사용자가 curses UI에서 파일 선택 (탐색, 검색, 필터링) +4. **선택된 파일 수집**: `collect_selected_content`를 통해 필요한 파일을 수집 +5. **파일 저장 및 출력**: 선택된 파일을 변환하여 저장하거나 클립보드로 복사 + +## 🔍 검색 및 필터링 설계 +검색 기능은 다음과 같은 흐름으로 설계되었습니다: + +1. **검색 모드 진입**: `/` 키를 통해 검색 모드 활성화 +2. **정규식 지원**: 사용자 입력을 정규식으로 처리하여 강력한 필터링 지원 +3. **트리 구조 유지**: 검색 결과에서도 디렉토리 계층 구조 표시 + - 일치하는 파일의 모든 부모 디렉토리가 표시됨 +4. **필터링 해제**: ESC 키를 통해 전체 목록으로 복원 + +## 🔄 모듈 간 상호작용 +파일 선택 모듈 간의 상호작용은 다음과 같이 이루어집니다: + +1. **외부 인터페이스 (`selector.py`)** + - `interactive_selection` 함수를 통해 UI 초기화 및 실행 + - `curses.wrapper`를 사용하여 터미널 환경 설정 + +2. **UI 모듈 (`selector_ui.py`)** + - `FileSelector` 클래스가 사용자와의 상호작용 담당 + - 키 입력을 받아서 적절한 액션 함수 호출 + +3. **액션 모듈 (`selector_actions.py`)** + - UI 모듈에서 요청한 동작 수행 (선택, 검색, 확장/축소) + - 동작 결과를 UI 모듈에 반환하여 화면에 반영 + +## ⚙️ 설계 고려 사항 +- **성능 최적화**: 대규모 프로젝트에서도 빠르게 파일을 탐색할 수 있도록 `os.walk()` 최적화. +- **확장 가능성**: 향후 다양한 프로젝트 구조를 지원할 수 있도록 모듈화된 구조 유지. +- **사용자 경험 개선**: 직관적인 UI 제공 및 불필요한 파일 자동 필터링. +- **Vim 사용자 친화적**: 널리 사용되는 Vim의 키 바인딩을 차용하여 학습 곡선 낮춤. +- **관심사 분리**: UI와 동작 로직 분리를 통한 코드 가독성 및 유지보수성 향상 + +## 🔍 향후 개선 사항 +- **고급 필터링 옵션 추가**: 특정 확장자 포함/제외 옵션 지원 +- **프로젝트 의존성 분석 심화**: `import` 및 `require` 관계를 더 정확하게 분석 +- **다양한 출력 포맷 지원**: JSON, YAML 등의 추가 지원 고려 +- **검색 기록 관리**: 이전 검색어 저장 및 쉬운 접근 지원 +- **플러그인 시스템**: 사용자 정의 동작을 추가할 수 있는 플러그인 아키텍처 도입 검토 \ No newline at end of file diff --git a/docs/kr/project_structure.md b/docs/kr/project_structure.md new file mode 100644 index 0000000..621d64e --- /dev/null +++ b/docs/kr/project_structure.md @@ -0,0 +1,84 @@ +# 📂 프로젝트 구조 (`codeselect`) + +## 🏗️ **폴더 및 파일 개요** +``` +codeselect/ + ├── codeselect.py # 메인 실행 스크립트 (CLI 진입점) + ├── cli.py # CLI 명령어 처리 및 실행 흐름 제어 + ├── filetree.py # 파일 트리 탐색 및 계층 구조 관리 + ├── selector.py # 파일 선택 인터페이스 (진입점 역할) + ├── selector_ui.py # curses 기반 UI 구현 (FileSelector 클래스) + ├── selector_actions.py # 파일 선택 관련 동작 함수 모음 + ├── output.py # 선택된 파일의 출력 (txt, md, llm 지원) + ├── dependency.py # 파일 간 의존성 분석 (import/include 탐색) + ├── utils.py # 공통 유틸리티 함수 (경로 처리, 클립보드 복사 등) + ├── install.sh # 프로젝트 설치 스크립트 + ├── uninstall.sh # 프로젝트 제거 스크립트 + ├── tests/ # 유닛 테스트 폴더 + │ ├── test_filetree.py # 파일 트리 생성 테스트 + │ ├── test_selector.py # 파일 선택 인터페이스 테스트 + │ ├── test_selector_actions.py # 파일 선택 액션 테스트 + │ ├── test_selector_ui.py # UI 컴포넌트 테스트 + │ └── test_dependency.py # 의존성 분석 테스트 + ├── docs/ # 문서화 폴더 (설계 개요, 사용법 등) +``` + +## 🛠️ **핵심 모듈 설명** + +### 1️⃣ `codeselect.py` (프로그램 실행 진입점) +- `cli.py`를 호출하여 프로그램을 실행 +- `argparse`로 CLI 옵션을 파싱 후, `filetree.py`에서 파일을 탐색하고 `selector.py`로 선택 UI 실행 + +### 2️⃣ `cli.py` (CLI 명령어 및 실행 흐름 관리) +- 명령어 인수(`--format`, `--skip-selection` 등)를 처리 +- `filetree.build_file_tree()`를 호출하여 파일 목록 생성 +- `selector.interactive_selection()`을 실행해 UI에서 파일 선택 +- `dependency.analyze_dependencies()`를 호출해 종속성 분석 수행 +- 최종적으로 `output.write_output_file()`로 결과 저장 + +### 3️⃣ `filetree.py` (파일 트리 탐색 및 관리) +- `build_file_tree(root_path)`: 디렉토리 내부 파일 및 폴더를 계층적으로 분석하여 트리 구조 생성 +- `flatten_tree(node)`: 트리를 리스트로 변환해 UI에서 쉽게 탐색 가능하도록 변환 + +### 4️⃣ 파일 선택 모듈 (분리된 세 개의 파일) +#### a. `selector.py` (외부 인터페이스) +- `interactive_selection(root_node)`: curses 환경 초기화 및 FileSelector 실행 +- 간단한 진입점 역할로 외부 모듈과의 인터페이스 제공 + +#### b. `selector_ui.py` (UI 컴포넌트) +- `FileSelector` 클래스: curses 기반 인터랙티브 UI 구현 +- 화면 그리기, 키 입력 처리, 사용자 인터페이스 로직 포함 +- `run()`: 선택 인터페이스 실행 루프 +- `draw_tree()`: 파일 트리 시각화 +- `process_key()`: 키 입력 처리 + +#### c. `selector_actions.py` (액션 함수) +- `toggle_selection(node)`: 파일/폴더 선택 상태 전환 +- `toggle_expand(node)`: 디렉토리 확장/축소 +- `apply_search_filter()`: 검색 필터 적용 +- `select_all()`: 모든 파일 선택/해제 +- `toggle_current_dir_selection()`: 현재 디렉토리 내 파일만 선택/해제 + +### 5️⃣ `dependency.py` (의존성 분석) +- `analyze_dependencies(root_path, file_contents)`: `import`, `require`, `include` 패턴을 분석하여 파일 간 참조 관계를 추출 +- Python, JavaScript, C/C++ 등의 언어를 지원 + +### 6️⃣ `output.py` (출력 파일 저장) +- `write_output_file(output_path, format)`: 선택된 파일을 다양한 형식(txt, md, llm)으로 변환하여 저장 +- `llm` 포맷은 AI 모델이 이해하기 쉬운 구조로 가공 + +### 7️⃣ `utils.py` (유틸리티 함수) +- `generate_output_filename(root_path, format)`: 출력 파일명을 자동 생성 +- `try_copy_to_clipboard(content)`: 선택된 파일 내용을 클립보드에 복사 +- `load_gitignore_patterns(directory)`: .gitignore 파일에서 패턴을 로드하고 파싱 +- `should_ignore_path(path, ignore_patterns)`: 파일 경로가 무시 패턴과 일치하는지 확인 + +--- +## 🚀 **실행 흐름 요약** +1️⃣ `codeselect.py` 실행 → `cli.py`에서 인자 파싱 +2️⃣ `filetree.py`에서 파일 트리 생성 +3️⃣ `selector.py`에서 curses 환경 초기화 +4️⃣ `selector_ui.py`의 `FileSelector` 클래스가 인터페이스 제공 +5️⃣ `selector_actions.py`의 함수들로 사용자 동작 처리 +6️⃣ `dependency.py`에서 파일 간 의존성 분석 +7️⃣ `output.py`에서 선택된 파일을 저장 및 클립보드 복사 \ No newline at end of file diff --git a/docs/kr/task-log/done/gitignore-support.md b/docs/kr/task-log/done/gitignore-support.md new file mode 100644 index 0000000..1afbdb4 --- /dev/null +++ b/docs/kr/task-log/done/gitignore-support.md @@ -0,0 +1,111 @@ +# `.gitignore` 지원 기능 구현 + +## 📝 작업 개요 +프로젝트에서 `.gitignore` 파일을 자동으로 파싱하여 해당 패턴에 맞는 파일과 디렉토리를 파일 트리 구성 시 제외하는 기능을 구현했습니다. + +## 🛠️ 구현 내용 + +### 1. `.gitignore` 패턴 로딩 기능 (`utils.py`) +- `load_gitignore_patterns(directory)` 함수 추가 +- `.gitignore` 파일에서 유효한 패턴만 추출 (주석 및 빈 줄 제외) +- 파일이 존재하지 않을 경우 빈 리스트 반환 + +```python +def load_gitignore_patterns(directory): + """ + Reads `.gitignore` file and returns a list of valid ignore patterns. + + Args: + directory (str): The directory containing the .gitignore file. + + Returns: + list: List of ignore patterns from the .gitignore file. + """ + gitignore_path = os.path.join(directory, ".gitignore") + if not os.path.isfile(gitignore_path): + return [] + + patterns = [] + with open(gitignore_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + # Skip empty lines and comments + if line and not line.startswith("#"): + patterns.append(line) + + return patterns +``` + +### 2. 패턴 매칭 알고리즘 개선 (`utils.py`) +- `should_ignore_path` 함수 개선 +- `.gitignore` 스타일 패턴 지원: + - 제외 패턴 (`!pattern`) + - 디렉토리 특정 패턴 (`dir/`) + - 와일드카드 패턴 (`*.log`) +- 파일 이름 및 전체 경로 매칭 지원 + +```python +def should_ignore_path(path, ignore_patterns=None): + """ + Checks if the given path matches a pattern that should be ignored. + Implements basic .gitignore style pattern matching. + + Args: + path (str): The path to the file or directory to check. + ignore_patterns (list): List of patterns to ignore (default: None) + + Returns: + Bool: True if the path should be ignored, False otherwise. + """ + # 패턴 처리 로직 구현 + # ... +``` + +### 3. 파일 트리 생성 시 `.gitignore` 통합 (`filetree.py`) +- `build_file_tree` 함수 개선 +- 기본 무시 패턴과 `.gitignore` 패턴 결합 +- 전체 경로 기반 필터링으로 변경 + +```python +def build_file_tree(root_path, ignore_patterns=None): + """ + Constructs a tree representing the file structure. + + Args: + root_path (str): Path to the root directory. + ignore_patterns (list, optional): List of patterns to ignore. + + Returns: + Node: the root node of the file tree. + """ + # 기본 패턴 정의 + default_patterns = ['.git', '__pycache__', '*.pyc', '.DS_Store', '.idea', '.vscode'] + + # .gitignore 패턴 로드 + gitignore_patterns = load_gitignore_patterns(root_path) + + # 패턴 결합 + if ignore_patterns is None: + ignore_patterns = default_patterns + gitignore_patterns + else: + ignore_patterns = ignore_patterns + gitignore_patterns + + # 파일 필터링 로직 + # ... +``` + +### 4. 테스트 케이스 추가 +- `.gitignore` 패턴 로딩 테스트 (`test_utils.py`) +- 파일 필터링 동작 테스트 (`test_filetree.py`) +- 다양한 패턴 유형에 대한 테스트 + +## 📊 개선 효과 +1. **자동화된 파일 필터링**: 사용자가 별도의 설정 없이 프로젝트의 `.gitignore` 규칙을 자동으로 적용 +2. **정확한 파일 경로 매칭**: 전체 경로 및 파일 이름 기반 매칭으로 필터링 정확도 향상 +3. **다양한 패턴 지원**: 여러 패턴 유형을 지원하여 유연한 파일 필터링 가능 +4. **코드 가독성 향상**: 패턴 로딩 및 매칭 로직을 별도 함수로 분리하여 유지보수성 개선 + +## 🔍 후속 개선 사항 +- 하위 디렉토리의 `.gitignore` 파일도 지원 (Git 원래 동작 방식과 유사하게) +- 패턴 매칭 성능 최적화 (대규모 프로젝트에서의 속도 개선) +- CLI를 통한 `--include`/`--exclude` 옵션 구현 diff --git a/docs/kr/task-log/done/refactor-module.md b/docs/kr/task-log/done/refactor-module.md new file mode 100644 index 0000000..d87cb3c --- /dev/null +++ b/docs/kr/task-log/done/refactor-module.md @@ -0,0 +1,83 @@ +# CodeSelect 모듈화 작업 계획 (완료) + +## 완료된 작업 +- ✅ **utils.py**: 공통 유틸리티 함수 분리 (2025-03-10 완료) + - `get_language_name()`: 확장자를 언어명으로 변환 + - `try_copy_to_clipboard()`: 클립보드 복사 기능 + - `generate_output_filename()`: 출력 파일명 생성 + - `should_ignore_path()`: 무시할 경로 확인 +- ✅ **filetree.py**: 파일 트리 구조 관리 (2025-03-10 완료) + - `Node` 클래스: 파일/디렉토리 노드 표현 + - `build_file_tree()`: 주어진 디렉토리의 파일 구조를 트리로 구성 + - `flatten_tree()`: 트리를 평탄화하여 UI 표시용 노드 목록으로 변환 + - `count_selected_files()`: 선택된 파일 수 계산 + - `collect_selected_content()`: 선택된 파일들의 내용 수집 + - `collect_all_content()`: 모든 파일의 내용 수집 (skip-selection 옵션용) +- ✅ **selector.py**: 파일 선택 UI (2025-03-10 완료) + - `FileSelector` 클래스: curses 기반 대화형 파일 선택 UI + - `interactive_selection()`: 선택 인터페이스 실행 함수 +- ✅ **output.py**: 출력 형식 관리 (2025-03-11 완료) + - `write_file_tree_to_string()`: 파일 트리를 문자열로 변환 + - `write_output_file()`: 여러 형식의 출력 파일 생성 + - `write_markdown_output()`: 마크다운 형식 출력 + - `write_llm_optimized_output()`: LLM 최적화 형식 출력 +- ✅ **dependency.py**: 의존성 분석 (2025-03-11 완료) + - `analyze_dependencies()`: 파일 간 의존성 분석 +- ✅ **cli.py**: 명령행 인터페이스 (2025-03-11 완료) + - `parse_arguments()`: 명령행 인수 처리 + - `main()`: 메인 실행 함수 + +## 테스트 코드 +- ✅ **test/test_utils.py**: utils.py 기능 테스트 (2025-03-10 완료) +- ✅ **test/test_filetree.py**: filetree.py 기능 테스트 (2025-03-10 완료) +- ✅ **test/test_selector.py**: selector.py 기능 테스트 (2025-03-10 완료) +- ✅ **test/test_output.py**: output.py 기능 테스트 (2025-03-11 완료) +- ✅ **test/test_dependency.py**: dependency.py 기능 테스트 (2025-03-11 완료) +- ✅ **test/test_cli.py**: cli.py 기능 테스트 (2025-03-11 완료) + +## 남은 작업 및 파일 구조 +``` +codeselect/ +├── codeselect.py # 메인 실행 파일 +├── utils.py # 완료: 공통 유틸리티 함수 +├── filetree.py # 완료: 파일 트리 구조 관리 +├── selector.py # 완료: 파일 선택 UI +├── output.py # 완료: 출력 형식 관리 +├── dependency.py # 완료: 의존성 분석 +└── cli.py # 완료: 명령행 인터페이스 +``` + +## 변환 작업 상세 +1. **utils.py** ✅ + - `get_language_name()` 함수 + - `try_copy_to_clipboard()` 함수 + - `generate_output_filename()` 함수 + - `should_ignore_path()` 함수 (추가) + +2. **filetree.py** ✅ + - `Node` 클래스 + - `build_file_tree()` 함수 + - `flatten_tree()` 함수 + - `count_selected_files()` 함수 + - `collect_selected_content()` 함수 + - `collect_all_content()` 함수 + +3. **selector.py** ✅ + - `FileSelector` 클래스 + - `interactive_selection()` 함수 + +4. **output.py** ✅ + - `write_file_tree_to_string()` 함수 + - `write_output_file()` 함수 + - `write_markdown_output()` 함수 + - `write_llm_optimized_output()` 함수 + +5. **dependency.py** ✅ + - `analyze_dependencies()` 함수 + +6. **cli.py** ✅ + - 명령행 인수 처리 (`argparse` 관련 코드) + - `main()` 함수 리팩토링 + +7. **codeselect.py** ✅ + - 모듈들을 임포트하고 조합하는 간결한 메인 스크립트로 변환 \ No newline at end of file diff --git a/filetree.py b/filetree.py new file mode 100644 index 0000000..6dff8d1 --- /dev/null +++ b/filetree.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +filetree.py - File tree structure management module + +This module provides functionality to create and manage file tree structures. +""" + +import os +import sys +import fnmatch +from utils import should_ignore_path, load_gitignore_patterns + +class Node: + """ + Classes that represent nodes in a file tree + + Represents a file or directory, which, if a directory, can have child nodes. + """ + def __init__(self, name, is_dir, parent=None): + """ + Initialise Node Class + + Args: + name (str): Name of the node (file/directory name) + is_dir (bool): Whether it is a directory + parent (Node, optional): Parent node + """ + self.name = name + self.is_dir = is_dir + self.children = {} if is_dir else None + self.parent = parent + self.selected = True # 기본적으로 선택됨 + self.expanded = True # 폴더는 기본적으로 확장됨 + + @property + def path(self): + """ + Returns the full path to the node. + + Returns: + str: the full path of the node + """ + if self.parent is None: + return self.name + parent_path = self.parent.path + if parent_path.endswith(os.sep): + return parent_path + self.name + return parent_path + os.sep + self.name + +def build_file_tree(root_path, ignore_patterns=None): + """ + Constructs a tree representing the file structure. + + Args: + root_path (str): Path to the root directory. + ignore_patterns (list, optional): List of patterns to ignore. + + Returns: + Node: the root node of the file tree. + """ + # Default patterns to ignore + default_patterns = ['.git', '__pycache__', '*.pyc', '.DS_Store', '.idea', '.vscode'] + + # Load patterns from .gitignore if it exists + gitignore_patterns = load_gitignore_patterns(root_path) + + # Combine ignore patterns, with .gitignore patterns taking precedence + if ignore_patterns is None: + ignore_patterns = default_patterns + gitignore_patterns + else: + ignore_patterns = ignore_patterns + gitignore_patterns + + def should_ignore(path): + """ + Checks if the given path matches a pattern that should be ignored. + + Args: + path (str): The path to check. + + Returns: + bool: True if it should be ignored, False otherwise + """ + return should_ignore_path(path, ignore_patterns) + + root_name = os.path.basename(root_path.rstrip(os.sep)) + if not root_name: # 루트 디렉토리 경우 + root_name = root_path + + root_node = Node(root_name, True) + root_node.full_path = root_path # 루트 노드에 절대 경로 저장 + + def add_path(current_node, path_parts, full_path): + """ + Adds each part of the path to the tree. + + Args: + current_node (Node): Current node + path_parts (list): List of path parts + full_path (str): Full path + """ + if not path_parts: + return + + part = path_parts[0] + remaining = path_parts[1:] + + if should_ignore(os.path.join(full_path, part)): + return + + # 이미 부분이 존재하는지 확인 + if part in current_node.children: + child = current_node.children[part] + else: + is_dir = os.path.isdir(os.path.join(full_path, part)) + child = Node(part, is_dir, current_node) + current_node.children[part] = child + + # 남은 부분이 있으면 재귀적으로 계속 + if remaining: + next_path = os.path.join(full_path, part) + add_path(child, remaining, next_path) + + # 디렉토리 구조 순회 + for dirpath, dirnames, filenames in os.walk(root_path): + # 필터링된 디렉토리 건너뛰기 + dirnames[:] = [d for d in dirnames if not should_ignore(os.path.join(dirpath, d))] + + rel_path = os.path.relpath(dirpath, root_path) + if rel_path == '.': + # 루트에 있는 파일 추가 + for filename in filenames: + full_path = os.path.join(dirpath, filename) + if filename not in root_node.children and not should_ignore(full_path): + file_node = Node(filename, False, root_node) + root_node.children[filename] = file_node + else: + # 디렉토리 추가 + path_parts = rel_path.split(os.sep) + add_path(root_node, path_parts, root_path) + + # 이 디렉토리에 있는 파일 추가 + current = root_node + for part in path_parts: + if part in current.children: + current = current.children[part] + else: + # 디렉토리가 필터링된 경우 건너뛰기 + break + else: + for filename in filenames: + full_path = os.path.join(dirpath, filename) + if not should_ignore(full_path) and filename not in current.children: + file_node = Node(filename, False, current) + current.children[filename] = file_node + + return root_node + +def flatten_tree(node, visible_only=True): + """ + Flattens the tree into a list of nodes for navigation. + + Args: + node (Node): Root node + visible_only (bool, optional): Whether to include only visible nodes. + + Returns: + list: a list of (node, level) tuples. + """ + flat_nodes = [] + + def _traverse(node, level=0): + """ + Traverse the tree and generate a flattened list of nodes. + + Args: + node (Node): The current node + level (int, optional): Current level + """ + # 루트 노드는 건너뛰되, 루트의 자식부터는 level 0으로 시작 + if node.parent is not None: # 루트 노드 건너뛰기 + flat_nodes.append((node, level)) + + if node.is_dir and node.children and (not visible_only or node.expanded): + # 먼저 디렉토리, 그 다음 파일, 알파벳 순으로 정렬 + items = sorted(node.children.items(), + key=lambda x: (not x[1].is_dir, x[0].lower())) + + for _, child in items: + # 루트의 직계 자식들은 level 0, 그 아래부터는 level+1 + next_level = 0 if node.parent is None else level + 1 + _traverse(child, next_level) + + _traverse(node) + return flat_nodes + +def count_selected_files(node): + """ + Count the number of selected files (excluding directories). + + Args: + node (Node): The root node. + + Returns: + int: Number of selected files + """ + count = 0 + if not node.is_dir and node.selected: + count = 1 + elif node.is_dir and node.children: + for child in node.children.values(): + count += count_selected_files(child) + return count + +def collect_selected_content(node, root_path): + """ + Gather the contents of the selected files. + + Args: + node (Node): Root node + root_path (str): Root directory path + + Returns: + list: a list of (file path, content) tuples. + """ + results = [] + + if not node.is_dir and node.selected: + file_path = node.path + + # 수정: 루트 경로가 중복되지 않도록 보장 + if node.parent and node.parent.parent is None: + # 노드가 루트 바로 아래에 있으면 파일 이름만 사용 + full_path = os.path.join(root_path, node.name) + else: + # 중첩된 파일의 경우 적절한 상대 경로 구성 + rel_path = file_path + if file_path.startswith(os.path.basename(root_path) + os.sep): + rel_path = file_path[len(os.path.basename(root_path) + os.sep):] + full_path = os.path.join(root_path, rel_path) + + try: + with open(full_path, 'r', encoding='utf-8') as f: + content = f.read() + results.append((file_path, content)) + except UnicodeDecodeError: + print(f"이진 파일 무시: {file_path}") + except Exception as e: + print(f"{full_path} 읽기 오류: {e}") + elif node.is_dir and node.children: + for child in node.children.values(): + results.extend(collect_selected_content(child, root_path)) + + return results + +def collect_all_content(node, root_path): + """ + Collect the contents of all files (for analysis). + + Args: + node (Node): Root node + root_path (str): Root directory path + + Returns: + list: a list of (file path, content) tuples. + """ + results = [] + + if not node.is_dir: + file_path = node.path + + # 수정: collect_selected_content와 동일한 경로 수정 적용 + if node.parent and node.parent.parent is None: + full_path = os.path.join(root_path, node.name) + else: + rel_path = file_path + if file_path.startswith(os.path.basename(root_path) + os.sep): + rel_path = file_path[len(os.path.basename(root_path) + os.sep):] + full_path = os.path.join(root_path, rel_path) + + try: + with open(full_path, 'r', encoding='utf-8') as f: + content = f.read() + results.append((file_path, content)) + except UnicodeDecodeError: + pass # 이진 파일 조용히 무시 + except Exception: + pass # 오류 조용히 무시 + elif node.is_dir and node.children: + for child in node.children.values(): + results.extend(collect_all_content(child, root_path)) + + return results diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 4a03c54..a2d38e6 --- a/install.sh +++ b/install.sh @@ -9,20 +9,34 @@ echo "Installing CodeSelect..." USER_BIN="$HOME/.local/bin" mkdir -p "$USER_BIN" -# Create CodeSelect file -CODESELECT_PATH="$USER_BIN/codeselect" +# Create CodeSelect directory +CODESELECT_DIR="$HOME/.local/lib/codeselect" +mkdir -p "$CODESELECT_DIR" + +# 필요한 모듈 파일 다운로드 또는 복사 +echo "Installing CodeSelect modules..." +MODULES=("codeselect.py" "cli.py" "utils.py" "filetree.py" "selector.py" "selector_ui.py" "selector_actions.py" "output.py" "dependency.py") + +for MODULE in "${MODULES[@]}"; do + echo "Installing $MODULE..." + curl -fsSL "https://raw.githubusercontent.com/oodmumc3/codeselect/main/$MODULE" -o "$CODESELECT_DIR/$MODULE" 2>/dev/null || { + # curl이 실패하면 로컬 파일에서 복사 + if [ -f "$MODULE" ]; then + cp "$MODULE" "$CODESELECT_DIR/$MODULE" + else + echo "Error: Cannot download or find $MODULE" + exit 1 + fi + } +done -# Download or create the file -echo "Downloading CodeSelect..." -curl -fsSL https://raw.githubusercontent.com/maynetee/codeselect/main/codeselect.py -o "$CODESELECT_PATH" 2>/dev/null || { - # If curl fails (e.g., GitHub URL not yet available), copy from the local file - if [ -f "codeselect.py" ]; then - cp "codeselect.py" "$CODESELECT_PATH" - else - echo "Error: Cannot download or find codeselect.py" - exit 1 - fi -} +# Create executable wrapper script +CODESELECT_PATH="$USER_BIN/codeselect" +cat > "$CODESELECT_PATH" << 'EOF' +#!/bin/bash +SCRIPT_DIR="$HOME/.local/lib/codeselect" +python3 "$SCRIPT_DIR/codeselect.py" "$@" +EOF # Make the script executable chmod +x "$CODESELECT_PATH" @@ -39,14 +53,24 @@ if [[ ":$PATH:" != *":$USER_BIN:"* ]]; then else SHELL_CONFIG="$HOME/.bashrc" fi + elif [[ "$SHELL" == *"fish"* ]]; then + FISH_CONFIG_DIR="$HOME/.config/fish" + mkdir -p "$FISH_CONFIG_DIR" + SHELL_CONFIG="$FISH_CONFIG_DIR/config.fish" + # Fish 쉘은 다른 문법을 사용합니다 + echo "set -gx PATH \$HOME/.local/bin \$PATH" >> "$SHELL_CONFIG" + echo "Added $USER_BIN to your PATH in $SHELL_CONFIG" + echo "To use immediately, run: source $SHELL_CONFIG" else SHELL_CONFIG="$HOME/.profile" fi - # Add to PATH - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_CONFIG" - echo "Added $USER_BIN to your PATH in $SHELL_CONFIG" - echo "To use immediately, run: source $SHELL_CONFIG" + # Add to PATH (fish 쉘이 아닌 경우) + if [[ "$SHELL" != *"fish"* ]]; then + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_CONFIG" + echo "Added $USER_BIN to your PATH in $SHELL_CONFIG" + echo "To use immediately, run: source $SHELL_CONFIG" + fi fi echo " @@ -57,7 +81,13 @@ Usage: codeselect /path/to/project # Analyze a specific directory codeselect --help # Show help +Features: + - Automatically respects .gitignore patterns in your project + - Interactive file selection with tree view + - Multiple output formats for different AI assistants + CodeSelect is now installed at: $CODESELECT_PATH +All modules installed at: $CODESELECT_DIR " # Try to add tab completion for bash diff --git a/output.py b/output.py new file mode 100644 index 0000000..ce95b39 --- /dev/null +++ b/output.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +CodeSelect - Output module + +This module provides the ability to output the selected file tree and its contents in various formats. +It supports the following output formats +- txt: Basic text format +- MD: GitHub-compatible markdown format +- LLM: Language model optimised format +""" + +import os + +def write_file_tree_to_string(node, prefix='', is_last=True): + """ + 파일 트리 구조를 문자열로 변환합니다. + + Args: + node: 현재 노드 + prefix: 들여쓰기 접두사 + is_last: 현재 노드가 부모의 마지막 자식인지 여부 + + Returns: + str: 파일 트리 문자열 표현 + --- + Converts a file tree structure to a string. + + Args: + node: the current node + prefix: Indentation prefix + is_last: whether the current node is the last child of the parent + + Returns: + str: File tree string representation + """ + result = "" + + if node.parent is not None: # 루트 노드는 건너뜀 + branch = "└── " if is_last else "├── " + result += f"{prefix}{branch}{node.name}\n" + + if node.is_dir and node.children: + items = sorted(node.children.items(), + key=lambda x: (not x[1].is_dir, x[0].lower())) + + for i, (_, child) in enumerate(items): + is_last_child = i == len(items) - 1 + new_prefix = prefix + (' ' if is_last else '│ ') + result += write_file_tree_to_string(child, new_prefix, is_last_child) + + return result + +def write_output_file(output_path, root_path, root_node, file_contents, output_format='txt', dependencies=None): + """ + 파일 트리와 선택된 내용을 출력 파일에 작성합니다. + + Args: + output_path: 출력 파일 경로 + root_path: 프로젝트 루트 경로 + root_node: 파일 트리 루트 노드 + file_contents: 파일 내용 목록 [(경로, 내용), ...] + output_format: 출력 형식 ('txt', 'md', 'llm') + dependencies: 파일 간 의존성 정보 (llm 형식에 필요) + + Returns: + str: 출력 파일 경로 + + --- + Write the file tree and selections to the output file. + + Args: + output_path: Path to the output file + root_path: Project root path + root_node: File tree root node + file_contents: List of file contents [(path, contents), ...]. + output_format: Output format (‘txt’, ‘md’, ‘llm’) + dependencies: Dependency information between files (required for llm format) + + Returns: + str: path to output file + """ + if output_format == 'md': + write_markdown_output(output_path, root_path, root_node, file_contents) + elif output_format == 'llm': + write_llm_optimized_output(output_path, root_path, root_node, file_contents, dependencies) + else: + # 기본 txt 형식 + with open(output_path, 'w', encoding='utf-8') as f: + # 파일 트리 작성 + f.write("\n") + f.write(f"{root_path}\n") + + tree_str = write_file_tree_to_string(root_node) + f.write(tree_str) + + f.write("\n\n") + + # 파일 내용 작성 + f.write("\n") + for path, content in file_contents: + f.write(f"File: {path}\n") + f.write("```") + + # 확장자를 통한 구문 강조 결정 + ext = os.path.splitext(path)[1][1:].lower() + if ext: + f.write(ext) + + f.write("\n") + f.write(content) + if not content.endswith('\n'): + f.write('\n') + f.write("```\n\n") + + f.write("\n") + + return output_path + +def write_markdown_output(output_path, root_path, root_node, file_contents): + """ + GitHub 호환 마크다운 형식으로 출력합니다. + + Args: + output_path: 출력 파일 경로 + root_path: 프로젝트 루트 경로 + root_node: 파일 트리 루트 노드 + file_contents: 파일 내용 목록 [(경로, 내용), ...] + --- + Output in GitHub-compatible markdown format. + + Args: + output_path: Path to the output file + root_path: Project root path + root_node: File tree root node + file_contents: List of file contents [(path, content), ...] + """ + with open(output_path, 'w', encoding='utf-8') as f: + # 헤더 작성 + f.write(f"# Project Files: `{root_path}`\n\n") + + # 파일 구조 섹션 작성 + f.write("## 📁 File Structure\n\n") + f.write("```\n") + f.write(f"{root_path}\n") + f.write(write_file_tree_to_string(root_node)) + f.write("```\n\n") + + # 파일 내용 섹션 작성 + f.write("## 📄 File Contents\n\n") + + for path, content in file_contents: + f.write(f"### {path}\n\n") + + # 확장자 기반 구문 강조 추가 + ext = os.path.splitext(path)[1][1:].lower() + f.write(f"```{ext}\n") + f.write(content) + if not content.endswith('\n'): + f.write('\n') + f.write("```\n\n") + +def get_language_name(extension): + """ + 파일 확장자를 언어 이름으로 변환합니다. + + Args: + extension: 파일 확장자 + + Returns: + str: 해당 확장자의 프로그래밍 언어 이름 + """ + language_map = { + 'py': 'Python', + 'c': 'C', + 'cpp': 'C++', + 'h': 'C/C++ Header', + 'hpp': 'C++ Header', + 'js': 'JavaScript', + 'ts': 'TypeScript', + 'java': 'Java', + 'html': 'HTML', + 'css': 'CSS', + 'php': 'PHP', + 'rb': 'Ruby', + 'go': 'Go', + 'rs': 'Rust', + 'swift': 'Swift', + 'kt': 'Kotlin', + 'sh': 'Shell', + 'md': 'Markdown', + 'json': 'JSON', + 'xml': 'XML', + 'yaml': 'YAML', + 'yml': 'YAML', + 'sql': 'SQL', + 'r': 'R', + } + return language_map.get(extension, extension.upper()) + +def write_llm_optimized_output(output_path, root_path, root_node, file_contents, dependencies): + """ + LLM 분석에 최적화된 형식으로 출력합니다. + + Args: + output_path: 출력 파일 경로 + root_path: 프로젝트 루트 경로 + root_node: 파일 트리 루트 노드 + file_contents: 파일 내용 목록 [(경로, 내용), ...] + dependencies: 파일 간 의존성 정보 + --- + Output in a format optimised for LLM analysis. + + Args: + output_path: Path to the output file + root_path: Project root path + root_node: File tree root node + file_contents: List of file contents [(path, contents), ...]. + dependencies: Dependency information between files + """ + # count_selected_files 함수를 모듈에서 임포트하지 않았기 때문에 필요한 함수를 정의 + def count_selected_files(node): + """Counts the number of selected files (excluding directories).""" + count = 0 + if not node.is_dir and node.selected: + count = 1 + elif node.is_dir and node.children: + for child in node.children.values(): + count += count_selected_files(child) + return count + + # flatten_tree 함수를 모듈에서 임포트하지 않았기 때문에 필요한 함수를 정의 + def flatten_tree(node, visible_only=True): + """Flattens the tree into a list of nodes for navigation.""" + flat_nodes = [] + + def _traverse(node, level=0): + if node.parent is not None: # 루트 노드 제외 + flat_nodes.append((node, level)) + + if node.is_dir and node.children and (not visible_only or node.expanded): + # 디렉토리 먼저, 그다음 파일, 알파벳 순으로 정렬 + items = sorted(node.children.items(), + key=lambda x: (not x[1].is_dir, x[0].lower())) + + for _, child in items: + _traverse(child, level + 1) + + _traverse(node) + return flat_nodes + + with open(output_path, 'w', encoding='utf-8') as f: + # 헤더 및 개요 + f.write("# PROJECT ANALYSIS FOR AI ASSISTANT\n\n") + + # 프로젝트 일반 정보 + total_files = sum(1 for node, _ in flatten_tree(root_node) if not node.is_dir) + selected_files = count_selected_files(root_node) + f.write("## 📦 GENERAL INFORMATION\n\n") + f.write(f"- **Project path**: `{root_path}`\n") + f.write(f"- **Total files**: {total_files}\n") + f.write(f"- **Files included in this analysis**: {selected_files}\n") + + # 사용된 언어 감지 + languages = {} + for path, _ in file_contents: + ext = os.path.splitext(path)[1].lower() + if ext: + ext = ext[1:] # 점 제거 + languages[ext] = languages.get(ext, 0) + 1 + + if languages: + f.write("- **Main languages used**:\n") + for ext, count in sorted(languages.items(), key=lambda x: x[1], reverse=True)[:5]: + lang_name = get_language_name(ext) + f.write(f" - {lang_name} ({count} files)\n") + f.write("\n") + + # 프로젝트 구조 + f.write("## 🗂️ PROJECT STRUCTURE\n\n") + f.write("```\n") + f.write(f"{root_path}\n") + f.write(write_file_tree_to_string(root_node)) + f.write("```\n\n") + + # 주요 디렉토리 및 컴포넌트 + main_dirs = [node for node, level in flatten_tree(root_node, False) + if node.is_dir and level == 1] + + if main_dirs: + f.write("### 📂 Main Components\n\n") + for dir_node in main_dirs: + dir_files = [p for p, _ in file_contents if p.startswith(f"{dir_node.name}/")] + f.write(f"- **`{dir_node.name}/`** - ") + if dir_files: + f.write(f"Contains {len(dir_files)} files") + + # 이 디렉토리의 언어들 + dir_exts = {} + for path in dir_files: + ext = os.path.splitext(path)[1].lower() + if ext: + ext = ext[1:] + dir_exts[ext] = dir_exts.get(ext, 0) + 1 + + if dir_exts: + main_langs = [get_language_name(ext) for ext, _ in + sorted(dir_exts.items(), key=lambda x: x[1], reverse=True)[:2]] + f.write(f" mainly in {', '.join(main_langs)}") + + f.write("\n") + f.write("\n") + + # 파일 관계 그래프 + f.write("## 🔄 FILE RELATIONSHIPS\n\n") + + # 가장 많이 참조된 파일 찾기 + referenced_by = {} + for file, deps in dependencies.items(): + for dep in deps: + if isinstance(dep, str) and os.path.sep in dep: # 파일 경로인 경우 + if dep not in referenced_by: + referenced_by[dep] = [] + referenced_by[dep].append(file) + + # 중요한 관계 표시 + if referenced_by: + f.write("### Core Files (most referenced)\n\n") + for file, refs in sorted(referenced_by.items(), key=lambda x: len(x[1]), reverse=True)[:10]: + if len(refs) > 1: # 여러 번 참조된 파일만 + f.write(f"- **`{file}`** is imported by {len(refs)} files\n") + f.write("\n") + + # 파일별 의존성 표시 + f.write("### Dependencies by File\n\n") + for file, deps in sorted(dependencies.items()): + if deps: + internal_deps = [d for d in deps if isinstance(d, str) and os.path.sep in d] + external_deps = [d for d in deps if d not in internal_deps] + + f.write(f"- **`{file}`**:\n") + + if internal_deps: + f.write(f" - *Internal dependencies*: ") + f.write(", ".join(f"`{d}`" for d in sorted(internal_deps)[:5])) + if len(internal_deps) > 5: + f.write(f" and {len(internal_deps)-5} more") + f.write("\n") + + if external_deps: + f.write(f" - *External dependencies*: ") + f.write(", ".join(f"`{d}`" for d in sorted(external_deps)[:5])) + if len(external_deps) > 5: + f.write(f" and {len(external_deps)-5} more") + f.write("\n") + f.write("\n") + + # 파일 내용 + f.write("## 📄 FILE CONTENTS\n\n") + f.write("*Note: The content below includes only selected files.*\n\n") + + for path, content in file_contents: + f.write(f"### {path}\n\n") + + # 파일 정보 추가 (가능한 경우) + file_deps = dependencies.get(path, set()) + if file_deps: + internal_deps = [d for d in file_deps if isinstance(d, str) and os.path.sep in d] + external_deps = [d for d in file_deps if d not in internal_deps] + + if internal_deps or external_deps: + f.write("**Dependencies:**\n") + + if internal_deps: + f.write("- Internal: " + ", ".join(f"`{d}`" for d in sorted(internal_deps)[:3])) + if len(internal_deps) > 3: + f.write(f" and {len(internal_deps)-3} more") + f.write("\n") + + if external_deps: + f.write("- External: " + ", ".join(f"`{d}`" for d in sorted(external_deps)[:3])) + if len(external_deps) > 3: + f.write(f" and {len(external_deps)-3} more") + f.write("\n") + + f.write("\n") + + # 확장자 기반 구문 강조 + ext = os.path.splitext(path)[1][1:].lower() + f.write(f"```{ext}\n") + f.write(content) + if not content.endswith('\n'): + f.write('\n') + f.write("```\n\n") \ No newline at end of file diff --git a/selector.py b/selector.py new file mode 100644 index 0000000..ff99ad8 --- /dev/null +++ b/selector.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +selector.py - File selection UI module + +A module that provides a curses-based interactive file selection interface. +""" + +import curses +from selector_ui import FileSelector + +def interactive_selection(root_node): + """Launch the interactive file selection interface.""" + return curses.wrapper(lambda stdscr: FileSelector(root_node, stdscr).run()) \ No newline at end of file diff --git a/selector_actions.py b/selector_actions.py new file mode 100644 index 0000000..5c4c68c --- /dev/null +++ b/selector_actions.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +selector_actions.py - File selection actions module + +A module that provides file selection, expansion, and search functionality. +""" + +import re +from filetree import flatten_tree + +def toggle_selection(node): + """ + Toggles the selection state of the node, and if it is a directory, the selection state of its children. + + Args: + node (Node): The node to toggle + """ + node.selected = not node.selected + + if node.is_dir and node.children: + for child in node.children.values(): + child.selected = node.selected + if child.is_dir: + toggle_selection(child) + +def toggle_expand(node, search_mode=False, search_query=None, original_nodes=None, apply_search_filter_func=None): + """ + Expand or collapse a directory. + + Args: + node (Node): The node to expand or collapse + search_mode (bool): Whether search mode is active + search_query (str): The current search query + original_nodes (list): Original list of nodes before search + apply_search_filter_func (callable): Function to apply search filter + + Returns: + list: Updated list of visible nodes + """ + if node.is_dir: + node.expanded = not node.expanded + # 검색 모드가 아닐 때만 보이는 노드 목록 업데이트 + if not search_mode and not search_query: + return flatten_tree(node.parent if node.parent else node) + elif search_query and apply_search_filter_func: + # 검색이 활성화된 경우 필터링을 다시 적용 + apply_search_filter_func() + return None + +def select_all(root_node, select=True): + """ + Select or deselect all nodes. + + Args: + root_node (Node): The root node + select (bool): Whether to select or deselect + """ + def _select_recursive(node): + """ + Recursively sets the selected state of a node and its children. + + Args: + node (Node): The node to set. + """ + node.selected = select + if node.is_dir and node.children: + for child in node.children.values(): + _select_recursive(child) + + _select_recursive(root_node) + +def expand_all(root_node, expand=True): + """ + Expand or collapse all directories. + + Args: + root_node (Node): The root node + expand (bool): Whether to expand or collapse + + Returns: + list: Updated list of visible nodes + """ + def _set_expanded(node, expand): + """ + Sets the expanded state of the node and its children. + + Args: + node (Node): The node to set + expand (bool): Whether to expand + """ + if node.is_dir and node.children: + node.expanded = expand + for child in node.children.values(): + _set_expanded(child, expand) + + _set_expanded(root_node, expand) + return flatten_tree(root_node) + +def apply_search_filter(search_queries: list[str], case_sensitive: bool, root_node, original_nodes: list, visible_nodes_out: list): + """ + Filter files based on a list of search queries. + + Args: + search_queries (list[str]): The list of search queries. + case_sensitive (bool): Whether the search is case sensitive. + root_node (Node): The root node of the file tree. + original_nodes (list): The original list of nodes to restore if search is cleared or invalid. + visible_nodes_out (list): Output parameter for the filtered list of (Node, level) tuples. + + Returns: + tuple: (success, error_message) + success (bool): True if the filter was applied successfully or cleared, False otherwise. + error_message (str): An error message if success is False, otherwise an empty string. + """ + if not search_queries or all(not query or query.isspace() for query in search_queries): + visible_nodes_out[:] = original_nodes + return True, "" + + compiled_patterns = [] + flags = 0 if case_sensitive else re.IGNORECASE + + for query in search_queries: + if query and not query.isspace(): + try: + compiled_patterns.append(re.compile(query, flags)) + except re.error: + return False, "잘못된 정규식" # Invalid regular expression + + if not compiled_patterns: # All queries were empty or whitespace + visible_nodes_out[:] = original_nodes + return True, "" + + all_nodes = flatten_tree(root_node) # This gives a list of (Node, level) tuples + + matching_file_nodes = [] + for node, level in all_nodes: + if not node.is_dir: # Only match against file names + for pattern in compiled_patterns: + if pattern.search(node.name): + matching_file_nodes.append(node) + break # OR condition: if one pattern matches, it's a match + + if not matching_file_nodes: + visible_nodes_out[:] = [] # Empty list as per requirement + return False, "검색 결과 없음" # No search results + + visible_nodes_set = set(matching_file_nodes) + for node in matching_file_nodes: + parent = node.parent + while parent: + visible_nodes_set.add(parent) + parent = parent.parent + + # Preserve original tree order and structure for visible nodes + # all_nodes is already in the correct order + filtered_visible_nodes = [] + for node, level in all_nodes: + if node in visible_nodes_set: + filtered_visible_nodes.append((node, level)) + # Ensure parent directories are expanded if they have visible children + if node.is_dir and node.children and any(child in visible_nodes_set for child in node.children.values()): + node.expanded = True + + + visible_nodes_out[:] = filtered_visible_nodes + + # It's possible that root_node itself is not in visible_nodes_set (e.g. if it's a dir and has no matching children) + # However, the prompt implies that original tree structure should be preserved for *these* visible nodes. + # If visible_nodes_out is not empty, and root_node is an ancestor of some node in visible_nodes_out, + # it should be included. The current logic with visible_nodes_set and iterating all_nodes should handle this. + # The old check `if not any(node[0] == root_node for node in visible_nodes_out):` might be too aggressive + # if the root itself doesn't match and has no direct matching children but is an ancestor. + # The current construction of `filtered_visible_nodes` should correctly include the root if it's part of the path to a visible node. + + return True, "" + +def toggle_current_dir_selection(current_node): + """ + Toggles the selection status of only files in the current directory (no subdirectories). + + Args: + current_node (Node): The current node + """ + # 현재 노드가 디렉토리인 경우, 그 직계 자식들만 선택 상태 전환 + if current_node.is_dir and current_node.children: + # 자식들의 대부분이 선택되었는지 확인하여 동작 결정 + selected_count = sum(1 for child in current_node.children.values() if child.selected) + select_all = selected_count <= len(current_node.children) / 2 + + # 모든 직계 자식들을 새 선택 상태로 설정 + for child in current_node.children.values(): + child.selected = select_all + # 현재 노드가 파일인 경우, 그 선택 상태만 전환 + else: + current_node.selected = not current_node.selected \ No newline at end of file diff --git a/selector_ui.py b/selector_ui.py new file mode 100644 index 0000000..6b15d27 --- /dev/null +++ b/selector_ui.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +selector_ui.py - File selection UI components + +A module that provides UI components for file selection. +""" + +import curses +import re +from filetree import flatten_tree, count_selected_files +from selector_actions import ( + toggle_selection, toggle_expand, select_all, + expand_all, apply_search_filter, toggle_current_dir_selection +) + +class FileSelector: + """ + Classes that provide an interactive file selection interface based on curses + + Provides a UI that allows the user to select a file from a file tree. + """ + def __init__(self, root_node, stdscr): + """ + Initialising the FileSelector Class + + Args: + root_node (Node): The root node of the file tree + stdscr (curses.window): curses window object + """ + self.root_node = root_node + self.stdscr = stdscr + self.current_index = 0 + self.scroll_offset = 0 + self.visible_nodes = flatten_tree(root_node) + self.max_visible = 0 + self.height, self.width = 0, 0 + self.copy_to_clipboard = True # 기본값: 클립보드 복사 활성화 + + # 검색 관련 변수 + self.search_mode = False + self.search_input_str = "" # Stores the raw string the user types for the search + self.search_patterns_list = [] # Stores the list of processed patterns + self.search_buffer = "" # Live buffer for typing search query + self.case_sensitive = False + # self.filtered_nodes = [] # This seems unused, consider removing if not needed later + self.original_nodes = [] # 검색 전 노드 상태 저장 + self.search_had_no_results = False # Flag for "검색 결과 없음" + + self.initialize_curses() + + def initialize_curses(self): + """Initialise curses settings.""" + curses.start_color() + curses.use_default_colors() + # 색상 쌍 정의 + curses.init_pair(1, curses.COLOR_GREEN, -1) # 선택된 파일 + curses.init_pair(2, curses.COLOR_BLUE, -1) # 디렉토리 + curses.init_pair(3, curses.COLOR_YELLOW, -1) # 선택된 디렉토리 + curses.init_pair(4, curses.COLOR_WHITE, -1) # 선택되지 않은 파일 + curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE) # 현재 선택 + curses.init_pair(6, curses.COLOR_RED, -1) # 도움말 메시지 + curses.init_pair(7, curses.COLOR_CYAN, -1) # 검색 모드 표시 + + # 커서 숨기기 + curses.curs_set(0) + + # 특수 키 활성화 + self.stdscr.keypad(True) + + # 화면 크기 가져오기 + self.update_dimensions() + + def update_dimensions(self): + """Update the screen size.""" + self.height, self.width = self.stdscr.getmaxyx() + self.max_visible = self.height - 6 # 상단에 통계를 위한 라인 추가 + + def expand_all(self, expand=True): + """Expand or collapse all directories.""" + result = expand_all(self.root_node, expand) + self.visible_nodes = result + + def toggle_current_dir_selection(self): + """Toggles the selection status of only files in the current directory (no subdirectories).""" + if self.current_index < len(self.visible_nodes): + current_node, _ = self.visible_nodes[self.current_index] + toggle_current_dir_selection(current_node) + + def toggle_search_mode(self): + """Turn search mode on or off.""" + if self.search_mode: + # 검색 모드 종료 + self.search_mode = False + # Do not clear self.search_buffer here, it might be needed if user re-enters search mode + # Do not clear self.search_input_str or self.search_patterns_list here. + else: + # 검색 모드 시작 + self.search_mode = True + if self.search_input_str: # If there was a previous search query + self.search_buffer = self.search_input_str # Initialize buffer with it + else: + self.search_buffer = "" # Start with an empty buffer + + if not self.original_nodes and not self.search_input_str: # Only save original_nodes if no search is active + # This logic might need refinement: original_nodes should be the true unfiltered list. + # If a search is active, original_nodes should already hold the full list. + # Let's assume original_nodes is set once when the first search begins or when cleared. + # A better place to set original_nodes might be when a search is *applied* or *cleared*. + self.original_nodes = list(self.visible_nodes) + + + def handle_search_input(self, ch): + """Process input in search mode.""" + if ch == 27: # ESC + # Store if a filter was active before clearing + was_filter_active = bool(self.search_input_str) + + self.search_mode = False + self.search_buffer = "" # Clear live buffer + + # Only clear applied filter if ESC is pressed *while not actively typing a new one from scratch* + # Or if the user intended to clear the existing filter by pressing ESC. + # The main ESC logic in run() handles clearing an *applied* filter when not in search_mode. + # This ESC in handle_search_input is for when search_mode is true. + if not self.search_input_str: # If no search was previously applied (buffer was empty or not submitted) + if self.original_nodes: + self.visible_nodes = list(self.original_nodes) + # self.original_nodes = [] # Don't clear original_nodes yet, might be needed if user re-enters search + + # If user presses ESC in search mode, we always clear current input string and patterns + self.search_input_str = "" + self.search_patterns_list = [] + self.search_had_no_results = False + + # If no filter was active, and ESC is pressed in search mode, restore original full list. + if not was_filter_active and self.original_nodes: + self.visible_nodes = list(self.original_nodes) + self.original_nodes = [] # Now clear, as we've reverted to pre-search state. + + return True + elif ch in (10, 13): # Enter + self.search_input_str = self.search_buffer + # Split by comma or space, and filter out empty strings + raw_patterns = re.split(r'[, ]+', self.search_input_str) + self.search_patterns_list = [p for p in raw_patterns if p] + + self.search_mode = False # Exit search mode after submitting + self.apply_search_filter() + return True + elif ch == curses.KEY_BACKSPACE or ch == 127 or ch == 8: # Backspace + # 검색어에서 한 글자 삭제 + self.search_buffer = self.search_buffer[:-1] + return True + elif ch == ord('^'): # ^ 키로 대소문자 구분 토글 + self.case_sensitive = not self.case_sensitive + return True + elif 32 <= ch <= 126: # ASCII 문자 + # 검색어에 문자 추가 + self.search_buffer += chr(ch) + return True + return False + + def apply_search_filter(self): + """Filter files based on the current search_patterns_list.""" + self.search_had_no_results = False # Reset flag + + if not self.search_input_str or not self.search_patterns_list: # If search input is empty or resulted in no patterns + if self.original_nodes: + self.visible_nodes = list(self.original_nodes) + # Potentially clear self.original_nodes = [] if we consider this a "filter cleared" state + # However, original_nodes should persist if user clears search then types a new one. + # Clearing of original_nodes is handled by ESC in run() or handle_search_input. + self.search_input_str = "" # Ensure consistency + self.search_patterns_list = [] + return + + # If this is the first real search operation (original_nodes is not yet set), + # store the current complete list of nodes. + if not self.original_nodes: + # This assumes visible_nodes currently holds the full, unfiltered list. + # This should be true if original_nodes is empty. + self.original_nodes = list(flatten_tree(self.root_node)) # Ensure it's the full list + + # self.visible_nodes is passed as an output parameter and will be modified in place. + success, error_message = apply_search_filter( + self.search_patterns_list, + self.case_sensitive, + self.root_node, + self.original_nodes, # Pass the true original list for reference + self.visible_nodes # This list will be modified + ) + + if not success: + if error_message == "검색 결과 없음": + self.search_had_no_results = True + # self.visible_nodes is already set to [] by selector_actions.apply_search_filter + + # Display error message (e.g., "잘못된 정규식" or "검색 결과 없음") + # For "검색 결과 없음", draw_tree will handle the specific message. + # For other errors like "잘못된 정규식", show a temporary message. + if error_message != "검색 결과 없음": # Avoid double messaging for "no results" + self.stdscr.addstr(self.height - 2, 1, f"Error: {error_message}", curses.color_pair(6)) + self.stdscr.refresh() + curses.napms(1500) # Show message for a bit + # No explicit return needed if success is True, visible_nodes is updated. + + def handle_vim_navigation(self, ch): + """Handles IM-style navigation keys.""" + if ch == ord('j'): # 아래로 이동 + self.current_index = min(len(self.visible_nodes) - 1, self.current_index + 1) + return True + elif ch == ord('k'): # 위로 이동 + self.current_index = max(0, self.current_index - 1) + return True + elif ch == ord('h'): # 디렉토리 닫기 또는 부모로 이동 + if self.current_index < len(self.visible_nodes): + node, _ = self.visible_nodes[self.current_index] + if node.is_dir and node.expanded: + result = toggle_expand(node, self.search_mode, self.search_input_str, + self.original_nodes, self.apply_search_filter) + if result: + self.visible_nodes = result + return True + elif node.parent and node.parent.parent: # 부모로 이동 (루트 제외) + # 부모의 인덱스 찾기 + for i, (n, _) in enumerate(self.visible_nodes): + if n == node.parent: + self.current_index = i + return True + elif ch == ord('l'): # 디렉토리 열기 + if self.current_index < len(self.visible_nodes): + node, _ = self.visible_nodes[self.current_index] + if node.is_dir and not node.expanded: + result = toggle_expand(node, self.search_mode, self.search_input_str, + self.original_nodes, self.apply_search_filter) + if result: + self.visible_nodes = result + return True + return False + + def draw_tree(self): + """Draw a file tree.""" + self.stdscr.clear() + self.update_dimensions() + + # Update visible_nodes based on current state + if not self.search_mode and not self.search_input_str: + if not self.original_nodes: # No prior search or search fully cleared + self.visible_nodes = flatten_tree(self.root_node) + # If self.original_nodes exists, visible_nodes should have been restored by clear/ESC logic + # If a search IS active (self.search_input_str is not empty), visible_nodes is managed by apply_search_filter + + # 범위 확인 + if self.current_index >= len(self.visible_nodes): + self.current_index = len(self.visible_nodes) - 1 + if self.current_index < 0: + self.current_index = 0 + + # 필요시 스크롤 조정 + if self.current_index < self.scroll_offset: + self.scroll_offset = self.current_index + elif self.current_index >= self.scroll_offset + self.max_visible: + self.scroll_offset = self.current_index - self.max_visible + 1 + + # 1번째 줄에 통계 표시 (첫 번째 항목을 가리지 않도록) + selected_count = count_selected_files(self.root_node) + total_count = sum(1 for node, _ in flatten_tree(self.root_node) if not node.is_dir) + visible_count = len([1 for node, _ in self.visible_nodes if not node.is_dir]) + + # 검색 모드 상태 표시 + # Line 0 for search status / general status + if self.search_mode or self.search_input_str: + search_text_display = self.search_buffer if self.search_mode else self.search_input_str + search_display_line = f"Search: {search_text_display}" + case_status = "Case-sensitive" if self.case_sensitive else "Ignore case" + + # Truncate search_display_line if too long + max_search_len = self.width - len(f" ({case_status})") - len(f"Show: {visible_count}/{total_count}") - 5 + if len(search_display_line) > max_search_len: + search_display_line = search_display_line[:max_search_len-3] + "..." + + self.stdscr.addstr(0, 0, search_display_line, curses.color_pair(7) | curses.A_BOLD) + self.stdscr.addstr(0, len(search_display_line) + 2, f"({case_status})", curses.color_pair(7)) + + # Show "Show: X/Y" to the right + stats_show_text = f"Show: {visible_count}/{total_count}" + self.stdscr.addstr(0, self.width - len(stats_show_text) -1 , stats_show_text, curses.A_BOLD) + + # Line 1 for selected files count (always shown) + self.stdscr.addstr(1, 0, f"Selected Files: {selected_count}/{total_count}", curses.A_BOLD) + else: + self.stdscr.addstr(0, 0, f"Selected Files: {selected_count}/{total_count}", curses.A_BOLD) + + # Display "일치하는 파일 없음" if applicable (line 2 or 3 based on layout) + if self.search_input_str and self.search_had_no_results and not self.visible_nodes: + message_y = 2 # Start message on line 2 + self.stdscr.addstr(message_y, 0, "일치하는 파일 없음", curses.color_pair(6)) + else: + # Draw the tree starting from line 2 + for i, (node, level) in enumerate(self.visible_nodes[self.scroll_offset:self.scroll_offset + self.max_visible]): + y = i + 2 # Start tree drawing from line 2 + if y >= self.max_visible + 2: # Adjust boundary + break + + # 유형 및 선택 상태에 따라 색상 결정 + if i + self.scroll_offset == self.current_index: + attr = curses.color_pair(5) # Highlight + elif node.is_dir: + attr = curses.color_pair(3) if node.selected else curses.color_pair(2) + else: + attr = curses.color_pair(1) if node.selected else curses.color_pair(4) + + indent = " " * level + prefix = "+ " if node.is_dir and node.expanded else ("- " if node.is_dir else ("✓ " if node.selected else "☐ ")) + + name_space = self.width - len(indent) - len(prefix) - 1 # Adjusted for potential border + name_display = node.name[:name_space] + ("..." if len(node.name) > name_space else "") + + self.stdscr.addstr(y, 0, f"{indent}{prefix}{name_display}", attr) + + # 화면 하단에 도움말 표시 + help_y = self.height - 4 # Adjusted for potentially one less line due to stats/search display + self.stdscr.addstr(help_y, 0, "━" * self.width) + help_y += 1 + if self.search_mode: + self.stdscr.addstr(help_y, 0, "Search mode: type and press Enter to execute search, ESC to cancel, ^ to toggle case", curses.color_pair(7)) + else: + self.stdscr.addstr(help_y, 0, "↑/↓/j/k: Navigate SPACE: Select ←/→/h/l: Close/open folder /: Search", curses.color_pair(6)) + help_y += 1 + self.stdscr.addstr(help_y, 0, "T: Toggle current folder only E: Expand all C: Collapse all", curses.color_pair(6)) + help_y += 1 + clip_status = "ON" if self.copy_to_clipboard else "OFF" + self.stdscr.addstr(help_y, 0, f"A: Select all N: Deselect all B: Clipboard ({clip_status}) X: Cancel D: Complete", curses.color_pair(6)) + + self.stdscr.refresh() + + def process_key(self, key): + """키 입력을 처리합니다.""" + # 검색 모드일 때는 검색 입력 처리 + if self.search_mode: + return self.handle_search_input(key) + + # ESC 키는 run() 메서드에서 특별 처리 + if key == 27: # ESC + return False + + # 검색 모드 진입 + if key == ord('/'): + self.toggle_search_mode() + return True + + # Vim 스타일 네비게이션 처리 + if self.handle_vim_navigation(key): + return True + + # 화살표 키 처리 + if key == curses.KEY_UP: + # 위로 이동 + self.current_index = max(0, self.current_index - 1) + return True + elif key == curses.KEY_DOWN: + # 아래로 이동 + self.current_index = min(len(self.visible_nodes) - 1, self.current_index + 1) + return True + elif key == curses.KEY_RIGHT: + # 디렉토리 열기 + if self.current_index < len(self.visible_nodes): + node, _ = self.visible_nodes[self.current_index] + if node.is_dir and not node.expanded: + result = toggle_expand(node, self.search_mode, self.search_input_str, + self.original_nodes, self.apply_search_filter) + if result: + self.visible_nodes = result + # 검색 모드에서는 필터링 다시 적용 + if self.search_input_str: # Use search_input_str + self.apply_search_filter() + return True + elif key == curses.KEY_LEFT: + # 디렉토리 닫기 + if self.current_index < len(self.visible_nodes): + node, _ = self.visible_nodes[self.current_index] + if node.is_dir and node.expanded: + result = toggle_expand(node, self.search_mode, self.search_input_str, + self.original_nodes, self.apply_search_filter) + if result: + self.visible_nodes = result + # 검색 모드에서는 필터링 다시 적용 + if self.search_input_str: # Use search_input_str + self.apply_search_filter() + return True + elif node.parent and node.parent.parent: # 부모로 이동 (루트 제외) + # 부모의 인덱스 찾기 + for i, (n, _) in enumerate(self.visible_nodes): + if n == node.parent: + self.current_index = i + return True + elif key == ord(' '): + # 선택 전환 (검색 모드에서도 작동하도록 함) + if self.current_index < len(self.visible_nodes): + node, _ = self.visible_nodes[self.current_index] + toggle_selection(node) + return True + elif key in [ord('a'), ord('A')]: + # 모두 선택 + select_all(self.root_node, True) + return True + elif key in [ord('n'), ord('N')]: + # 모두 선택 해제 + select_all(self.root_node, False) + return True + elif key in [ord('e'), ord('E')]: + # 모두 확장 + self.expand_all(True) + return True + elif key in [ord('c'), ord('C')]: + # 모두 접기 + self.expand_all(False) + return True + elif key in [ord('t'), ord('T')]: + # 현재 디렉토리만 선택 전환 + self.toggle_current_dir_selection() + return True + elif key in [ord('b'), ord('B')]: # 'c'에서 'b'로 변경 (클립보드) + # 클립보드 전환 + self.copy_to_clipboard = not self.copy_to_clipboard + return True + elif key in [ord('x'), ord('X'), 27]: # 27 = ESC + # ESC는 검색 모드에서만 처리하므로 여기서는 종료로 처리 + return False + elif key in [ord('d'), ord('D'), 10, 13]: # 10, 13 = Enter + # 검색 모드가 아닐 때만 완료로 처리 + return False + + return True + + def run(self): + """Launch the selection interface.""" + while True: + self.draw_tree() + key = self.stdscr.getch() + + # ESC 키 특별 처리: 검색 모드일 때와 검색 결과가 있을 때 + if key == 27: # 27 = ESC + if self.search_mode: + # Call handle_search_input with ESC, which will manage search state + self.handle_search_input(key) + # handle_search_input now sets search_mode to False. + # It also handles restoring nodes if search_buffer was empty. + elif self.search_input_str: # Not in search mode, but a filter is active + # Clear all search state and restore original nodes + self.search_input_str = "" + self.search_patterns_list = [] + self.search_buffer = "" + self.search_had_no_results = False + if self.original_nodes: + self.visible_nodes = list(self.original_nodes) # Restore from original_nodes + else: # Fallback if original_nodes somehow not set + self.visible_nodes = flatten_tree(self.root_node) + self.original_nodes = [] # Clear original_nodes as the filter is now cleared + else: + # No active filter, not in search mode: exit + return False # Exit application + continue + + # 키 처리 결과에 따라 분기 + if key == curses.KEY_RESIZE: + # 창 크기 변경 처리 + self.update_dimensions() + continue + + # 키 처리 + key_handled = self.process_key(key) + + # 종료 조건 + if not key_handled: + if key in [ord('x'), ord('X')]: + # 저장하지 않고 종료 + return False + elif key in [ord('d'), ord('D'), 10, 13]: # 10, 13 = Enter + # 완료 + return True + + return True \ No newline at end of file diff --git a/test/test_cli.py b/test/test_cli.py new file mode 100644 index 0000000..d96b2a5 --- /dev/null +++ b/test/test_cli.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +cli.py 모듈에 대한 테스트 코드 +""" + +import os +import sys +import unittest +import tempfile +from unittest.mock import patch, MagicMock + +# 테스트 대상 모듈 임포트 +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +import cli + +class TestCLI(unittest.TestCase): + """cli.py 모듈의 함수들을 테스트하는 클래스""" + + def setUp(self): + """테스트 준비""" + self.temp_dir = tempfile.mkdtemp() + + # 테스트용 임시 디렉토리에 파일 생성 + with open(os.path.join(self.temp_dir, "test.py"), "w") as f: + f.write("print('Hello, world!')") + + def tearDown(self): + """테스트 정리 - 임시 파일 삭제""" + import shutil + shutil.rmtree(self.temp_dir) + + def test_parse_arguments(self): + """parse_arguments 함수 테스트""" + # 명령행 인수를 모의로 설정 + test_args = ["codeselect", self.temp_dir, "--format", "md", "--no-clipboard"] + + with patch('sys.argv', test_args): + args = cli.parse_arguments() + + # 인수가 올바르게 파싱되었는지 확인 + self.assertEqual(args.directory, self.temp_dir) + self.assertEqual(args.format, "md") + self.assertTrue(args.no_clipboard) + self.assertFalse(args.skip_selection) + self.assertFalse(args.version) + + def test_main_version_flag(self): + """main 함수의 --version 플래그 처리 테스트""" + # --version 플래그를 설정 + with patch('sys.argv', ["codeselect", "--version"]): + with patch('builtins.print') as mock_print: + exit_code = cli.main() + + # 종료 코드가 0인지 확인 + self.assertEqual(exit_code, 0) + + # 버전 정보가 출력되었는지 확인 + mock_print.assert_called_once_with(f"CodeSelect v{cli.__version__}") + + def test_main_invalid_directory(self): + """main 함수의 잘못된 디렉토리 처리 테스트""" + # 존재하지 않는 디렉토리를 인수로 지정 + with patch('sys.argv', ["codeselect", "/nonexistent/directory"]): + with patch('builtins.print') as mock_print: + exit_code = cli.main() + + # 종료 코드가 1인지 확인 + self.assertEqual(exit_code, 1) + + # 오류 메시지가 출력되었는지 확인 + mock_print.assert_called_with(f"Error: {os.path.abspath('/nonexistent/directory')} is not a valid directory") + + @patch('filetree.build_file_tree') + @patch('selector.interactive_selection') + @patch('filetree.count_selected_files') + @patch('filetree.collect_selected_content') + @patch('filetree.collect_all_content') + @patch('dependency.analyze_dependencies') + @patch('output.write_output_file') + @patch('utils.try_copy_to_clipboard') + def test_main_normal_flow(self, mock_clipboard, mock_write_output, mock_analyze_dep, + mock_collect_all, mock_collect_selected, mock_count_files, + mock_selection, mock_build_tree): + """main 함수의 정상 실행 흐름 테스트""" + # 모의 객체 설정 + mock_root_node = MagicMock() + mock_build_tree.return_value = mock_root_node + mock_selection.return_value = True + mock_count_files.return_value = 2 + mock_collect_selected.return_value = [("file1.py", "content1"), ("file2.py", "content2")] + mock_collect_all.return_value = [("file1.py", "content1"), ("file2.py", "content2")] + mock_analyze_dep.return_value = {"file1.py": set(), "file2.py": set()} + mock_write_output.return_value = os.path.join(self.temp_dir, "output.txt") + mock_clipboard.return_value = True + + # 임시 파일 생성 + output_path = os.path.join(self.temp_dir, "output.txt") + with open(output_path, "w") as f: + f.write("test content") + + # 명령행 인수 설정 + with patch('sys.argv', ["codeselect", self.temp_dir, "-o", output_path]): + with patch('builtins.open', unittest.mock.mock_open(read_data="test content")): + exit_code = cli.main() + + # 종료 코드가 0인지 확인 + self.assertEqual(exit_code, 0) + + # 각 함수가 적절하게 호출되었는지 확인 + mock_build_tree.assert_called_once_with(self.temp_dir) + mock_selection.assert_called_once_with(mock_root_node) + mock_count_files.assert_called_with(mock_root_node) + mock_collect_selected.assert_called_with(mock_root_node, self.temp_dir) + mock_collect_all.assert_called_with(mock_root_node, self.temp_dir) + mock_analyze_dep.assert_called_with(self.temp_dir, mock_collect_all.return_value) + mock_write_output.assert_called_with( + output_path, self.temp_dir, mock_root_node, + mock_collect_selected.return_value, "llm", mock_analyze_dep.return_value + ) + mock_clipboard.assert_called_with("test content") + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/test/test_dependency.py b/test/test_dependency.py new file mode 100644 index 0000000..23c50e1 --- /dev/null +++ b/test/test_dependency.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +dependency.py 모듈에 대한 테스트 코드 +""" + +import os +import sys +import unittest +import tempfile + +# 테스트 대상 모듈 임포트 +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +import dependency + +class TestDependency(unittest.TestCase): + """dependency.py 모듈의 함수들을 테스트하는 클래스""" + + def setUp(self): + """테스트 준비""" + self.temp_dir = tempfile.mkdtemp() + + # 다양한 언어로 된 테스트 파일 생성 + self.python_file1 = os.path.join(self.temp_dir, "main.py") + self.python_file2 = os.path.join(self.temp_dir, "utils.py") + self.js_file = os.path.join(self.temp_dir, "app.js") + self.cpp_file = os.path.join(self.temp_dir, "program.cpp") + + # 디렉토리 생성 + os.makedirs(os.path.join(self.temp_dir, "lib"), exist_ok=True) + self.lib_file = os.path.join(self.temp_dir, "lib", "helper.py") + + # 파일 내용 정의 + self.python_content1 = """ +import os +import sys +from utils import format_string +from lib.helper import Helper +""" + + self.python_content2 = """ +import os +import datetime + +def format_string(s): + return s.strip() +""" + + self.lib_content = """ +class Helper: + def __init__(self): + pass +""" + + self.js_content = """ +import React from 'react'; +import { useState } from 'react'; +const axios = require('axios'); +import './styles.css'; +""" + + self.cpp_content = """ +#include +#include +#include "lib/helper.hpp" +""" + + # 파일 작성 + with open(self.python_file1, 'w') as f: + f.write(self.python_content1) + + with open(self.python_file2, 'w') as f: + f.write(self.python_content2) + + with open(self.lib_file, 'w') as f: + f.write(self.lib_content) + + with open(self.js_file, 'w') as f: + f.write(self.js_content) + + with open(self.cpp_file, 'w') as f: + f.write(self.cpp_content) + + # 파일 내용 리스트 생성 + self.file_contents = [ + ("main.py", self.python_content1), + ("utils.py", self.python_content2), + ("lib/helper.py", self.lib_content), + ("app.js", self.js_content), + ("program.cpp", self.cpp_content) + ] + + def tearDown(self): + """테스트 정리 - 임시 파일 삭제""" + import shutil + shutil.rmtree(self.temp_dir) + + def test_analyze_dependencies(self): + """analyze_dependencies 함수 테스트""" + # 의존성 분석 함수 호출 + dependencies = dependency.analyze_dependencies(self.temp_dir, self.file_contents) + + # 결과 검증 + # 1. main.py 확인 + self.assertIn("main.py", dependencies) + deps = dependencies["main.py"] + + # main.py는 utils.py와 lib/helper.py에 의존성이 있어야 함 + self.assertIn("utils.py", deps) + self.assertIn("lib/helper.py", deps) + + # 외부 모듈도 의존성에 포함되어야 함 (실제 파일이 없으므로 문자열로만 존재) + self.assertTrue(any(dep == "os" for dep in deps)) + self.assertTrue(any(dep == "sys" for dep in deps)) + + # 2. utils.py 확인 + self.assertIn("utils.py", dependencies) + deps = dependencies["utils.py"] + + # utils.py는 os와 datetime에 의존성이 있어야 함 + self.assertTrue(any(dep == "os" for dep in deps)) + self.assertTrue(any(dep == "datetime" for dep in deps)) + + # 3. app.js 확인 + self.assertIn("app.js", dependencies) + deps = dependencies["app.js"] + + # app.js는 React, useState, axios, styles.css에 의존성이 있어야 함 + self.assertTrue(any(dep == "react" or dep == "React" for dep in deps)) + self.assertTrue(any("useState" in str(dep) for dep in deps)) + self.assertTrue(any("axios" in str(dep) for dep in deps)) + self.assertTrue(any("styles.css" in str(dep) for dep in deps)) + + # 4. program.cpp 확인 + self.assertIn("program.cpp", dependencies) + deps = dependencies["program.cpp"] + + # program.cpp는 iostream, vector, lib/helper.hpp에 의존성이 있어야 함 + self.assertTrue(any("iostream" in str(dep) for dep in deps)) + self.assertTrue(any("vector" in str(dep) for dep in deps)) + self.assertTrue(any("helper.hpp" in str(dep) for dep in deps)) + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/test/test_filetree.py b/test/test_filetree.py new file mode 100644 index 0000000..68f8f26 --- /dev/null +++ b/test/test_filetree.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_filetree.py - filetree.py 모듈 테스트 + +filetree.py 모듈의 클래스와 함수들을 테스트하는 코드입니다. +""" + +import os +import sys +import tempfile +import unittest +from pathlib import Path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from filetree import Node, build_file_tree, flatten_tree, count_selected_files, collect_selected_content, collect_all_content + +class TestNode(unittest.TestCase): + """Node 클래스를 테스트하는 클래스""" + + def test_node_initialization(self): + """Node 클래스 초기화가 올바르게 되는지 테스트합니다.""" + # 파일 노드 테스트 + file_node = Node("test.py", False) + self.assertEqual(file_node.name, "test.py") + self.assertFalse(file_node.is_dir) + self.assertIsNone(file_node.children) + self.assertIsNone(file_node.parent) + self.assertTrue(file_node.selected) + + # 디렉토리 노드 테스트 + dir_node = Node("test_dir", True) + self.assertEqual(dir_node.name, "test_dir") + self.assertTrue(dir_node.is_dir) + self.assertEqual(dir_node.children, {}) + self.assertIsNone(dir_node.parent) + self.assertTrue(dir_node.selected) + self.assertTrue(dir_node.expanded) + + def test_node_path(self): + """Node의 path 프로퍼티가 올바른 경로를 반환하는지 테스트합니다.""" + # 루트 노드 + root = Node("root", True) + self.assertEqual(root.path, "root") + + # 자식 노드 + child = Node("child", True, root) + self.assertEqual(child.path, "root" + os.sep + "child") + + # 손자 노드 + grandchild = Node("grandchild.py", False, child) + self.assertEqual(grandchild.path, "root" + os.sep + "child" + os.sep + "grandchild.py") + +class TestFileTree(unittest.TestCase): + """파일 트리 관련 함수들을 테스트하는 클래스""" + + def setUp(self): + """테스트 전에 임시 디렉토리와 파일 구조를 생성합니다.""" + self.temp_dir = tempfile.TemporaryDirectory() + self.test_dir = self.temp_dir.name + + # 기본 디렉토리 구조 생성 + os.makedirs(os.path.join(self.test_dir, "dir1")) + os.makedirs(os.path.join(self.test_dir, "dir2", "subdir")) + + # 몇 가지 파일 생성 + Path(os.path.join(self.test_dir, "file1.txt")).write_text("File 1 content") + Path(os.path.join(self.test_dir, "dir1", "file2.py")).write_text("File 2 content") + Path(os.path.join(self.test_dir, "dir2", "file3.md")).write_text("File 3 content") + Path(os.path.join(self.test_dir, "dir2", "subdir", "file4.js")).write_text("File 4 content") + + # 무시할 파일과 디렉토리 생성 + os.makedirs(os.path.join(self.test_dir, ".git")) + os.makedirs(os.path.join(self.test_dir, "__pycache__")) + Path(os.path.join(self.test_dir, ".DS_Store")).touch() + Path(os.path.join(self.test_dir, "dir1", "temp.pyc")).touch() + + # .gitignore 테스트를 위한 추가 파일 생성 + Path(os.path.join(self.test_dir, "ignored_file.txt")).touch() + Path(os.path.join(self.test_dir, "error.log")).touch() + Path(os.path.join(self.test_dir, "important.log")).touch() + os.makedirs(os.path.join(self.test_dir, "ignored_dir")) + Path(os.path.join(self.test_dir, "ignored_dir", "some_file.txt")).touch() + + def tearDown(self): + """테스트 후에 임시 디렉토리를 정리합니다.""" + self.temp_dir.cleanup() + + def test_build_file_tree(self): + """build_file_tree 함수가 올바른 파일 트리를 생성하는지 테스트합니다.""" + root_node = build_file_tree(self.test_dir) + + # 루트 노드 확인 + self.assertEqual(root_node.name, os.path.basename(self.test_dir)) + self.assertTrue(root_node.is_dir) + + # 필터링 확인 - 무시된 파일/디렉토리는 포함되지 않아야 함 + root_children = list(root_node.children.keys()) + self.assertIn("dir1", root_children) + self.assertIn("dir2", root_children) + self.assertIn("file1.txt", root_children) + self.assertNotIn(".git", root_children) + self.assertNotIn("__pycache__", root_children) + self.assertNotIn(".DS_Store", root_children) + + # 중첩된 구조 확인 + dir1_node = root_node.children["dir1"] + self.assertTrue(dir1_node.is_dir) + self.assertIn("file2.py", dir1_node.children) + self.assertNotIn("temp.pyc", dir1_node.children) + + dir2_node = root_node.children["dir2"] + self.assertTrue(dir2_node.is_dir) + self.assertIn("file3.md", dir2_node.children) + self.assertIn("subdir", dir2_node.children) + + subdir_node = dir2_node.children["subdir"] + self.assertTrue(subdir_node.is_dir) + self.assertIn("file4.js", subdir_node.children) + + def test_flatten_tree(self): + """flatten_tree 함수가 트리를 올바르게 평탄화하는지 테스트합니다.""" + root_node = build_file_tree(self.test_dir) + + # 모든 노드 포함 (visible_only=False) + flat_nodes = flatten_tree(root_node, visible_only=False) + + # 노드 수 확인 (루트 제외) + # 실제 테스트된 파일 시스템에 있는 노드 수 + self.assertEqual(len(flat_nodes), 12) + + # 레벨 확인 + level_0_nodes = [node for node, level in flat_nodes if level == 0] + level_1_nodes = [node for node, level in flat_nodes if level == 1] + level_2_nodes = [node for node, level in flat_nodes if level == 2] + + # 레벨별 노드 수도 조정 + self.assertEqual(len(level_0_nodes), 7) # 최상위 노드들 + self.assertEqual(len(level_1_nodes), 4) # 중간 레벨 노드들 + self.assertEqual(len(level_2_nodes), 1) # 최하위 노드들 + + # 노드 접힘 테스트 + root_node.children["dir2"].expanded = False + flat_nodes_visible = flatten_tree(root_node, visible_only=True) + + # dir2 내부 노드들(file3.md, subdir, file4.js)은 보이지 않아야 함 + # 접힌 노드를 제외한 노드 수 + self.assertEqual(len(flat_nodes_visible), 9) # dir2 내부 노드 3개 제외 + + def test_count_selected_files(self): + """count_selected_files 함수가 올바르게 선택된 파일 수를 계산하는지 테스트합니다.""" + root_node = build_file_tree(self.test_dir) + + # 기본적으로 모든 파일이 선택됨 + self.assertEqual(count_selected_files(root_node), 8) # 실제 테스트 환경의 파일 수 + + # 일부 파일 선택 해제 + root_node.children["file1.txt"].selected = False + root_node.children["dir1"].children["file2.py"].selected = False + + self.assertEqual(count_selected_files(root_node), 6) # 2개 파일 선택 해제됨 + + # 디렉토리 선택 해제 (하위 파일 포함) + root_node.children["dir2"].selected = False + + # 디렉토리 자체는 포함되지 않고, 내부 파일만 계산됨 + # dir2를 선택 해제했지만 그 안의 파일들의 selected 상태는 변경되지 않음 + self.assertEqual(count_selected_files(root_node), 6) # dir2 선택 해제는 파일 수에 영향 없음 + + def test_collect_selected_content(self): + """collect_selected_content 함수가 선택된 파일의 내용을 올바르게 수집하는지 테스트합니다.""" + root_node = build_file_tree(self.test_dir) + + # 일부 파일만 선택 + root_node.children["dir1"].children["file2.py"].selected = False + + contents = collect_selected_content(root_node, self.test_dir) + + # 선택된 파일 수 확인 (하나 선택 해제됨) + self.assertEqual(len(contents), 7) # 총 8개 파일 중 file2.py 제외한 7개 + + # 파일 경로와 내용 확인 + paths = [path for path, _ in contents] + + base_name = os.path.basename(self.test_dir) + + # 주요 파일들이 포함되어 있는지만 확인 + expected_paths = [ + "file1.txt", + f"{base_name}{os.sep}dir2{os.sep}file3.md", + f"{base_name}{os.sep}dir2{os.sep}subdir{os.sep}file4.js", + "important.log" + ] + + # 각 파일이 포함되어 있는지 확인 + for exp_path in expected_paths: + self.assertTrue(any(exp_path in p for p in paths), f"경로 {exp_path}가 결과에 없습니다") + + # 선택되지 않은 파일이 포함되지 않는지 확인 + self.assertFalse(any("file2.py" in p for p in paths)) + + def test_collect_all_content(self): + """collect_all_content 함수가 모든 파일의 내용을 올바르게 수집하는지 테스트합니다.""" + root_node = build_file_tree(self.test_dir) + + # 일부 파일 선택 해제 (영향을 주지 않아야 함) + root_node.children["dir1"].children["file2.py"].selected = False + + contents = collect_all_content(root_node, self.test_dir) + + # 모든 파일이 포함되어야 함 + self.assertEqual(len(contents), 8) # 실제 테스트 환경의 총 파일 수 + + # 파일 경로와 내용 확인 + paths = [path for path, _ in contents] + + base_name = os.path.basename(self.test_dir) + + # 주요 파일들이 포함되어 있는지만 확인 + expected_paths = [ + "file1.txt", + f"{base_name}{os.sep}dir1{os.sep}file2.py", + f"{base_name}{os.sep}dir2{os.sep}file3.md", + f"{base_name}{os.sep}dir2{os.sep}subdir{os.sep}file4.js", + "important.log" + ] + + # 각 파일이 포함되어 있는지 확인 + for exp_path in expected_paths: + self.assertTrue(any(exp_path in p for p in paths), f"경로 {exp_path}가 결과에 없습니다") + + def test_gitignore_filtering(self): + """`.gitignore` 패턴이 파일과 디렉토리를 올바르게 제외하는지 테스트합니다.""" + # 테스트용 .gitignore 파일 생성 + with open(os.path.join(self.test_dir, ".gitignore"), "w") as f: + f.write("*.log\nignored_dir/\nignored_file.txt\n!important.log") + + # 파일 트리 빌드 + root_node = build_file_tree(self.test_dir) + + # .gitignore에 의해 필터링되는지 확인 + root_children = list(root_node.children.keys()) + + # 무시되어야 하는 파일들 확인 + self.assertNotIn("ignored_dir", root_children) + self.assertNotIn("ignored_file.txt", root_children) + self.assertNotIn("error.log", root_children) + + # 제외된 파일은 포함되어야 함 + self.assertIn("important.log", root_children) + + # 기본 필터링도 여전히 적용되는지 확인 + self.assertNotIn(".git", root_children) + self.assertNotIn("__pycache__", root_children) + self.assertNotIn(".DS_Store", root_children) + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_gitignore.py b/test/test_gitignore.py new file mode 100644 index 0000000..625d876 --- /dev/null +++ b/test/test_gitignore.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_gitignore.py - Manual test for .gitignore functionality + +This script demonstrates the .gitignore functionality by creating a test directory +with various files and a .gitignore file, then building a file tree and displaying +which files are included/excluded. +""" + +import os +import sys +import tempfile +import shutil +from pathlib import Path + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from filetree import build_file_tree +from utils import load_gitignore_patterns + +def main(): + """Test .gitignore functionality with a manual test.""" + # Create a temporary test directory + temp_dir = tempfile.mkdtemp() + print(f"Test directory created at: {temp_dir}") + + try: + # Create .gitignore file + gitignore_content = """ +# This is a test .gitignore file +*.log +ignored_dir/ +ignored_file.txt +!important.log +""" + + with open(os.path.join(temp_dir, ".gitignore"), "w") as f: + f.write(gitignore_content) + + print("Created .gitignore with content:") + print(gitignore_content) + + # Create test files and directories + Path(os.path.join(temp_dir, "normal_file.txt")).write_text("Normal file content") + Path(os.path.join(temp_dir, "error.log")).write_text("Error log content") + Path(os.path.join(temp_dir, "important.log")).write_text("Important log content") + Path(os.path.join(temp_dir, "ignored_file.txt")).write_text("Ignored file content") + + os.makedirs(os.path.join(temp_dir, "normal_dir")) + os.makedirs(os.path.join(temp_dir, "ignored_dir")) + + Path(os.path.join(temp_dir, "normal_dir", "file_in_normal_dir.txt")).write_text("File in normal dir") + Path(os.path.join(temp_dir, "ignored_dir", "file_in_ignored_dir.txt")).write_text("File in ignored dir") + + # Load gitignore patterns and display them + patterns = load_gitignore_patterns(temp_dir) + print("\nLoaded .gitignore patterns:") + for pattern in patterns: + print(f" - {pattern}") + + # Build file tree + print("\nBuilding file tree...") + root_node = build_file_tree(temp_dir) + + # Print the file tree + print("\nFile tree contents (should exclude ignored files/dirs):") + def print_tree(node, indent=""): + print(f"{indent}- {node.name}{'/' if node.is_dir else ''}") + if node.is_dir and node.children: + for child_name in sorted(node.children.keys()): + print_tree(node.children[child_name], indent + " ") + + print_tree(root_node) + + # Print summary + print("\nSummary:") + print("The following should be INCLUDED:") + print(" - normal_file.txt") + print(" - important.log (exception to *.log pattern)") + print(" - normal_dir/") + print(" - normal_dir/file_in_normal_dir.txt") + + print("\nThe following should be EXCLUDED:") + print(" - error.log (matched by *.log)") + print(" - ignored_file.txt (directly listed)") + print(" - ignored_dir/ (directory pattern)") + print(" - ignored_dir/file_in_ignored_dir.txt (in ignored directory)") + + finally: + # Clean up + shutil.rmtree(temp_dir) + print(f"\nTest directory removed: {temp_dir}") + +if __name__ == "__main__": + main() diff --git a/test/test_output.py b/test/test_output.py new file mode 100644 index 0000000..b22bf88 --- /dev/null +++ b/test/test_output.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +output.py 모듈에 대한 테스트 코드 +""" + +import os +import sys +import unittest +import tempfile +import shutil +from unittest.mock import MagicMock, patch + +# 테스트 대상 모듈 임포트 +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +import output + +class TestOutput(unittest.TestCase): + """output.py 모듈의 함수들을 테스트하는 클래스""" + + def setUp(self): + """테스트를 위한 mock 객체 및 환경 설정""" + # Node 클래스 모의 객체 생성 + self.root_node = MagicMock() + self.root_node.name = "project" + self.root_node.is_dir = True + self.root_node.parent = None + self.root_node.children = {} + self.root_node.expanded = True + self.root_node.selected = True + + # 임시 디렉토리 생성 + self.temp_dir = tempfile.mkdtemp() + self.output_path = os.path.join(self.temp_dir, "output.txt") + + def tearDown(self): + """테스트 후 임시 디렉토리 제거""" + shutil.rmtree(self.temp_dir) + + def test_write_file_tree_to_string(self): + """write_file_tree_to_string 함수 테스트""" + # 간단한 파일 트리 구조 설정 + child1 = MagicMock() + child1.name = "file1.py" + child1.is_dir = False + child1.parent = self.root_node + child1.children = None + + child2 = MagicMock() + child2.name = "dir1" + child2.is_dir = True + child2.parent = self.root_node + child2.children = {} + + self.root_node.children = {"file1.py": child1, "dir1": child2} + + # 자식 디렉토리에 파일 추가 + subchild = MagicMock() + subchild.name = "file2.py" + subchild.is_dir = False + subchild.parent = child2 + subchild.children = None + + child2.children = {"file2.py": subchild} + + # 함수 실행 + result = output.write_file_tree_to_string(self.root_node) + + # 결과 검증 (루트 노드는 + # "file1.py"와 "dir1"가 결과에 포함되어 있어야 함 + self.assertIn("file1.py", result) + self.assertIn("dir1", result) + self.assertIn("file2.py", result) + + def test_write_output_file_txt_format(self): + """write_output_file 함수의 txt 형식 출력 테스트""" + # 간단한 파일 내용 설정 + file_contents = [ + ("file1.py", "print('Hello, world!')"), + ("dir1/file2.py", "import os\nprint(os.getcwd())") + ] + + # 함수 호출 + with patch("output.write_file_tree_to_string", return_value="└── file1.py\n└── dir1\n └── file2.py\n"): + output_path = output.write_output_file( + self.output_path, "/path/to/project", self.root_node, file_contents + ) + + # 출력 파일이 생성되었는지 확인 + self.assertTrue(os.path.exists(output_path)) + + # 출력 내용 확인 + with open(output_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 특정 문자열이 출력에 포함되었는지 확인 + self.assertIn("", content) + self.assertIn("", content) + self.assertIn("File: file1.py", content) + self.assertIn("print('Hello, world!')", content) + self.assertIn("File: dir1/file2.py", content) + self.assertIn("import os", content) + + def test_write_markdown_output(self): + """write_markdown_output 함수 테스트""" + # 간단한 파일 내용 설정 + file_contents = [ + ("file1.py", "print('Hello, world!')"), + ("dir1/file2.py", "import os\nprint(os.getcwd())") + ] + + # 함수 호출 + with patch("output.write_file_tree_to_string", return_value="└── file1.py\n└── dir1\n └── file2.py\n"): + output.write_markdown_output( + self.output_path, "/path/to/project", self.root_node, file_contents + ) + + # 출력 파일이 생성되었는지 확인 + self.assertTrue(os.path.exists(self.output_path)) + + # 출력 내용 확인 + with open(self.output_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 마크다운 형식이 올바른지 확인 + self.assertIn("# Project Files:", content) + self.assertIn("## 📁 File Structure", content) + self.assertIn("## 📄 File Contents", content) + self.assertIn("### file1.py", content) + self.assertIn("```py", content) + + def test_get_language_name(self): + """get_language_name 함수 테스트""" + self.assertEqual(output.get_language_name("py"), "Python") + self.assertEqual(output.get_language_name("js"), "JavaScript") + self.assertEqual(output.get_language_name("unknown"), "UNKNOWN") + + def test_write_llm_optimized_output(self): + """write_llm_optimized_output 함수 테스트""" + # 간단한 파일 내용 및 의존성 설정 + file_contents = [ + ("file1.py", "import file2\nprint('Hello, world!')"), + ("dir1/file2.py", "import os\nprint(os.getcwd())") + ] + + dependencies = { + "file1.py": {"dir1/file2.py"}, + "dir1/file2.py": {"os"} + } + + # 함수 호출 + with patch("output.write_file_tree_to_string", return_value="└── file1.py\n└── dir1\n └── file2.py\n"): + output.write_llm_optimized_output( + self.output_path, "/path/to/project", self.root_node, file_contents, dependencies + ) + + # 출력 파일이 생성되었는지 확인 + self.assertTrue(os.path.exists(self.output_path)) + + # 출력 내용 확인 + with open(self.output_path, 'r', encoding='utf-8') as f: + content = f.read() + + # LLM 최적화 형식이 올바른지 확인 + self.assertIn("# PROJECT ANALYSIS FOR AI ASSISTANT", content) + self.assertIn("## 📦 GENERAL INFORMATION", content) + self.assertIn("## 🗂️ PROJECT STRUCTURE", content) + self.assertIn("## 🔄 FILE RELATIONSHIPS", content) + self.assertIn("## 📄 FILE CONTENTS", content) + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/test/test_selector.py b/test/test_selector.py new file mode 100644 index 0000000..90f4bc2 --- /dev/null +++ b/test/test_selector.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_selector_updated.py - 리팩토링된 selector.py 모듈 테스트 + +리팩토링된 selector.py 모듈의 함수들을 테스트하는 코드입니다. +""" + +import os +import sys +import unittest +from unittest.mock import patch, MagicMock +from pathlib import Path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from filetree import Node +from selector import interactive_selection + +class TestSelector(unittest.TestCase): + """selector 모듈의 함수들을 테스트하는 클래스""" + + def setUp(self): + """테스트 전에 파일 트리 구조를 설정합니다.""" + # 루트 노드 생성 + self.root_node = Node("test_root", True) + + # 디렉토리 추가 + dir1 = Node("dir1", True, self.root_node) + dir2 = Node("dir2", True, self.root_node) + self.root_node.children["dir1"] = dir1 + self.root_node.children["dir2"] = dir2 + + # 파일 추가 + file1 = Node("file1.txt", False, self.root_node) + file2 = Node("file2.py", False, dir1) + file3 = Node("file3.md", False, dir2) + + self.root_node.children["file1.txt"] = file1 + dir1.children["file2.py"] = file2 + dir2.children["file3.md"] = file3 + + @patch('curses.wrapper') + def test_interactive_selection(self, mock_wrapper): + """interactive_selection 함수가 올바르게 curses를 초기화하고 FileSelector를 실행하는지 테스트합니다.""" + # 모의 결과 설정 + file_selector_mock = MagicMock() + file_selector_mock.run.return_value = True + + # mock_wrapper가 호출될 때 file_selector_mock.run을 반환하도록 설정 + mock_wrapper.side_effect = lambda func: func(MagicMock()) + + # FileSelector 클래스 모의 + with patch('selector.FileSelector', return_value=file_selector_mock): + # interactive_selection 함수 호출 + result = interactive_selection(self.root_node) + + # curses.wrapper가 호출되었는지 확인 + mock_wrapper.assert_called_once() + + # FileSelector가 생성되고 run 메서드가 호출되었는지 확인 + file_selector_mock.run.assert_called_once() + + # 결과가 올바른지 확인 + self.assertTrue(result) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/test/test_selector_actions.py b/test/test_selector_actions.py new file mode 100644 index 0000000..72ac46f --- /dev/null +++ b/test/test_selector_actions.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_selector_actions.py - selector_actions.py 모듈 테스트 + +selector_actions.py 모듈의 함수들을 테스트하는 코드입니다. +""" + +import os +import sys +import unittest +from pathlib import Path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from filetree import Node +from selector_actions import ( + toggle_selection, toggle_expand, select_all, + expand_all, apply_search_filter, toggle_current_dir_selection +) + +class TestSelectorActions(unittest.TestCase): + """selector_actions 모듈의 함수들을 테스트하는 클래스""" + + def setUp(self): + """테스트 전에 파일 트리 구조를 설정합니다.""" + # 루트 노드 생성 + self.root_node = Node("test_root", True) + + # 디렉토리 추가 + dir1 = Node("dir1", True, self.root_node) + dir2 = Node("dir2", True, self.root_node) + self.root_node.children["dir1"] = dir1 + self.root_node.children["dir2"] = dir2 + + # 파일 추가 + file1 = Node("file1.txt", False, self.root_node) + file2 = Node("file2.py", False, dir1) + file3 = Node("file3.md", False, dir2) + + self.root_node.children["file1.txt"] = file1 + dir1.children["file2.py"] = file2 + dir2.children["file3.md"] = file3 + + # 기본적으로 모든 노드가 선택됨 + for node_name, node in self.root_node.children.items(): + node.selected = True + if node.is_dir and node.children: + for child_name, child in node.children.items(): + child.selected = True + + def test_toggle_selection(self): + """toggle_selection 함수가 노드와 하위 노드의 선택 상태를 올바르게 전환하는지 테스트합니다.""" + # 모든 노드의 선택 상태 확인 + self.assertTrue(self.root_node.selected) + self.assertTrue(self.root_node.children["dir1"].selected) + self.assertTrue(self.root_node.children["dir1"].children["file2.py"].selected) + + # dir1의 선택 상태 토글 + toggle_selection(self.root_node.children["dir1"]) + + # dir1과 그 하위 노드의 선택 상태가 모두 변경되었는지 확인 + self.assertFalse(self.root_node.children["dir1"].selected) + self.assertFalse(self.root_node.children["dir1"].children["file2.py"].selected) + + # 다른 노드들은 영향을 받지 않았는지 확인 + self.assertTrue(self.root_node.selected) + self.assertTrue(self.root_node.children["dir2"].selected) + self.assertTrue(self.root_node.children["dir2"].children["file3.md"].selected) + + def test_select_all(self): + """select_all 함수가 모든 노드의 선택 상태를 올바르게 설정하는지 테스트합니다.""" + # 모든 노드 선택 해제 + select_all(self.root_node, False) + + # 모든 노드가 선택 해제되었는지 확인 + def check_selection_state(node, expected_state): + self.assertEqual(node.selected, expected_state) + if node.is_dir and node.children: + for child in node.children.values(): + check_selection_state(child, expected_state) + + check_selection_state(self.root_node, False) + + # 모든 노드 선택 + select_all(self.root_node, True) + + # 모든 노드가 선택되었는지 확인 + check_selection_state(self.root_node, True) + + def test_expand_all(self): + """expand_all 함수가 모든 디렉토리의 확장 상태를 올바르게 설정하는지 테스트합니다.""" + # 모든 디렉토리 접기 + expand_all(self.root_node, False) + + # 모든 디렉토리가 접혀있는지 확인 + def check_expanded_state(node, expected_state): + if node.is_dir: + self.assertEqual(node.expanded, expected_state) + if node.children: + for child in node.children.values(): + check_expanded_state(child, expected_state) + + check_expanded_state(self.root_node, False) + + # 모든 디렉토리 펼치기 + expand_all(self.root_node, True) + + # 모든 디렉토리가 펼쳐있는지 확인 + check_expanded_state(self.root_node, True) + + def test_toggle_expand(self): + """toggle_expand 함수가 디렉토리의 확장 상태를 올바르게 전환하는지 테스트합니다.""" + dir1 = self.root_node.children["dir1"] + + # 초기 상태 확인 + self.assertTrue(dir1.expanded) + + # 확장 상태 토글 + toggle_expand(dir1) + + # 토글 후 상태 확인 + self.assertFalse(dir1.expanded) + + # 다시 토글 + toggle_expand(dir1) + + # 다시 원래 상태로 돌아왔는지 확인 + self.assertTrue(dir1.expanded) + + def test_toggle_current_dir_selection(self): + """toggle_current_dir_selection 함수가 현재 디렉토리의 파일들만 선택 상태를 전환하는지 테스트합니다.""" + dir1 = self.root_node.children["dir1"] + + # dir1의 자식들이 모두 선택되어 있는지 확인 + self.assertTrue(dir1.children["file2.py"].selected) + + # 현재 디렉토리 선택 상태 토글 + toggle_current_dir_selection(dir1) + + # dir1의 자식들이 선택 해제되었는지 확인 + self.assertFalse(dir1.children["file2.py"].selected) + + # 다른 노드들은 영향을 받지 않았는지 확인 + self.assertTrue(self.root_node.children["file1.txt"].selected) + self.assertTrue(self.root_node.children["dir2"].children["file3.md"].selected) + + # 다시 토글 + toggle_current_dir_selection(dir1) + + # dir1의 자식들이 다시 선택되었는지 확인 + self.assertTrue(dir1.children["file2.py"].selected) + + def test_apply_search_filter(self): + """apply_search_filter 함수가 검색어에 따라 올바르게 필터링하는지 테스트합니다.""" + # 'py' 확장자 파일만 검색 + search_query = r"\.py$" + visible_nodes = [] + original_nodes = [(node, 0) for node in [self.root_node]] + + # 검색 필터 적용 + success, error_message = apply_search_filter( + search_query, False, self.root_node, original_nodes, visible_nodes + ) + + # 검색 성공 여부 확인 + self.assertTrue(success) + self.assertEqual(error_message, "") + + # 필터링된 노드 수 확인 (file2.py와 그 부모 노드들이 포함되어야 함) + # 루트, dir1, file2.py가 보여야 함 + self.assertEqual(len(visible_nodes), 3) + + # file2.py가 결과에 포함되었는지 확인 + node_names = [node[0].name for node in visible_nodes] + self.assertIn("file2.py", node_names) + self.assertIn("dir1", node_names) + self.assertIn("test_root", node_names) + + # file1.txt와 file3.md는 결과에 포함되지 않아야 함 + self.assertNotIn("file1.txt", node_names) + self.assertNotIn("file3.md", node_names) + + # 존재하지 않는 패턴으로 검색 + search_query = "nonexistent" + visible_nodes = [] + + # 검색 필터 적용 + success, error_message = apply_search_filter( + search_query, False, self.root_node, original_nodes, visible_nodes + ) + + # 검색 결과가 없을 때의 처리 확인 (새로운 동작 방식: visible_nodes는 빈 리스트가 되어야 함) + self.assertFalse(success) + self.assertEqual(error_message, "검색 결과 없음") + # self.assertEqual(visible_nodes, original_nodes) # 이전 동작 + self.assertEqual(len(visible_nodes), 0) # 수정된 동작: 빈 리스트여야 함 + + +# Helper to get node names from a list of (Node, level) tuples +def get_node_names(nodes_with_levels): + return sorted([node.name for node, level in nodes_with_levels]) + +class TestApplySearchFilterMultiPattern(unittest.TestCase): + def setUp(self): + # test_root + # ├── common_utils + # │ ├── script.py + # │ └── Helper.PY + # ├── data_files + # │ ├── report.md + # │ └── DATA.log + # ├── another_empty_dir + # ├── main_test.py + # └── notes.txt + self.root = Node("test_root", is_dir=True) + self.common_utils = Node("common_utils", is_dir=True, parent=self.root) + self.script_py = Node("script.py", is_dir=False, parent=self.common_utils) + self.helper_PY = Node("Helper.PY", is_dir=False, parent=self.common_utils) # Uppercase extension + self.common_utils.children = {"script.py": self.script_py, "Helper.PY": self.helper_PY} + + self.data_files = Node("data_files", is_dir=True, parent=self.root) + self.report_md = Node("report.md", is_dir=False, parent=self.data_files) + self.data_log = Node("DATA.log", is_dir=False, parent=self.data_files) # Uppercase name + self.data_files.children = {"report.md": self.report_md, "DATA.log": self.data_log} + + self.another_empty_dir = Node("another_empty_dir", is_dir=True, parent=self.root) # Empty dir + + self.main_test_py = Node("main_test.py", is_dir=False, parent=self.root) + self.notes_txt = Node("notes.txt", is_dir=False, parent=self.root) + + self.root.children = { + "common_utils": self.common_utils, + "data_files": self.data_files, + "another_empty_dir": self.another_empty_dir, + "main_test.py": self.main_test_py, + "notes.txt": self.notes_txt + } + + # Manually set expanded for testing parent expansion logic later + self.root.expanded = False + self.common_utils.expanded = False + self.data_files.expanded = False + + # Original nodes for reference (full tree) + # Correctly import flatten_tree from filetree module + current_dir = Path(__file__).resolve().parent + project_root = current_dir.parent + sys.path.append(str(project_root)) # Add project root to sys.path + from filetree import flatten_tree # Now this import should work + + self.original_nodes = flatten_tree(self.root) + # Ensure all nodes in original_nodes have their parents set for the functions to work + for node, _ in self.original_nodes: + if node.parent: # if not root + # this is already handled by Node class structure, but good to be aware + pass + + + def test_or_logic_multiple_patterns(self): + visible_nodes_out = [] + queries = ["*.txt", "*.md"] # Case-insensitive by default + success, msg = apply_search_filter(queries, False, self.root, self.original_nodes, visible_nodes_out) + self.assertTrue(success) + self.assertEqual(msg, "") + self.assertEqual(get_node_names(visible_nodes_out), sorted(["test_root", "data_files", "report.md", "notes.txt"])) + + def test_wildcard_usage_multiple_patterns(self): + visible_nodes_out = [] + queries = ["*util*", "test*.py"] # common_utils, main_test.py + success, msg = apply_search_filter(queries, False, self.root, self.original_nodes, visible_nodes_out) + self.assertTrue(success) + self.assertEqual(msg, "") + # common_utils and its children (script.py, Helper.PY) + main_test.py + root + expected_nodes = ["test_root", "common_utils", "script.py", "Helper.PY", "main_test.py"] + self.assertEqual(get_node_names(visible_nodes_out), sorted(expected_nodes)) + + def test_case_sensitive_search(self): + visible_nodes_out = [] + queries = ["DATA.*", "*.PY"] # DATA.log, Helper.PY + success, msg = apply_search_filter(queries, True, self.root, self.original_nodes, visible_nodes_out) # case_sensitive = True + self.assertTrue(success) + self.assertEqual(msg, "") + expected_nodes = ["test_root", "common_utils", "Helper.PY", "data_files", "DATA.log"] + self.assertEqual(get_node_names(visible_nodes_out), sorted(expected_nodes)) + + def test_case_insensitive_search(self): + visible_nodes_out = [] + # Using same queries as sensitive, but expecting more matches + queries = ["DATA.*", "*.PY"] # data.log, DATA.log, script.py, Helper.PY + success, msg = apply_search_filter(queries, False, self.root, self.original_nodes, visible_nodes_out) # case_sensitive = False + self.assertTrue(success) + self.assertEqual(msg, "") + expected_nodes = ["test_root", "common_utils", "script.py", "Helper.PY", "data_files", "DATA.log"] + self.assertEqual(get_node_names(visible_nodes_out), sorted(expected_nodes)) + + def test_no_matching_results(self): + visible_nodes_out = [] + queries = ["nonexistent.file", "*.foo"] + success, msg = apply_search_filter(queries, False, self.root, self.original_nodes, visible_nodes_out) + self.assertFalse(success) + self.assertEqual(msg, "검색 결과 없음") + self.assertEqual(len(visible_nodes_out), 0) # Should be an empty list + + def test_empty_query_list(self): + visible_nodes_out = [] + queries = [] + success, msg = apply_search_filter(queries, False, self.root, self.original_nodes, visible_nodes_out) + self.assertTrue(success) + self.assertEqual(msg, "") + self.assertEqual(get_node_names(visible_nodes_out), get_node_names(self.original_nodes)) # Should be original_nodes + + def test_query_list_with_empty_whitespace_strings(self): + visible_nodes_out = [] + queries = ["", " *.py ", " "] # Should behave like ["*.py"] + success, msg = apply_search_filter(queries, False, self.root, self.original_nodes, visible_nodes_out) + self.assertTrue(success) + self.assertEqual(msg, "") + # Expecting script.py, Helper.PY (due to case-insensitivity), main_test.py and their parents + expected_nodes = ["test_root", "common_utils", "script.py", "Helper.PY", "main_test.py"] + self.assertEqual(get_node_names(visible_nodes_out), sorted(expected_nodes)) + + def test_invalid_regular_expression(self): + visible_nodes_out = [] + queries = ["*[.py"] # Invalid regex + success, msg = apply_search_filter(queries, False, self.root, self.original_nodes, visible_nodes_out) + self.assertFalse(success) + self.assertEqual(msg, "잘못된 정규식") + # visible_nodes_out might be undefined or empty, not strictly specified for this error. + # The primary check is the return tuple. + + def test_parent_directory_inclusion_and_expansion(self): + # Reset expansion states for this specific test + self.root.expanded = False + self.common_utils.expanded = False + self.data_files.expanded = False + + visible_nodes_out = [] + # Match one file in common_utils and one in data_files + queries = ["script.py", "report.md"] + success, msg = apply_search_filter(queries, False, self.root, self.original_nodes, visible_nodes_out) + self.assertTrue(success) + self.assertEqual(msg, "") + + expected_node_names = ["test_root", "common_utils", "script.py", "data_files", "report.md"] + self.assertEqual(get_node_names(visible_nodes_out), sorted(expected_node_names)) + + # Check expansion status + # Create a map of nodes from visible_nodes_out for easy lookup + result_nodes_map = {node.name: node for node, level in visible_nodes_out} + + self.assertTrue(result_nodes_map["test_root"].expanded, "Root node should be expanded") + self.assertTrue(result_nodes_map["common_utils"].expanded, "common_utils should be expanded") + self.assertTrue(result_nodes_map["data_files"].expanded, "data_files should be expanded") + # another_empty_dir should not be in results and thus its expansion state is not set by this function + self.assertFalse(self.another_empty_dir.expanded, "another_empty_dir should not be expanded by this search") + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/test/test_selector_ui.py b/test/test_selector_ui.py new file mode 100644 index 0000000..ef20a4f --- /dev/null +++ b/test/test_selector_ui.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_selector_ui.py - selector_ui.py 모듈 테스트 + +selector_ui.py 모듈의 클래스와 메서드들을 테스트하는 코드입니다. +""" + +import os +import sys +import unittest +import curses +from unittest.mock import patch, MagicMock +from pathlib import Path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from filetree import Node +from selector_ui import FileSelector + +class TestFileSelector(unittest.TestCase): + """FileSelector 클래스를 테스트하는 클래스""" + + def setUp(self): + """테스트 전에 파일 트리 구조를 설정합니다.""" + # 루트 노드 생성 + self.root_node = Node("test_root", True) + + # 디렉토리 추가 + dir1 = Node("dir1", True, self.root_node) + dir2 = Node("dir2", True, self.root_node) + self.root_node.children["dir1"] = dir1 + self.root_node.children["dir2"] = dir2 + + # 파일 추가 + file1 = Node("file1.txt", False, self.root_node) + file2 = Node("file2.py", False, dir1) + file3 = Node("file3.md", False, dir2) + + self.root_node.children["file1.txt"] = file1 + dir1.children["file2.py"] = file2 + dir2.children["file3.md"] = file3 + + # curses 스크린 모의 객체 생성 + self.mock_stdscr = MagicMock() + self.mock_stdscr.getmaxyx.return_value = (24, 80) # 24행 80열의 화면 + + @patch('curses.color_pair') + @patch('curses.init_pair') + @patch('curses.start_color') + @patch('curses.use_default_colors') + @patch('curses.curs_set') + def test_initialize_curses(self, mock_curs_set, mock_use_default_colors, mock_start_color, mock_init_pair, mock_color_pair): + """initialize_curses 메서드가 올바르게 curses를 초기화하는지 테스트합니다.""" + # FileSelector 인스턴스 생성 + selector = FileSelector(self.root_node, self.mock_stdscr) + + # curses 초기화 함수들이 호출되었는지 확인 + mock_start_color.assert_called_once() + mock_use_default_colors.assert_called_once() + mock_curs_set.assert_called_once_with(0) # 커서 숨기기 + + # 색상 쌍 초기화 확인 + self.assertEqual(mock_init_pair.call_count, 7) # 7개의 색상 쌍 + + # keypad 함수 호출 확인 + self.mock_stdscr.keypad.assert_called_once_with(True) + + @patch('curses.color_pair') + @patch('curses.init_pair') + @patch('curses.start_color') + @patch('curses.use_default_colors') + @patch('curses.curs_set') + def test_update_dimensions(self, mock_curs_set, mock_use_default_colors, mock_start_color, mock_init_pair, mock_color_pair): + """update_dimensions 메서드가 올바르게 화면 크기를 업데이트하는지 테스트합니다.""" + # FileSelector 인스턴스 생성 + selector = FileSelector(self.root_node, self.mock_stdscr) + + # 화면 크기 설정 + self.mock_stdscr.getmaxyx.return_value = (30, 100) # 변경된 화면 크기 + + # 크기 업데이트 + selector.update_dimensions() + + # 업데이트된 크기 확인 + self.assertEqual(selector.height, 30) + self.assertEqual(selector.width, 100) + self.assertEqual(selector.max_visible, 24) # 30 - 6 + + @patch('curses.color_pair') + @patch('curses.init_pair') + @patch('curses.start_color') + @patch('curses.use_default_colors') + @patch('curses.curs_set') + @patch('selector_ui.toggle_expand') + def test_handle_vim_navigation(self, mock_toggle_expand, mock_curs_set, mock_use_default_colors, mock_start_color, mock_init_pair, mock_color_pair): + """handle_vim_navigation 메서드가 vim 스타일 네비게이션 키를 올바르게 처리하는지 테스트합니다.""" + # FileSelector 인스턴스 생성 + selector = FileSelector(self.root_node, self.mock_stdscr) + + # j 키 (아래로 이동) + result = selector.handle_vim_navigation(ord('j')) + self.assertTrue(result) + self.assertEqual(selector.current_index, 1) # 인덱스가 1 증가 + + # k 키 (위로 이동) + result = selector.handle_vim_navigation(ord('k')) + self.assertTrue(result) + self.assertEqual(selector.current_index, 0) # 인덱스가 다시 0으로 감소 + + # 현재 노드 설정 + dir_node = None + for node, level in selector.visible_nodes: + if node.is_dir: + dir_node = node + break + + if dir_node: + # 디렉토리 노드를 현재 선택으로 설정 + selector.visible_nodes = [(dir_node, 0)] + selector.current_index = 0 + + # l 키 (디렉토리 열기) + dir_node.expanded = False # 우선 닫은 상태로 설정 + mock_toggle_expand.return_value = selector.visible_nodes + result = selector.handle_vim_navigation(ord('l')) + self.assertTrue(result) + mock_toggle_expand.assert_called_once() + + @patch('curses.color_pair') + @patch('curses.init_pair') + @patch('curses.start_color') + @patch('curses.use_default_colors') + @patch('curses.curs_set') + def test_toggle_search_mode(self, mock_curs_set, mock_use_default_colors, mock_start_color, mock_init_pair, mock_color_pair): + """toggle_search_mode 메서드가 검색 모드를 올바르게 전환하는지 테스트합니다.""" + # FileSelector 인스턴스 생성 + selector = FileSelector(self.root_node, self.mock_stdscr) + + # 초기 상태 확인 + self.assertFalse(selector.search_mode) + self.assertEqual(selector.search_buffer, "") + + # 검색 모드 활성화 + selector.toggle_search_mode() + + # 검색 모드가 활성화되었는지 확인 + self.assertTrue(selector.search_mode) + self.assertEqual(selector.search_buffer, "") + self.assertEqual(selector.original_nodes, selector.visible_nodes) + + # 검색 모드 비활성화 + selector.toggle_search_mode() + + # 검색 모드가 비활성화되었는지 확인 + self.assertFalse(selector.search_mode) + self.assertEqual(selector.search_buffer, "") + + @patch('curses.color_pair') + @patch('curses.init_pair') + @patch('curses.start_color') + @patch('curses.use_default_colors') + @patch('curses.curs_set') + @patch('curses.napms') + @patch('selector_ui.apply_search_filter') + def test_apply_search_filter(self, mock_apply_search_filter, mock_napms, mock_curs_set, mock_use_default_colors, mock_start_color, mock_init_pair, mock_color_pair): + """apply_search_filter 메서드가 검색 필터를 올바르게 적용하는지 테스트합니다.""" + # FileSelector 인스턴스 생성 + selector = FileSelector(self.root_node, self.mock_stdscr) + + # 검색 쿼리 설정 + selector.search_query = "test" + selector.original_nodes = [] + + # apply_search_filter 모의 함수 설정 + mock_apply_search_filter.return_value = (True, "") + + # 검색 필터 적용 + selector.apply_search_filter() + + # apply_search_filter 함수가 호출되었는지 확인 + mock_apply_search_filter.assert_called_once_with( + "test", + False, + selector.root_node, + selector.original_nodes, + selector.visible_nodes + ) + + # 오류 발생 시나리오 테스트 + mock_apply_search_filter.reset_mock() + mock_apply_search_filter.return_value = (False, "검색 결과 없음") + + # 검색 필터 적용 + selector.apply_search_filter() + + # 오류 메시지가 표시되었는지 확인 + self.mock_stdscr.addstr.assert_called_with(0, selector.width - 25, "검색 결과 없음", mock_color_pair.return_value) + self.mock_stdscr.refresh.assert_called_once() + + @patch('curses.color_pair') + @patch('curses.init_pair') + @patch('curses.start_color') + @patch('curses.use_default_colors') + @patch('curses.curs_set') + def test_handle_search_input(self, mock_curs_set, mock_use_default_colors, mock_start_color, mock_init_pair, mock_color_pair): + """handle_search_input 메서드가 검색 입력을 올바르게 처리하는지 테스트합니다.""" + # FileSelector 인스턴스 생성 + selector = FileSelector(self.root_node, self.mock_stdscr) + selector.search_mode = True + + # ESC 키 (검색 취소) + result = selector.handle_search_input(27) + self.assertTrue(result) + self.assertFalse(selector.search_mode) + self.assertEqual(selector.search_buffer, "") + self.assertEqual(selector.search_query, "") + + # 검색 모드 다시 활성화 + selector.search_mode = True + selector.search_buffer = "test" + + # Enter 키 (검색 실행) + with patch.object(selector, 'apply_search_filter') as mock_apply_search: + result = selector.handle_search_input(10) # Enter 키 + self.assertTrue(result) + self.assertFalse(selector.search_mode) + self.assertEqual(selector.search_query, "test") + mock_apply_search.assert_called_once() + + # 검색 모드 다시 활성화 + selector.search_mode = True + selector.search_buffer = "test" + + # Backspace 키 (문자 삭제) + result = selector.handle_search_input(8) # Backspace 키 + self.assertTrue(result) + self.assertEqual(selector.search_buffer, "tes") + + # ^ 키 (대소문자 구분 토글) + result = selector.handle_search_input(ord('^')) + self.assertTrue(result) + self.assertTrue(selector.case_sensitive) + + # 일반 문자 키 (문자 추가) + result = selector.handle_search_input(ord('a')) + self.assertTrue(result) + self.assertEqual(selector.search_buffer, "tesa") + + # 지원되지 않는 키 + result = selector.handle_search_input(255) # 지원되지 않는 키 + self.assertFalse(result) + + @patch('curses.color_pair') + @patch('curses.init_pair') + @patch('curses.start_color') + @patch('curses.use_default_colors') + @patch('curses.curs_set') + @patch('selector_ui.toggle_selection') + @patch('selector_ui.select_all') + def test_process_key(self, mock_select_all, mock_toggle_selection, mock_curs_set, mock_use_default_colors, mock_start_color, mock_init_pair, mock_color_pair): + """process_key 메서드가 키 입력을 올바르게 처리하는지 테스트합니다.""" + # FileSelector 인스턴스 생성 + selector = FileSelector(self.root_node, self.mock_stdscr) + + # 검색 모드에서의 키 처리 테스트 + selector.search_mode = True + with patch.object(selector, 'handle_search_input', return_value=True) as mock_handle_search: + result = selector.process_key(ord('a')) + self.assertTrue(result) + mock_handle_search.assert_called_once_with(ord('a')) + + # 일반 모드로 변경 + selector.search_mode = False + + # / 키 (검색 모드 진입) + with patch.object(selector, 'toggle_search_mode') as mock_toggle_search: + result = selector.process_key(ord('/')) + self.assertTrue(result) + mock_toggle_search.assert_called_once() + + # Vim 스타일 네비게이션 테스트 + with patch.object(selector, 'handle_vim_navigation', return_value=True) as mock_vim_nav: + result = selector.process_key(ord('j')) + self.assertTrue(result) + mock_vim_nav.assert_called_once_with(ord('j')) + + # 화살표 키 테스트 (위로 이동) + with patch('curses.KEY_UP', 259): + result = selector.process_key(curses.KEY_UP) + self.assertTrue(result) + self.assertEqual(selector.current_index, 0) # 이미 0이므로 변경 없음 + + # 화살표 키 테스트 (아래로 이동) + with patch('curses.KEY_DOWN', 258): + result = selector.process_key(curses.KEY_DOWN) + self.assertTrue(result) + self.assertEqual(selector.current_index, 1) # 1로 증가 + + # 공백 키 (선택 토글) + if selector.visible_nodes and len(selector.visible_nodes) > 0: + node, _ = selector.visible_nodes[selector.current_index] + result = selector.process_key(ord(' ')) + self.assertTrue(result) + mock_toggle_selection.assert_called_once_with(node) + + # 'a' 키 (모두 선택) + result = selector.process_key(ord('a')) + self.assertTrue(result) + mock_select_all.assert_called_once_with(selector.root_node, True) + + # 종료 키 테스트 + result = selector.process_key(ord('x')) + self.assertFalse(result) + + result = selector.process_key(ord('d')) + self.assertFalse(result) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/test/test_utils.py b/test/test_utils.py new file mode 100644 index 0000000..db35e58 --- /dev/null +++ b/test/test_utils.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +test_utils.py - utils.py 모듈 테스트 + +utils.py 모듈의 함수들을 테스트하는 코드입니다. +""" + +import os +import sys +import tempfile +import unittest +from pathlib import Path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from utils import get_language_name, generate_output_filename, should_ignore_path, load_gitignore_patterns + +class TestUtils(unittest.TestCase): + """utils.py 모듈에 있는 함수들을 테스트하는 클래스""" + + def test_get_language_name(self): + """get_language_name 함수가 올바른 언어 이름을 반환하는지 테스트합니다.""" + self.assertEqual(get_language_name('py'), 'Python') + self.assertEqual(get_language_name('js'), 'JavaScript') + self.assertEqual(get_language_name('cpp'), 'C++') + self.assertEqual(get_language_name('unknown'), 'UNKNOWN') # 알 수 없는 확장자 + + def test_generate_output_filename(self): + """generate_output_filename 함수가 올바른 출력 파일 이름을 생성하는지 테스트합니다.""" + # 임시 디렉토리 생성 + with tempfile.TemporaryDirectory() as temp_dir: + # 기본 이름 확인 + output_name = generate_output_filename(temp_dir, 'txt') + self.assertEqual(output_name, f"{os.path.basename(temp_dir)}.txt") + + # 이미 존재하는 파일이 있는 경우 확인 + Path(output_name).touch() # 파일 생성 + output_name_2 = generate_output_filename(temp_dir, 'txt') + self.assertEqual(output_name_2, f"{os.path.basename(temp_dir)}(1).txt") + + # 다른 형식 확인 + md_output = generate_output_filename(temp_dir, 'md') + self.assertEqual(md_output, f"{os.path.basename(temp_dir)}.md") + + def test_should_ignore_path(self): + """should_ignore_path 함수가 올바르게 무시할 경로를 식별하는지 테스트합니다.""" + # 기본 무시 패턴으로 테스트 + self.assertTrue(should_ignore_path('.git')) + self.assertTrue(should_ignore_path('__pycache__')) + self.assertTrue(should_ignore_path('example.pyc')) + self.assertTrue(should_ignore_path('.DS_Store')) + + # 무시하지 않아야 할 경로 + self.assertFalse(should_ignore_path('main.py')) + self.assertFalse(should_ignore_path('README.md')) + + # 사용자 정의 패턴으로 테스트 + custom_patterns = ['*.log', 'temp_*', 'backup'] + self.assertTrue(should_ignore_path('example.log', custom_patterns)) + self.assertTrue(should_ignore_path('temp_file', custom_patterns)) + self.assertTrue(should_ignore_path('backup', custom_patterns)) + self.assertFalse(should_ignore_path('main.py', custom_patterns)) + + # gitignore 스타일 패턴으로 테스트 + gitignore_patterns = ['*.log', 'ignored_dir/', 'ignored_file.txt', '!important.log'] + self.assertTrue(should_ignore_path('error.log', gitignore_patterns)) + self.assertTrue(should_ignore_path('ignored_file.txt', gitignore_patterns)) + # 부정 패턴 테스트 + self.assertFalse(should_ignore_path('important.log', gitignore_patterns)) + # 디렉토리 패턴 테스트 (디렉토리 경로로 설정) + temp_dir = tempfile.TemporaryDirectory() + ignored_dir = os.path.join(temp_dir.name, 'ignored_dir') + os.makedirs(ignored_dir) + self.assertTrue(should_ignore_path(ignored_dir, gitignore_patterns)) + temp_dir.cleanup() + + def test_load_gitignore_patterns(self): + """load_gitignore_patterns 함수가 .gitignore 파일에서 패턴을 올바르게 로드하는지 테스트합니다.""" + # 임시 디렉토리 생성 + with tempfile.TemporaryDirectory() as temp_dir: + # 테스트용 .gitignore 파일 생성 + gitignore_content = """ +# 주석 라인 +*.log +ignored_dir/ + +# 빈 라인 + +ignored_file.txt +!important.log + """ + gitignore_path = os.path.join(temp_dir, ".gitignore") + with open(gitignore_path, "w") as f: + f.write(gitignore_content) + + # 패턴 로드 + patterns = load_gitignore_patterns(temp_dir) + + # 예상 패턴 확인 + self.assertEqual(len(patterns), 4) + self.assertIn("*.log", patterns) + self.assertIn("ignored_dir/", patterns) + self.assertIn("ignored_file.txt", patterns) + self.assertIn("!important.log", patterns) + + # 주석과 빈 라인은 포함되지 않아야 함 + self.assertNotIn("# 주석 라인", patterns) + self.assertNotIn("# 빈 라인", patterns) + self.assertNotIn("", patterns) + + # .gitignore 파일이 없는 경우 + non_gitignore_dir = os.path.join(temp_dir, "subdir") + os.makedirs(non_gitignore_dir) + self.assertEqual(load_gitignore_patterns(non_gitignore_dir), []) + +if __name__ == '__main__': + unittest.main() diff --git a/uninstall.sh b/uninstall.sh old mode 100644 new mode 100755 index a4bc5cf..68f3b5c --- a/uninstall.sh +++ b/uninstall.sh @@ -12,6 +12,15 @@ else echo "Executable not found at $CODESELECT_PATH" fi +# Remove module directory +CODESELECT_DIR="$HOME/.local/lib/codeselect" +if [ -d "$CODESELECT_DIR" ]; then + rm -rf "$CODESELECT_DIR" + echo "Removed module directory from $CODESELECT_DIR" +else + echo "Module directory not found at $CODESELECT_DIR" +fi + # Remove bash completion if it exists COMPLETION_FILE="$HOME/.local/share/bash-completion/completions/codeselect" if [ -f "$COMPLETION_FILE" ]; then @@ -29,6 +38,8 @@ elif [[ "$SHELL" == *"bash"* ]]; then else SHELL_CONFIG="$HOME/.bashrc" fi +elif [[ "$SHELL" == *"fish"* ]]; then + SHELL_CONFIG="$HOME/.config/fish/config.fish" else SHELL_CONFIG="$HOME/.profile" fi @@ -38,8 +49,13 @@ if [ -f "$SHELL_CONFIG" ]; then # Create a backup cp "$SHELL_CONFIG" "${SHELL_CONFIG}.bak" - # Remove the PATH line - grep -v 'export PATH="$HOME/.local/bin:$PATH"' "$SHELL_CONFIG" > "${SHELL_CONFIG}.tmp" + # Remove the PATH line (fish와 다른 쉘에 따라 다른 처리) + if [[ "$SHELL" == *"fish"* ]]; then + grep -v 'set -gx PATH $HOME/.local/bin $PATH' "$SHELL_CONFIG" > "${SHELL_CONFIG}.tmp" + else + grep -v 'export PATH="$HOME/.local/bin:$PATH"' "$SHELL_CONFIG" > "${SHELL_CONFIG}.tmp" + fi + mv "${SHELL_CONFIG}.tmp" "$SHELL_CONFIG" echo "Removed PATH entry from $SHELL_CONFIG" echo "Backup created at ${SHELL_CONFIG}.bak" @@ -48,12 +64,14 @@ fi echo " Uninstallation complete! +The following files and directories have been removed: +- Executable: $HOME/.local/bin/codeselect +- Module directory: $HOME/.local/lib/codeselect/ +- Bash completion: $HOME/.local/share/bash-completion/completions/codeselect +- PATH entry in shell configuration + Note: You may need to restart your terminal or run: source $SHELL_CONFIG - -If you installed CodeSelect as the only tool in ~/.local/bin, -you can also delete this directory: - rm -rf ~/.local/bin " exit 0 diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..b598730 --- /dev/null +++ b/utils.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +utils.py - 공통 유틸리티 함수 모듈 + +CodeSelect 프로젝트의 공통 유틸리티 함수들을 포함하는 모듈입니다. +""" + +import os +import sys +import fnmatch +import subprocess +import tempfile +from pathlib import Path + +def get_language_name(extension): + """ + 파일 확장자를 언어 이름으로 변환합니다. + + Args: + extension (str): 언어 확장자 (예: 'py', 'js') + + Returns: + str: 확장자에 해당하는 언어 이름 + """ + language_map = { + 'py': 'Python', + 'c': 'C', + 'cpp': 'C++', + 'h': 'C/C++ Header', + 'hpp': 'C++ Header', + 'js': 'JavaScript', + 'ts': 'TypeScript', + 'java': 'Java', + 'html': 'HTML', + 'css': 'CSS', + 'php': 'PHP', + 'rb': 'Ruby', + 'go': 'Go', + 'rs': 'Rust', + 'swift': 'Swift', + 'kt': 'Kotlin', + 'sh': 'Shell', + 'md': 'Markdown', + 'json': 'JSON', + 'xml': 'XML', + 'yaml': 'YAML', + 'yml': 'YAML', + 'sql': 'SQL', + 'r': 'R', + } + return language_map.get(extension, extension.upper()) + +def try_copy_to_clipboard(text): + """ + Attempts to copy the text to the clipboard. On failure, use an appropriate fallback method. + + Args: + text (str): The text to copy to the clipboard. + + Returns: + bool: Clipboard copy success or failure + """ + try: + # 플랫폼별 방법 시도 + if sys.platform == 'darwin': # macOS + try: + process = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE) + process.communicate(text.encode('utf-8')) + return True + except: + pass + elif sys.platform == 'win32': # Windows + try: + process = subprocess.Popen(['clip'], stdin=subprocess.PIPE) + process.communicate(text.encode('utf-8')) + return True + except: + pass + elif sys.platform.startswith('linux'): # Linux + for cmd in ['xclip -selection clipboard', 'xsel -ib']: + try: + process = subprocess.Popen(cmd.split(), stdin=subprocess.PIPE) + process.communicate(text.encode('utf-8')) + return True + except: + continue + + # 모든 방법이 실패하면 홈 디렉토리에 파일 생성 시도 + fallback_path = os.path.expanduser("~/codeselect_output.txt") + with open(fallback_path, 'w', encoding='utf-8') as f: + f.write(text) + print(f"클립보드 복사 실패. 출력이 다음 위치에 저장됨: {fallback_path}") + return False + except: + print("클립보드에 복사하거나 파일에 저장할 수 없습니다.") + return False + +def generate_output_filename(directory_path, output_format='txt'): + """ + Generate unique output filenames based on directory names. + + Args: + directory_path (str): Destination directory path + output_format (str): Output file format (default: ‘txt’) + + Returns: + str: Generated output file name + """ + base_name = os.path.basename(os.path.abspath(directory_path)) + extension = f".{output_format}" + + # 기본 이름으로 시작 + output_name = f"{base_name}{extension}" + counter = 1 + + # 파일이 존재하면 카운터 추가 + while os.path.exists(output_name): + output_name = f"{base_name}({counter}){extension}" + counter += 1 + + return output_name + +def load_gitignore_patterns(directory): + """ + Reads `.gitignore` file and returns a list of valid ignore patterns. + + Args: + directory (str): The directory containing the .gitignore file. + + Returns: + list: List of ignore patterns from the .gitignore file. + """ + gitignore_path = os.path.join(directory, ".gitignore") + if not os.path.isfile(gitignore_path): + return [] + + patterns = [] + with open(gitignore_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + # Skip empty lines and comments + if line and not line.startswith("#"): + patterns.append(line) + + return patterns + +def should_ignore_path(path, ignore_patterns=None): + """ + Checks if the given path matches a pattern that should be ignored. + Implements basic .gitignore style pattern matching. + + Args: + path (str): The path to the file or directory to check. + ignore_patterns (list): List of patterns to ignore (default: None) + + Returns: + Bool: True if the path should be ignored, False otherwise. + """ + if ignore_patterns is None: + ignore_patterns = ['.git', '__pycache__', '*.pyc', '.DS_Store', '.idea', '.vscode', 'node_modules', 'dist'] + + basename = os.path.basename(path) + should_ignore = False + + for pattern in ignore_patterns: + # Skip empty patterns + if not pattern: + continue + + # Handle negated patterns (patterns with !) + if pattern.startswith('!'): + negated_pattern = pattern[1:] + # If the path matches a negated pattern, it should NOT be ignored + if fnmatch.fnmatch(basename, negated_pattern) or fnmatch.fnmatch(path, negated_pattern): + return False + # Handle directory-specific patterns (patterns ending with /) + elif pattern.endswith('/') and os.path.isdir(path): + if fnmatch.fnmatch(basename, pattern[:-1]) or fnmatch.fnmatch(path, pattern): + should_ignore = True + # Handle wildcard patterns + elif '*' in pattern: + if fnmatch.fnmatch(basename, pattern) or fnmatch.fnmatch(path, pattern): + should_ignore = True + # Handle exact matches + elif basename == pattern or path.endswith('/' + pattern): + should_ignore = True + + return should_ignore