janito 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. janito/__init__.py +0 -47
  2. janito/__main__.py +96 -15
  3. janito/agents/__init__.py +2 -8
  4. janito/agents/claudeai.py +3 -12
  5. janito/change/__init__.py +29 -16
  6. janito/change/__main__.py +0 -0
  7. janito/{analysis → change/analysis}/__init__.py +5 -15
  8. janito/change/analysis/__main__.py +7 -0
  9. janito/change/analysis/analyze.py +61 -0
  10. janito/change/analysis/formatting.py +78 -0
  11. janito/change/analysis/options.py +81 -0
  12. janito/{analysis → change/analysis}/prompts.py +35 -12
  13. janito/change/analysis/view/__init__.py +9 -0
  14. janito/change/analysis/view/terminal.py +171 -0
  15. janito/change/applier/__init__.py +5 -0
  16. janito/change/applier/file.py +58 -0
  17. janito/change/applier/main.py +156 -0
  18. janito/change/applier/text.py +245 -0
  19. janito/change/applier/workspace_dir.py +58 -0
  20. janito/change/core.py +131 -0
  21. janito/{changehistory.py → change/history.py} +12 -14
  22. janito/change/operations.py +7 -0
  23. janito/change/parser.py +289 -0
  24. janito/change/play.py +54 -0
  25. janito/change/preview.py +82 -0
  26. janito/change/prompts.py +126 -0
  27. janito/change/test.py +0 -0
  28. janito/change/validator.py +251 -0
  29. janito/{changeviewer → change/viewer}/__init__.py +3 -4
  30. janito/change/viewer/content.py +66 -0
  31. janito/{changeviewer → change/viewer}/diff.py +19 -4
  32. janito/change/viewer/pager.py +56 -0
  33. janito/change/viewer/panels.py +555 -0
  34. janito/change/viewer/styling.py +103 -0
  35. janito/{changeviewer → change/viewer}/themes.py +3 -5
  36. janito/clear_statement_parser/clear_statement_format.txt +328 -0
  37. janito/clear_statement_parser/examples.txt +326 -0
  38. janito/clear_statement_parser/models.py +104 -0
  39. janito/clear_statement_parser/parser.py +496 -0
  40. janito/cli/base.py +30 -0
  41. janito/cli/commands.py +30 -38
  42. janito/cli/functions.py +19 -194
  43. janito/cli/handlers/ask.py +22 -0
  44. janito/cli/handlers/demo.py +22 -0
  45. janito/cli/handlers/request.py +24 -0
  46. janito/cli/handlers/scan.py +9 -0
  47. janito/cli/history.py +61 -0
  48. janito/common.py +34 -3
  49. janito/config.py +71 -6
  50. janito/demo/__init__.py +4 -0
  51. janito/demo/data.py +13 -0
  52. janito/demo/mock_data.py +20 -0
  53. janito/demo/operations.py +45 -0
  54. janito/demo/runner.py +59 -0
  55. janito/demo/scenarios.py +32 -0
  56. janito/prompts.py +1 -80
  57. janito/qa.py +4 -3
  58. janito/search_replace/README.md +146 -0
  59. janito/search_replace/__init__.py +6 -0
  60. janito/search_replace/__main__.py +21 -0
  61. janito/search_replace/core.py +119 -0
  62. janito/search_replace/parser.py +52 -0
  63. janito/search_replace/play.py +61 -0
  64. janito/search_replace/replacer.py +36 -0
  65. janito/search_replace/searcher.py +299 -0
  66. janito/shell/__init__.py +39 -0
  67. janito/shell/bus.py +31 -0
  68. janito/shell/commands.py +195 -0
  69. janito/shell/handlers.py +122 -0
  70. janito/shell/history.py +20 -0
  71. janito/shell/processor.py +52 -0
  72. janito/tui/__init__.py +21 -0
  73. janito/tui/base.py +22 -0
  74. janito/tui/flows/__init__.py +5 -0
  75. janito/tui/flows/changes.py +65 -0
  76. janito/tui/flows/content.py +128 -0
  77. janito/tui/flows/selection.py +117 -0
  78. janito/tui/screens/__init__.py +3 -0
  79. janito/tui/screens/app.py +1 -0
  80. janito/workspace/__init__.py +7 -0
  81. janito/workspace/analysis.py +121 -0
  82. janito/workspace/manager.py +48 -0
  83. janito/workspace/scan.py +232 -0
  84. janito-0.6.0.dist-info/METADATA +185 -0
  85. janito-0.6.0.dist-info/RECORD +95 -0
  86. {janito-0.5.0.dist-info → janito-0.6.0.dist-info}/WHEEL +1 -1
  87. janito/_contextparser.py +0 -113
  88. janito/analysis/display.py +0 -149
  89. janito/analysis/options.py +0 -112
  90. janito/change/applier.py +0 -269
  91. janito/change/content.py +0 -62
  92. janito/change/indentation.py +0 -33
  93. janito/change/position.py +0 -169
  94. janito/changeviewer/panels.py +0 -268
  95. janito/changeviewer/styling.py +0 -59
  96. janito/console/__init__.py +0 -3
  97. janito/console/commands.py +0 -112
  98. janito/console/core.py +0 -62
  99. janito/console/display.py +0 -157
  100. janito/fileparser.py +0 -334
  101. janito/scan.py +0 -176
  102. janito/tests/test_fileparser.py +0 -26
  103. janito-0.5.0.dist-info/METADATA +0 -146
  104. janito-0.5.0.dist-info/RECORD +0 -45
  105. {janito-0.5.0.dist-info → janito-0.6.0.dist-info}/entry_points.txt +0 -0
  106. {janito-0.5.0.dist-info → janito-0.6.0.dist-info}/licenses/LICENSE +0 -0
