janito 0.4.0__py3-none-any.whl → 0.5.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.
- janito/__init__.py +48 -1
- janito/__main__.py +29 -334
- janito/agents/__init__.py +22 -0
- janito/agents/agent.py +21 -0
- janito/{claude.py → agents/claudeai.py} +10 -5
- janito/agents/openai.py +53 -0
- janito/agents/test.py +34 -0
- janito/analysis/__init__.py +33 -0
- janito/analysis/display.py +149 -0
- janito/analysis/options.py +112 -0
- janito/analysis/prompts.py +75 -0
- janito/change/__init__.py +19 -0
- janito/change/applier.py +269 -0
- janito/{contentchange.py → change/content.py} +5 -27
- janito/change/indentation.py +33 -0
- janito/change/position.py +169 -0
- janito/changehistory.py +46 -0
- janito/changeviewer/__init__.py +12 -0
- janito/changeviewer/diff.py +28 -0
- janito/changeviewer/panels.py +268 -0
- janito/changeviewer/styling.py +59 -0
- janito/changeviewer/themes.py +57 -0
- janito/cli/__init__.py +2 -0
- janito/cli/commands.py +53 -0
- janito/cli/functions.py +286 -0
- janito/cli/registry.py +26 -0
- janito/common.py +9 -9
- janito/console/__init__.py +3 -0
- janito/console/commands.py +112 -0
- janito/console/core.py +62 -0
- janito/console/display.py +157 -0
- janito/fileparser.py +292 -83
- janito/prompts.py +21 -6
- janito/qa.py +7 -5
- janito/review.py +13 -0
- janito/scan.py +44 -5
- janito/tests/test_fileparser.py +26 -0
- janito-0.5.0.dist-info/METADATA +146 -0
- janito-0.5.0.dist-info/RECORD +45 -0
- janito/analysis.py +0 -281
- janito/changeapplier.py +0 -436
- janito/changeviewer.py +0 -350
- janito/console.py +0 -330
- janito-0.4.0.dist-info/METADATA +0 -164
- janito-0.4.0.dist-info/RECORD +0 -21
- /janito/{contextparser.py → _contextparser.py} +0 -0
- {janito-0.4.0.dist-info → janito-0.5.0.dist-info}/WHEEL +0 -0
- {janito-0.4.0.dist-info → janito-0.5.0.dist-info}/entry_points.txt +0 -0
- {janito-0.4.0.dist-info → janito-0.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,149 @@
|
|
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
|
@@ -0,0 +1,112 @@
|
|
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
|
@@ -0,0 +1,75 @@
|
|
1
|
+
"""User prompts and input handling for analysis."""
|
2
|
+
|
3
|
+
from typing import List
|
4
|
+
from rich.console import Console
|
5
|
+
from rich.panel import Panel
|
6
|
+
from rich.rule import Rule
|
7
|
+
from rich.prompt import Prompt
|
8
|
+
from rich import box
|
9
|
+
|
10
|
+
# Keep only prompt-related functionality
|
11
|
+
CHANGE_ANALYSIS_PROMPT = """
|
12
|
+
Current files:
|
13
|
+
<files>
|
14
|
+
{files_content}
|
15
|
+
</files>
|
16
|
+
|
17
|
+
Considering the above current files content, provide 3 sections, each identified by a keyword and representing an option.
|
18
|
+
Each option should include a concise description and a list of affected files.
|
19
|
+
1st option should be minimalistic style change, 2nd organized style, 3rd exntensible style.
|
20
|
+
Do not use style as keyword, instead focus on the changes summaru
|
21
|
+
Use the following format:
|
22
|
+
|
23
|
+
A. Keyword summary of the change
|
24
|
+
-----------------
|
25
|
+
Description:
|
26
|
+
- Concise description of the change
|
27
|
+
|
28
|
+
Affected files:
|
29
|
+
- path/file1.py (new)
|
30
|
+
- path/file2.py (modified)
|
31
|
+
- path/file3.py (removed)
|
32
|
+
|
33
|
+
END_OF_OPTIONS (mandatory marker)
|
34
|
+
|
35
|
+
RULES:
|
36
|
+
- do NOT provide the content of the files
|
37
|
+
- do NOT offer to implement the changes
|
38
|
+
- description items should be 80 chars or less
|
39
|
+
|
40
|
+
Request:
|
41
|
+
{request}
|
42
|
+
"""
|
43
|
+
|
44
|
+
def prompt_user(message: str, choices: List[str] = None) -> str:
|
45
|
+
"""Display a prominent user prompt with optional choices"""
|
46
|
+
console = Console()
|
47
|
+
console.print()
|
48
|
+
console.print(Rule(" User Input Required ", style="bold cyan"))
|
49
|
+
|
50
|
+
if choices:
|
51
|
+
choice_text = f"[cyan]Options: {', '.join(choices)}[/cyan]"
|
52
|
+
console.print(Panel(choice_text, box=box.ROUNDED))
|
53
|
+
|
54
|
+
return Prompt.ask(f"[bold cyan]> {message}[/bold cyan]")
|
55
|
+
|
56
|
+
def validate_option_letter(letter: str, options: dict) -> bool:
|
57
|
+
"""Validate if the given letter is a valid option or 'M' for modify"""
|
58
|
+
return letter.upper() in options or letter.upper() == 'M'
|
59
|
+
|
60
|
+
def get_option_selection() -> str:
|
61
|
+
"""Get user input for option selection with modify option"""
|
62
|
+
console = Console()
|
63
|
+
console.print("\n[cyan]Enter option letter or 'M' to modify request[/cyan]")
|
64
|
+
while True:
|
65
|
+
letter = prompt_user("Select option").strip().upper()
|
66
|
+
if letter == 'M' or (letter.isalpha() and len(letter) == 1):
|
67
|
+
return letter
|
68
|
+
console.print("[red]Please enter a valid letter or 'M'[/red]")
|
69
|
+
|
70
|
+
def build_request_analysis_prompt(files_content: str, request: str) -> str:
|
71
|
+
"""Build prompt for information requests"""
|
72
|
+
return CHANGE_ANALYSIS_PROMPT.format(
|
73
|
+
files_content=files_content,
|
74
|
+
request=request
|
75
|
+
)
|
@@ -0,0 +1,19 @@
|
|
1
|
+
from .applier import apply_single_change
|
2
|
+
from .position import parse_and_apply_changes_sequence
|
3
|
+
from .content import (
|
4
|
+
get_file_type,
|
5
|
+
process_and_save_changes,
|
6
|
+
format_parsed_changes,
|
7
|
+
apply_content_changes,
|
8
|
+
handle_changes_file
|
9
|
+
)
|
10
|
+
|
11
|
+
__all__ = [
|
12
|
+
'apply_single_change',
|
13
|
+
'parse_and_apply_changes_sequence',
|
14
|
+
'get_file_type',
|
15
|
+
'process_and_save_changes',
|
16
|
+
'format_parsed_changes',
|
17
|
+
'apply_content_changes',
|
18
|
+
'handle_changes_file'
|
19
|
+
]
|
janito/change/applier.py
ADDED
@@ -0,0 +1,269 @@
|
|
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
|
@@ -1,9 +1,13 @@
|
|
1
|
+
|
1
2
|
from pathlib import Path
|
2
3
|
from typing import Dict, Tuple
|
3
4
|
from rich.console import Console
|
4
5
|
from datetime import datetime
|
6
|
+
|
5
7
|
from janito.fileparser import FileChange, parse_block_changes
|
6
|
-
from janito.
|
8
|
+
from janito.changehistory import save_changes_to_history, get_history_file_path
|
9
|
+
from janito.changeviewer import preview_all_changes
|
10
|
+
from .applier import apply_single_change
|
7
11
|
|
8
12
|
def get_file_type(filepath: Path) -> str:
|
9
13
|
"""Determine the type of saved file based on its name"""
|
@@ -18,38 +22,12 @@ def get_file_type(filepath: Path) -> str:
|
|
18
22
|
return 'response'
|
19
23
|
return 'unknown'
|
20
24
|
|
21
|
-
def save_changes_to_history(content: str, request: str, workdir: Path) -> Path:
|
22
|
-
"""Save change content to history folder with timestamp and request info"""
|
23
|
-
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") # Already in the correct format
|
24
|
-
history_dir = workdir / '.janito' / 'history'
|
25
|
-
history_dir.mkdir(parents=True, exist_ok=True)
|
26
|
-
|
27
|
-
# Create history entry with request and changes
|
28
|
-
history_file = history_dir / f"changes_{timestamp}.txt"
|
29
|
-
|
30
|
-
history_content = f"""Request: {request}
|
31
|
-
Timestamp: {timestamp}
|
32
|
-
|
33
|
-
Changes:
|
34
|
-
{content}
|
35
|
-
"""
|
36
|
-
history_file.write_text(history_content)
|
37
|
-
return history_file
|
38
|
-
|
39
25
|
def process_and_save_changes(content: str, request: str, workdir: Path) -> Tuple[Dict[Path, Tuple[str, str]], Path]:
|
40
26
|
"""Parse changes and save to history, returns (changes_dict, history_file)"""
|
41
27
|
changes = parse_block_changes(content)
|
42
28
|
history_file = save_changes_to_history(content, request, workdir)
|
43
29
|
return changes, history_file
|
44
30
|
|
45
|
-
def validate_python_syntax(content: str, filepath: Path) -> Tuple[bool, str]:
|
46
|
-
"""Validate Python syntax and return (is_valid, error_message)"""
|
47
|
-
try:
|
48
|
-
ast.parse(content)
|
49
|
-
return True, ""
|
50
|
-
except SyntaxError as e:
|
51
|
-
return False, f"Line {e.lineno}: {e.msg}"
|
52
|
-
|
53
31
|
def format_parsed_changes(changes: Dict[Path, Tuple[str, str]]) -> str:
|
54
32
|
"""Format parsed changes to show only file change descriptions"""
|
55
33
|
result = []
|