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 @@
-
+
**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