janito/_contextparser.py DELETED
@@ -1,113 +0,0 @@
1
- from typing import List, Tuple, Optional, NamedTuple
2
- from difflib import SequenceMatcher
3
- from janito.config import config
4
- from rich.console import Console
5
-
6
- class ContextError(NamedTuple):
7
- """Contains error details for context matching failures"""
8
- pre_context: List[str]
9
- post_context: List[str]
10
- content: str
11
-
12
- def parse_change_block(content: str) -> Tuple[List[str], List[str], List[str]]:
13
- """Parse a change block into pre-context, post-context and change lines.
14
- Returns (pre_context_lines, post_context_lines, change_lines)"""
15
- pre_context_lines = []
16
- post_context_lines = []
17
- change_lines = []
18
- in_pre_context = True
19
-
20
- for line in content.splitlines():
21
- if line.startswith('='):
22
- if in_pre_context:
23
- pre_context_lines.append(line[1:])
24
- else:
25
- post_context_lines.append(line[1:])
26
- elif line.startswith('>'):
27
- in_pre_context = False
28
- change_lines.append(line[1:])
29
-
30
- return pre_context_lines, post_context_lines, change_lines
31
-
32
- def find_context_match(file_content: str, pre_context: List[str], post_context: List[str], min_context: int = 2) -> Optional[Tuple[int, int]]:
33
- """Find exact matching location using line-by-line matching.
34
- Returns (start_index, end_index) or None if no match found."""
35
- if not (pre_context or post_context) or (len(pre_context) + len(post_context)) < min_context:
36
- return None
37
-
38
- file_lines = file_content.splitlines()
39
-
40
- # Function to check if lines match at a given position
41
- def lines_match_at(pos: int, target_lines: List[str]) -> bool:
42
- if pos + len(target_lines) > len(file_lines):
43
- return False
44
- return all(a == b for a, b in zip(file_lines[pos:pos + len(target_lines)], target_lines))
45
-
46
- # For debug output
47
- debug_matches = []
48
-
49
- # Try to find pre_context match
50
- pre_match_pos = None
51
- if pre_context:
52
- for i in range(len(file_lines) - len(pre_context) + 1):
53
- if lines_match_at(i, pre_context):
54
- pre_match_pos = i
55
- break
56
- if config.debug:
57
- # Record first 20 non-matches for debug output
58
- if len(debug_matches) < 20:
59
- debug_matches.append((i, file_lines[i:i + len(pre_context)]))
60
-
61
- # Try to find post_context match after pre_context if found
62
- if pre_match_pos is not None and post_context:
63
- expected_post_pos = pre_match_pos + len(pre_context)
64
- if not lines_match_at(expected_post_pos, post_context):
65
- pre_match_pos = None
66
-
67
- if pre_match_pos is None and config.debug:
68
- console = Console()
69
- console.print("\n[bold red]Context Match Debug:[/bold red]")
70
-
71
- if pre_context:
72
- console.print("\n[yellow]Expected pre-context:[/yellow]")
73
- for i, line in enumerate(pre_context):
74
- console.print(f" {i+1:2d} | '{line}'")
75
-
76
- if post_context:
77
- console.print("\n[yellow]Expected post-context:[/yellow]")
78
- for i, line in enumerate(post_context):
79
- console.print(f" {i+1:2d} | '{line}'")
80
-
81
- console.print("\n[yellow]First 20 attempted matches in file:[/yellow]")
82
- for pos, lines in debug_matches:
83
- console.print(f"\n[cyan]At line {pos+1}:[/cyan]")
84
- for i, line in enumerate(lines):
85
- match_status = "≠" if i < len(pre_context) and line != pre_context[i] else "="
86
- console.print(f" {i+1:2d} | '{line}' {match_status}")
87
-
88
- return None
89
-
90
- if pre_match_pos is None:
91
- return None
92
-
93
- end_pos = pre_match_pos + len(pre_context)
94
-
95
- return pre_match_pos, end_pos
96
-
97
- def apply_changes(content: str,
98
- pre_context_lines: List[str],
99
- post_context_lines: List[str],
100
- change_lines: List[str]) -> Optional[Tuple[str, Optional[ContextError]]]:
101
- """Apply changes with context matching, returns (new_content, error_details)"""
102
- if not content.strip() and not pre_context_lines and not post_context_lines:
103
- return '\n'.join(change_lines), None
104
-
105
- pre_context = '\n'.join(pre_context_lines)
106
- post_context = '\n'.join(post_context_lines)
107
-
108
- if pre_context and pre_context not in content:
109
- return None, ContextError(pre_context_lines, post_context_lines, content)
110
-
111
- if post_context and post_context not in content:
112
- return None, ContextError(pre_context_lines, post_context_lines, content)
113
-
@@ -1,149 +0,0 @@
1
- """Display formatting for analysis results."""
2
-
3
- from typing import Optional, Dict
4
- from pathlib import Path
5
- from datetime import datetime, timezone
6
- from rich.console import Console
7
- from rich.markdown import Markdown
8
- from rich.panel import Panel
9
- from rich.text import Text
10
- from rich import box
11
- from rich.columns import Columns
12
- from rich.rule import Rule
13
- from janito.agents import AIAgent, AgentSingleton
14
- from .options import AnalysisOption
15
- from .options import parse_analysis_options
16
-
17
- MIN_PANEL_WIDTH = 40
18
-
19
- def get_analysis_summary(options: Dict[str, AnalysisOption]) -> str:
20
- """Generate a summary of affected directories and their file counts."""
21
- dirs_summary = {}
22
- for _, option in options.items():
23
- for file in option.affected_files:
24
- clean_path = option.get_clean_path(file)
25
- dir_path = str(Path(clean_path).parent)
26
- dirs_summary[dir_path] = dirs_summary.get(dir_path, 0) + 1
27
-
28
- return " | ".join([f"{dir}: {count} files" for dir, count in dirs_summary.items()])
29
-
30
- def _display_options(options: Dict[str, AnalysisOption]) -> None:
31
- """Display available options in a single horizontal row with equal widths."""
32
- console = Console()
33
-
34
- console.print()
35
- console.print(Rule(" Available Options ", style="bold cyan", align="center"))
36
- console.print()
37
-
38
- term_width = console.width or 100
39
- spacing = 4
40
- total_spacing = spacing * (len(options) - 1)
41
- panel_width = max(MIN_PANEL_WIDTH, (term_width - total_spacing) // len(options))
42
-
43
- panels = []
44
- for letter, option in options.items():
45
- content = Text()
46
-
47
- content.append("Description:\n", style="bold cyan")
48
- for item in option.description_items:
49
- content.append(f"• {item}\n", style="white")
50
- content.append("\n")
51
-
52
- if option.affected_files:
53
- content.append("Affected files:\n", style="bold cyan")
54
- unique_files = {}
55
- for file in option.affected_files:
56
- clean_path = option.get_clean_path(file)
57
- unique_files[clean_path] = file
58
-
59
- for file in unique_files.values():
60
- if '(new)' in file:
61
- color = "green"
62
- elif '(removed)' in file:
63
- color = "red"
64
- else:
65
- color = "yellow"
66
- content.append(f"• {file}\n", style=color)
67
-
68
- panel = Panel(
69
- content,
70
- box=box.ROUNDED,
71
- border_style="cyan",
72
- title=f"Option {letter}: {option.summary}",
73
- title_align="center",
74
- padding=(1, 2),
75
- width=panel_width
76
- )
77
- panels.append(panel)
78
-
79
- if panels:
80
- columns = Columns(
81
- panels,
82
- align="center",
83
- expand=True,
84
- equal=True,
85
- padding=(0, spacing // 2)
86
- )
87
- console.print(columns)
88
-
89
- def _display_markdown(content: str) -> None:
90
- """Display content in markdown format."""
91
- console = Console()
92
- md = Markdown(content)
93
- console.print(md)
94
-
95
- def _display_raw_history(agent: AIAgent) -> None:
96
- """Display raw message history from Claude agent."""
97
- console = Console()
98
- console.print("\n=== Message History ===")
99
- for role, content in agent.messages_history:
100
- console.print(f"\n[bold cyan]{role.upper()}:[/bold cyan]")
101
- console.print(content)
102
- console.print("\n=== End Message History ===\n")
103
-
104
- def format_analysis(analysis: str, raw: bool = False, workdir: Optional[Path] = None) -> None:
105
- """Format and display the analysis output with enhanced capabilities."""
106
- console = Console()
107
-
108
- agent = AgentSingleton.get_agent()
109
- if raw and agent:
110
- _display_raw_history(agent)
111
- else:
112
- options = parse_analysis_options(analysis)
113
- if options:
114
- _display_options(options)
115
- else:
116
- console.print("\n[yellow]Warning: No valid options found in response. Displaying as markdown.[/yellow]\n")
117
- _display_markdown(analysis)
118
-
119
- def get_history_file_type(filepath: Path) -> str:
120
- """Determine the type of saved file based on its name"""
121
- name = filepath.name.lower()
122
- if 'changes' in name:
123
- return 'changes'
124
- elif 'selected' in name:
125
- return 'selected'
126
- elif 'analysis' in name:
127
- return 'analysis'
128
- elif 'response' in name:
129
- return 'response'
130
- return 'unknown'
131
-
132
- def get_history_path(workdir: Path) -> Path:
133
- """Create and return the history directory path"""
134
- history_dir = workdir / '.janito' / 'history'
135
- history_dir.mkdir(parents=True, exist_ok=True)
136
- return history_dir
137
-
138
- def get_timestamp() -> str:
139
- """Get current UTC timestamp in YMD_HMS format with leading zeros"""
140
- return datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
141
-
142
- def save_to_file(content: str, prefix: str, workdir: Path) -> Path:
143
- """Save content to a timestamped file in history directory"""
144
- history_dir = get_history_path(workdir)
145
- timestamp = get_timestamp()
146
- filename = f"{timestamp}_{prefix}.txt"
147
- file_path = history_dir / filename
148
- file_path.write_text(content)
149
- return file_path
@@ -1,112 +0,0 @@
1
- """Options handling for analysis module."""
2
-
3
- from dataclasses import dataclass
4
- from pathlib import Path
5
- from typing import List, Dict, Tuple
6
- import re
7
-
8
- @dataclass
9
- class AnalysisOption:
10
- letter: str
11
- summary: str
12
- affected_files: List[str]
13
- description_items: List[str]
14
-
15
- def get_clean_path(self, file_path: str) -> str:
16
- """Get clean path without markers"""
17
- return file_path.split(' (')[0].strip()
18
-
19
- def is_new_file(self, file_path: str) -> bool:
20
- """Check if file is marked as new"""
21
- return '(new)' in file_path
22
-
23
- def is_removed_file(self, file_path: str) -> bool:
24
- """Check if file is marked as removed"""
25
- return '(removed)' in file_path
26
-
27
- def get_affected_paths(self, workdir: Path = None) -> List[Path]:
28
- """Get list of affected paths, resolving against workdir if provided"""
29
- paths = []
30
- for file_path in self.affected_files:
31
- clean_path = self.get_clean_path(file_path)
32
- path = workdir / clean_path if workdir else Path(clean_path)
33
- paths.append(path)
34
- return paths
35
-
36
- def process_file_path(self, path: str) -> Tuple[str, bool, bool, bool]:
37
- """Process a file path to extract clean path and modification flags
38
- Returns: (clean_path, is_new, is_modified, is_removed)
39
- """
40
- clean_path = path.strip()
41
- is_new = False
42
- is_modified = False
43
- is_removed = False
44
-
45
- if "(new)" in clean_path:
46
- is_new = True
47
- clean_path = clean_path.replace("(new)", "").strip()
48
- if "(modified)" in clean_path:
49
- is_modified = True
50
- clean_path = clean_path.replace("(modified)", "").strip()
51
- if "(removed)" in clean_path:
52
- is_removed = True
53
- clean_path = clean_path.replace("(removed)", "").strip()
54
-
55
- return clean_path, is_new, is_modified, is_removed
56
-
57
- def parse_analysis_options(response: str) -> Dict[str, AnalysisOption]:
58
- """Parse options from the response text."""
59
- options = {}
60
-
61
- if 'END_OF_OPTIONS' in response:
62
- response = response.split('END_OF_OPTIONS')[0]
63
-
64
- current_option = None
65
- current_section = None
66
-
67
- lines = response.split('\n')
68
-
69
- for line in lines:
70
- line = line.strip()
71
- if not line:
72
- continue
73
-
74
- option_match = re.match(r'^([A-Z])\.\s+(.+)$', line)
75
- if option_match:
76
- if current_option:
77
- options[current_option.letter] = current_option
78
-
79
- letter, summary = option_match.groups()
80
- current_option = AnalysisOption(
81
- letter=letter,
82
- summary=summary,
83
- affected_files=[],
84
- description_items=[]
85
- )
86
- current_section = None
87
- continue
88
-
89
- if re.match(r'^-+$', line):
90
- continue
91
-
92
- if current_option:
93
- if line.lower() == 'description:':
94
- current_section = 'description'
95
- continue
96
- elif line.lower() == 'affected files:':
97
- current_section = 'files'
98
- continue
99
-
100
- if line.startswith('- '):
101
- content = line[2:].strip()
102
- if current_section == 'description':
103
- current_option.description_items.append(content)
104
- elif current_section == 'files':
105
- # Accept any combination of new, modified or removed markers
106
- if any(marker in content for marker in ['(new)', '(modified)', '(removed)']):
107
- current_option.affected_files.append(content)
108
-
109
- if current_option:
110
- options[current_option.letter] = current_option
111
-
112
- return options
janito/change/applier.py DELETED
@@ -1,269 +0,0 @@
1
- from pathlib import Path
2
- from typing import Tuple, Optional, Set
3
- from rich.console import Console
4
- from rich import box
5
- from rich.panel import Panel
6
- from rich.prompt import Confirm
7
- from datetime import datetime
8
- import subprocess
9
- import shutil
10
- import tempfile
11
-
12
- from janito.fileparser import FileChange
13
- from janito.config import config
14
- from .position import find_text_positions, format_whitespace_debug
15
- from .indentation import adjust_indentation
16
- from typing import List
17
- from ..changeviewer import preview_all_changes
18
- from ..fileparser import validate_python_syntax
19
- from ..changehistory import get_history_file_path
20
-
21
-
22
- def run_test_command(preview_dir: Path, test_cmd: str) -> Tuple[bool, str, Optional[str]]:
23
- """Run test command in preview directory.
24
- Returns (success, output, error)"""
25
- try:
26
- result = subprocess.run(
27
- test_cmd,
28
- shell=True,
29
- cwd=preview_dir,
30
- capture_output=True,
31
- text=True,
32
- timeout=300 # 5 minute timeout
33
- )
34
- return (
35
- result.returncode == 0,
36
- result.stdout,
37
- result.stderr if result.returncode != 0 else None
38
- )
39
- except subprocess.TimeoutExpired:
40
- return False, "", "Test command timed out after 5 minutes"
41
- except Exception as e:
42
- return False, "", f"Error running test: {str(e)}"
43
-
44
- def preview_and_apply_changes(changes: List[FileChange], workdir: Path, test_cmd: str = None) -> bool:
45
- """Preview changes and apply if confirmed"""
46
- console = Console()
47
-
48
- if not changes:
49
- console.print("\n[yellow]No changes were found to apply[/yellow]")
50
- return False
51
-
52
- # Show change preview
53
- preview_all_changes(console, changes)
54
-
55
- with tempfile.TemporaryDirectory() as temp_dir:
56
- preview_dir = Path(temp_dir)
57
- console.print("\n[blue]Creating preview in temporary directory...[/blue]")
58
-
59
- # Create backup directory
60
- backup_dir = workdir / '.janito' / 'backups' / datetime.now().strftime('%Y%m%d_%H%M%S')
61
- backup_dir.parent.mkdir(parents=True, exist_ok=True)
62
-
63
- # Copy existing files to preview directory
64
- if workdir.exists():
65
- if config.verbose:
66
- console.print(f"[blue]Creating backup at:[/blue] {backup_dir}")
67
- shutil.copytree(workdir, backup_dir, ignore=shutil.ignore_patterns('.janito'))
68
- shutil.copytree(workdir, preview_dir, dirs_exist_ok=True, ignore=shutil.ignore_patterns('.janito'))
69
-
70
- # Create restore script
71
- restore_script = workdir / '.janito' / 'restore.sh'
72
- restore_script.parent.mkdir(parents=True, exist_ok=True)
73
- script_content = f"""#!/bin/bash
74
- # Restore script generated by Janito
75
- # Restores files from backup created at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
76
-
77
- # Exit on error
78
- set -e
79
-
80
- # Check if backup directory exists
81
- if [ ! -d "{backup_dir}" ]; then
82
- echo "Error: Backup directory not found at {backup_dir}"
83
- exit 1
84
- fi
85
-
86
- # Restore files from backup
87
- echo "Restoring files from backup..."
88
- cp -r "{backup_dir}"/* "{workdir}/"
89
-
90
- echo "Files restored successfully from {backup_dir}"
91
- """
92
- restore_script.write_text(script_content)
93
- restore_script.chmod(0o755)
94
-
95
- if config.verbose:
96
- console.print(f"[blue]Created restore script at:[/blue] {restore_script}")
97
-
98
- # Track modified files and apply changes to preview directory
99
- modified_files: Set[Path] = set()
100
- any_errors = False
101
- for change in changes:
102
- if config.verbose:
103
- console.print(f"[dim]Previewing changes for {change.path}...[/dim]")
104
- success, error = apply_single_change(change.path, change, workdir, preview_dir)
105
- if success and not change.remove_file:
106
- modified_files.add(change.path)
107
- if not success:
108
- if "file already exists" in str(error):
109
- console.print(f"\n[red]Error: Cannot create {change.path}[/red]")
110
- console.print("[red]File already exists and overwriting is not allowed.[/red]")
111
- else:
112
- console.print(f"\n[red]Error previewing changes for {change.path}:[/red]")
113
- console.print(f"[red]{error}[/red]")
114
- any_errors = True
115
- continue
116
-
117
- if any_errors:
118
- console.print("\n[red]Some changes could not be previewed. Aborting.[/red]")
119
- return False
120
-
121
- # Validate Python syntax
122
- python_files = {change.path for change in changes if change.path.suffix == '.py'}
123
- for filepath in python_files:
124
- preview_path = preview_dir / filepath
125
- is_valid, error_msg = validate_python_syntax(preview_path.read_text(), preview_path)
126
- if not is_valid:
127
- console.print(f"\n[red]Python syntax validation failed for {filepath}:[/red]")
128
- console.print(f"[red]{error_msg}[/red]")
129
- return False
130
-
131
- # Run tests if specified
132
- if test_cmd:
133
- console.print(f"\n[cyan]Testing changes in preview directory:[/cyan] {test_cmd}")
134
- success, output, error = run_test_command(preview_dir, test_cmd)
135
-
136
- if output:
137
- console.print("\n[bold]Test Output:[/bold]")
138
- console.print(Panel(output, box=box.ROUNDED))
139
-
140
- if not success:
141
- console.print("\n[red bold]Tests failed in preview. Changes will not be applied.[/red bold]")
142
- if error:
143
- console.print(Panel(error, title="Error", border_style="red"))
144
- return False
145
-
146
- # Final confirmation
147
- if not Confirm.ask("\n[cyan bold]Apply previewed changes to working directory?[/cyan bold]"):
148
- console.print("\n[yellow]Changes were only previewed, not applied to working directory[/yellow]")
149
- console.print("[green]Changes are stored in the history directory and can be applied later using:[/green]")
150
- changes_file = get_history_file_path(workdir)
151
- console.print(f"[cyan] {changes_file.relative_to(workdir)}[/cyan]")
152
- return False
153
-
154
- # Apply changes - copy each modified file only once
155
- console.print("\n[blue]Applying changes to working directory...[/blue]")
156
- for file_path in modified_files:
157
- console.print(f"[dim]Applying changes to {file_path}...[/dim]")
158
- target_path = workdir / file_path
159
- preview_path = preview_dir / file_path
160
- target_path.parent.mkdir(parents=True, exist_ok=True)
161
- shutil.copy2(preview_path, target_path)
162
-
163
- # Handle file removals separately
164
- for change in changes:
165
- if change.remove_file:
166
- target_path = workdir / change.path
167
- if target_path.exists():
168
- target_path.unlink()
169
- console.print(f"[red]Removed {change.path}[/red]")
170
-
171
- console.print("\n[green]Changes successfully applied to working directory![/green]")
172
- return True
173
-
174
- def apply_single_change(filepath: Path, change: FileChange, workdir: Path, preview_dir: Path) -> Tuple[bool, Optional[str]]:
175
- """Apply a single file change"""
176
- preview_path = preview_dir / filepath
177
- preview_path.parent.mkdir(parents=True, exist_ok=True)
178
-
179
- if change.remove_file:
180
- orig_path = workdir / filepath
181
- if not orig_path.exists():
182
- return False, f"Cannot remove non-existent file {filepath}"
183
- if config.debug:
184
- console = Console()
185
- console.print(f"\n[red]Removing file {filepath}[/red]")
186
- # For preview, we don't create the file in preview dir
187
- return True, None
188
-
189
- if config.debug:
190
- console = Console()
191
- console.print(f"\n[cyan]Processing change for {filepath}[/cyan]")
192
- console.print(f"[dim]Change type: {'new file' if change.is_new_file else 'modification'}[/dim]")
193
-
194
- if change.is_new_file or change.replace_file:
195
- if change.is_new_file and filepath.exists():
196
- return False, "Cannot create file - already exists"
197
- if config.debug:
198
- action = "Creating new" if change.is_new_file else "Replacing"
199
- console.print(f"[cyan]{action} file with content:[/cyan]")
200
- console.print(Panel(change.content, title="File Content"))
201
- preview_path.write_text(change.content)
202
- return True, None
203
-
204
- orig_path = workdir / filepath
205
- if not orig_path.exists():
206
- return False, f"Cannot modify non-existent file {filepath}"
207
-
208
- content = orig_path.read_text()
209
- modified = content
210
-
211
- for search, replace, description in change.search_blocks:
212
- if config.debug:
213
- console.print(f"\n[cyan]Processing search block:[/cyan] {description or 'no description'}")
214
- console.print("[yellow]Search text:[/yellow]")
215
- console.print(Panel(format_whitespace_debug(search)))
216
- if replace is not None:
217
- console.print("[yellow]Replace with:[/yellow]")
218
- console.print(Panel(format_whitespace_debug(replace)))
219
- else:
220
- console.print("[yellow]Action:[/yellow] Delete text")
221
-
222
- positions = find_text_positions(modified, search)
223
-
224
- if config.debug:
225
- console.print(f"[cyan]Found {len(positions)} matches[/cyan]")
226
-
227
- if not positions:
228
- error_context = f" ({description})" if description else ""
229
- debug_search = format_whitespace_debug(search)
230
- debug_content = format_whitespace_debug(modified)
231
- error_msg = (
232
- f"Could not find search text in {filepath}{error_context}:\n\n"
233
- f"[yellow]Search text (with whitespace markers):[/yellow]\n"
234
- f"{debug_search}\n\n"
235
- f"[yellow]File content (with whitespace markers):[/yellow]\n"
236
- f"{debug_content}"
237
- )
238
- return False, error_msg
239
-
240
- # Apply replacements from end to start to maintain position validity
241
- for start, end in reversed(positions):
242
- if config.debug:
243
- console.print(f"\n[cyan]Replacing text at positions {start}-{end}:[/cyan]")
244
- console.print("[yellow]Original segment:[/yellow]")
245
- console.print(Panel(format_whitespace_debug(modified[start:end])))
246
- if replace is not None:
247
- console.print("[yellow]Replacing with:[/yellow]")
248
- console.print(Panel(format_whitespace_debug(replace)))
249
-
250
- # Adjust replacement text indentation
251
- original_segment = modified[start:end]
252
- adjusted_replace = adjust_indentation(original_segment, replace) if replace else ""
253
-
254
- if config.debug and replace:
255
- console.print("[yellow]Adjusted replacement:[/yellow]")
256
- console.print(Panel(format_whitespace_debug(adjusted_replace)))
257
-
258
- modified = modified[:start] + adjusted_replace + modified[end:]
259
-
260
- if modified == content:
261
- if config.debug:
262
- console.print("\n[yellow]No changes were applied to the file[/yellow]")
263
- return False, "No changes were applied"
264
-
265
- if config.debug:
266
- console.print("\n[green]Changes applied successfully[/green]")
267
-
268
- preview_path.write_text(modified)
269
- return True, None