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.
- janito/__init__.py +0 -47
- janito/__main__.py +96 -15
- janito/agents/__init__.py +2 -8
- janito/agents/claudeai.py +3 -12
- janito/change/__init__.py +29 -16
- janito/change/__main__.py +0 -0
- janito/{analysis → change/analysis}/__init__.py +5 -15
- janito/change/analysis/__main__.py +7 -0
- janito/change/analysis/analyze.py +61 -0
- janito/change/analysis/formatting.py +78 -0
- janito/change/analysis/options.py +81 -0
- janito/{analysis → change/analysis}/prompts.py +35 -12
- janito/change/analysis/view/__init__.py +9 -0
- janito/change/analysis/view/terminal.py +171 -0
- janito/change/applier/__init__.py +5 -0
- janito/change/applier/file.py +58 -0
- janito/change/applier/main.py +156 -0
- janito/change/applier/text.py +245 -0
- janito/change/applier/workspace_dir.py +58 -0
- janito/change/core.py +131 -0
- janito/{changehistory.py → change/history.py} +12 -14
- janito/change/operations.py +7 -0
- janito/change/parser.py +289 -0
- janito/change/play.py +54 -0
- janito/change/preview.py +82 -0
- janito/change/prompts.py +126 -0
- janito/change/test.py +0 -0
- janito/change/validator.py +251 -0
- janito/{changeviewer → change/viewer}/__init__.py +3 -4
- janito/change/viewer/content.py +66 -0
- janito/{changeviewer → change/viewer}/diff.py +19 -4
- janito/change/viewer/pager.py +56 -0
- janito/change/viewer/panels.py +555 -0
- janito/change/viewer/styling.py +103 -0
- janito/{changeviewer → change/viewer}/themes.py +3 -5
- janito/clear_statement_parser/clear_statement_format.txt +328 -0
- janito/clear_statement_parser/examples.txt +326 -0
- janito/clear_statement_parser/models.py +104 -0
- janito/clear_statement_parser/parser.py +496 -0
- janito/cli/base.py +30 -0
- janito/cli/commands.py +30 -38
- janito/cli/functions.py +19 -194
- janito/cli/handlers/ask.py +22 -0
- janito/cli/handlers/demo.py +22 -0
- janito/cli/handlers/request.py +24 -0
- janito/cli/handlers/scan.py +9 -0
- janito/cli/history.py +61 -0
- janito/common.py +34 -3
- janito/config.py +71 -6
- janito/demo/__init__.py +4 -0
- janito/demo/data.py +13 -0
- janito/demo/mock_data.py +20 -0
- janito/demo/operations.py +45 -0
- janito/demo/runner.py +59 -0
- janito/demo/scenarios.py +32 -0
- janito/prompts.py +1 -80
- janito/qa.py +4 -3
- janito/search_replace/README.md +146 -0
- janito/search_replace/__init__.py +6 -0
- janito/search_replace/__main__.py +21 -0
- janito/search_replace/core.py +119 -0
- janito/search_replace/parser.py +52 -0
- janito/search_replace/play.py +61 -0
- janito/search_replace/replacer.py +36 -0
- janito/search_replace/searcher.py +299 -0
- janito/shell/__init__.py +39 -0
- janito/shell/bus.py +31 -0
- janito/shell/commands.py +195 -0
- janito/shell/handlers.py +122 -0
- janito/shell/history.py +20 -0
- janito/shell/processor.py +52 -0
- janito/tui/__init__.py +21 -0
- janito/tui/base.py +22 -0
- janito/tui/flows/__init__.py +5 -0
- janito/tui/flows/changes.py +65 -0
- janito/tui/flows/content.py +128 -0
- janito/tui/flows/selection.py +117 -0
- janito/tui/screens/__init__.py +3 -0
- janito/tui/screens/app.py +1 -0
- janito/workspace/__init__.py +7 -0
- janito/workspace/analysis.py +121 -0
- janito/workspace/manager.py +48 -0
- janito/workspace/scan.py +232 -0
- janito-0.6.0.dist-info/METADATA +185 -0
- janito-0.6.0.dist-info/RECORD +95 -0
- {janito-0.5.0.dist-info → janito-0.6.0.dist-info}/WHEEL +1 -1
- janito/_contextparser.py +0 -113
- janito/analysis/display.py +0 -149
- janito/analysis/options.py +0 -112
- janito/change/applier.py +0 -269
- janito/change/content.py +0 -62
- janito/change/indentation.py +0 -33
- janito/change/position.py +0 -169
- janito/changeviewer/panels.py +0 -268
- janito/changeviewer/styling.py +0 -59
- janito/console/__init__.py +0 -3
- janito/console/commands.py +0 -112
- janito/console/core.py +0 -62
- janito/console/display.py +0 -157
- janito/fileparser.py +0 -334
- janito/scan.py +0 -176
- janito/tests/test_fileparser.py +0 -26
- janito-0.5.0.dist-info/METADATA +0 -146
- janito-0.5.0.dist-info/RECORD +0 -45
- {janito-0.5.0.dist-info → janito-0.6.0.dist-info}/entry_points.txt +0 -0
- {janito-0.5.0.dist-info → janito-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,58 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import Set, List
|
3
|
+
import shutil
|
4
|
+
from rich.console import Console
|
5
|
+
from janito.config import config
|
6
|
+
from ..parser import FileChange, ChangeOperation
|
7
|
+
|
8
|
+
def verify_changes(changes: List[FileChange]) -> tuple[bool, str]:
|
9
|
+
"""Verify changes can be safely applied to workspace_dir.
|
10
|
+
Returns (is_safe, error_message)."""
|
11
|
+
for change in changes:
|
12
|
+
source_path = config.workspace_dir / change.name
|
13
|
+
|
14
|
+
if change.operation == ChangeOperation.CREATE_FILE:
|
15
|
+
if source_path.exists():
|
16
|
+
return False, f"Cannot create {change.name} - already exists"
|
17
|
+
|
18
|
+
elif change.operation in (ChangeOperation.MOVE_FILE, ChangeOperation.RENAME_FILE):
|
19
|
+
if not source_path.exists():
|
20
|
+
return False, f"Cannot {change.operation.name.lower()} non-existent file {change.name}"
|
21
|
+
target_path = config.workspace_dir / change.target
|
22
|
+
if target_path.exists():
|
23
|
+
return False, f"Cannot {change.operation.name.lower()} {change.name} to {change.target} - target already exists"
|
24
|
+
|
25
|
+
|
26
|
+
return True, ""
|
27
|
+
|
28
|
+
def apply_changes(changes: List[FileChange], preview_dir: Path, console: Console) -> bool:
|
29
|
+
"""Apply all changes from preview to workspace_dir.
|
30
|
+
Returns success status."""
|
31
|
+
is_safe, error = verify_changes(changes)
|
32
|
+
if not is_safe:
|
33
|
+
console.print(f"[red]Error: {error}[/red]")
|
34
|
+
return False
|
35
|
+
|
36
|
+
console.print("\n[blue]Applying changes to working directory...[/blue]")
|
37
|
+
|
38
|
+
for change in changes:
|
39
|
+
if change.operation == ChangeOperation.REMOVE_FILE:
|
40
|
+
remove_from_workspace_dir(change.name, console)
|
41
|
+
else:
|
42
|
+
filepath = change.target if change.operation == ChangeOperation.RENAME_FILE else change.name
|
43
|
+
target_path = config.workspace_dir / filepath
|
44
|
+
preview_path = preview_dir / filepath
|
45
|
+
|
46
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
47
|
+
if preview_path.exists():
|
48
|
+
shutil.copy2(preview_path, target_path)
|
49
|
+
console.print(f"[dim]Applied changes to {filepath}[/dim]")
|
50
|
+
|
51
|
+
return True
|
52
|
+
|
53
|
+
def remove_from_workspace_dir(filepath: Path, console: Console) -> None:
|
54
|
+
"""Remove file from working directory"""
|
55
|
+
target_path = config.workspace_dir / filepath
|
56
|
+
if target_path.exists():
|
57
|
+
target_path.unlink()
|
58
|
+
console.print(f"[red]Removed {filepath}[/red]")
|
janito/change/core.py
ADDED
@@ -0,0 +1,131 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import Optional, Tuple, Optional, List
|
3
|
+
from rich.console import Console
|
4
|
+
from rich.prompt import Confirm
|
5
|
+
from rich.panel import Panel
|
6
|
+
from rich.columns import Columns
|
7
|
+
from rich import box
|
8
|
+
|
9
|
+
from janito.common import progress_send_message
|
10
|
+
from janito.change.history import save_changes_to_history
|
11
|
+
from janito.config import config
|
12
|
+
from janito.workspace.scan import collect_files_content
|
13
|
+
from .viewer import preview_all_changes
|
14
|
+
from janito.workspace.analysis import analyze_workspace_content as show_content_stats
|
15
|
+
|
16
|
+
from . import (
|
17
|
+
build_change_request_prompt,
|
18
|
+
parse_response,
|
19
|
+
setup_workspace_dir_preview,
|
20
|
+
ChangeApplier
|
21
|
+
)
|
22
|
+
|
23
|
+
from .analysis import analyze_request
|
24
|
+
|
25
|
+
def process_change_request(
|
26
|
+
request: str,
|
27
|
+
preview_only: bool = False,
|
28
|
+
debug: bool = False
|
29
|
+
) -> Tuple[bool, Optional[Path]]:
|
30
|
+
"""
|
31
|
+
Process a change request through the main flow.
|
32
|
+
Return:
|
33
|
+
success: True if the request was processed successfully
|
34
|
+
history_file: Path to the saved history file
|
35
|
+
"""
|
36
|
+
console = Console()
|
37
|
+
paths_to_scan = config.include if config.include else [config.workspace_dir]
|
38
|
+
|
39
|
+
content_xml = collect_files_content(paths_to_scan)
|
40
|
+
|
41
|
+
# Show workspace content preview
|
42
|
+
show_content_stats(content_xml)
|
43
|
+
|
44
|
+
analysis = analyze_request(request, content_xml)
|
45
|
+
if not analysis:
|
46
|
+
console.print("[red]Analysis failed or interrupted[/red]")
|
47
|
+
return False, None
|
48
|
+
|
49
|
+
prompt = build_change_request_prompt(request, analysis.format_option_text(), content_xml)
|
50
|
+
response = progress_send_message(prompt)
|
51
|
+
if not response:
|
52
|
+
console.print("[red]Failed to get response from AI[/red]")
|
53
|
+
return False, None
|
54
|
+
|
55
|
+
history_file = save_changes_to_history(response, request)
|
56
|
+
|
57
|
+
# Parse changes
|
58
|
+
changes = parse_response(response)
|
59
|
+
if not changes:
|
60
|
+
console.print("[yellow]No changes found in response[/yellow]")
|
61
|
+
return False, None
|
62
|
+
|
63
|
+
# Extract response info after END_OF_INSTRUCTIONS
|
64
|
+
response_info = extract_response_info(response)
|
65
|
+
|
66
|
+
# Show request and response info in panels
|
67
|
+
request_panel = Panel(
|
68
|
+
request,
|
69
|
+
title="User Request",
|
70
|
+
border_style="cyan",
|
71
|
+
box=box.ROUNDED
|
72
|
+
)
|
73
|
+
response_panel = Panel(
|
74
|
+
response_info if response_info else "No additional information provided",
|
75
|
+
title="Response Information",
|
76
|
+
border_style="green",
|
77
|
+
box=box.ROUNDED
|
78
|
+
)
|
79
|
+
|
80
|
+
# Display panels side by side
|
81
|
+
columns = Columns([request_panel, response_panel], equal=True, expand=True)
|
82
|
+
console.print("\n")
|
83
|
+
console.print(columns)
|
84
|
+
console.print("\n")
|
85
|
+
|
86
|
+
if preview_only:
|
87
|
+
preview_all_changes(console, changes)
|
88
|
+
return True, history_file
|
89
|
+
|
90
|
+
# Create preview directory and apply changes
|
91
|
+
_, preview_dir = setup_workspace_dir_preview()
|
92
|
+
applier = ChangeApplier(preview_dir, debug=debug)
|
93
|
+
|
94
|
+
success, _ = applier.apply_changes(changes)
|
95
|
+
if success:
|
96
|
+
preview_all_changes(console, changes)
|
97
|
+
|
98
|
+
if not config.auto_apply:
|
99
|
+
apply_changes = Confirm.ask("[cyan]Apply changes to working dir?[/cyan]")
|
100
|
+
else:
|
101
|
+
apply_changes = True
|
102
|
+
console.print("[cyan]Auto-applying changes to working dir...[/cyan]")
|
103
|
+
|
104
|
+
if apply_changes:
|
105
|
+
applier.apply_to_workspace_dir(changes)
|
106
|
+
console.print("[green]Changes applied successfully[/green]")
|
107
|
+
else:
|
108
|
+
console.print("[yellow]Changes were not applied[/yellow]")
|
109
|
+
|
110
|
+
return success, history_file
|
111
|
+
|
112
|
+
|
113
|
+
def extract_response_info(response: str) -> str:
|
114
|
+
"""Extract information after END_OF_INSTRUCTIONS marker"""
|
115
|
+
if not response:
|
116
|
+
return ""
|
117
|
+
|
118
|
+
# Find the marker
|
119
|
+
marker = "END_INSTRUCTIONS"
|
120
|
+
marker_pos = response.find(marker)
|
121
|
+
|
122
|
+
if marker_pos == -1:
|
123
|
+
return ""
|
124
|
+
|
125
|
+
# Get text after marker, skipping the marker itself
|
126
|
+
info = response[marker_pos + len(marker):].strip()
|
127
|
+
|
128
|
+
# Remove any XML-style tags
|
129
|
+
info = info.replace("<Extra info about what was implemented/changed goes here>", "")
|
130
|
+
|
131
|
+
return info.strip()
|
@@ -1,20 +1,23 @@
|
|
1
1
|
from pathlib import Path
|
2
2
|
from datetime import datetime
|
3
|
-
from typing import Optional
|
3
|
+
from typing import Optional, Tuple
|
4
|
+
from janito.config import config
|
4
5
|
|
5
6
|
# Set fixed timestamp when module is loaded
|
6
7
|
APP_TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S")
|
7
8
|
|
8
|
-
def get_history_path(
|
9
|
+
def get_history_path() -> Path:
|
9
10
|
"""Create and return the history directory path"""
|
10
|
-
history_dir =
|
11
|
+
history_dir = config.workspace_dir / '.janito' / 'change_history'
|
11
12
|
history_dir.mkdir(parents=True, exist_ok=True)
|
12
13
|
return history_dir
|
13
14
|
|
14
15
|
def determine_history_file_type(filepath: Path) -> str:
|
15
16
|
"""Determine the type of saved file based on its name"""
|
16
17
|
name = filepath.name.lower()
|
17
|
-
if '
|
18
|
+
if '_changes_failed' in name:
|
19
|
+
return 'changes_failed'
|
20
|
+
elif 'changes' in name:
|
18
21
|
return 'changes'
|
19
22
|
elif 'selected' in name:
|
20
23
|
return 'selected'
|
@@ -24,13 +27,13 @@ def determine_history_file_type(filepath: Path) -> str:
|
|
24
27
|
return 'response'
|
25
28
|
return 'unknown'
|
26
29
|
|
27
|
-
def save_changes_to_history(content: str, request: str
|
30
|
+
def save_changes_to_history(content: str, request: str) -> Path:
|
28
31
|
"""Save change content to history folder with timestamp and request info"""
|
29
|
-
history_dir = get_history_path(
|
32
|
+
history_dir = get_history_path()
|
30
33
|
|
31
34
|
# Create history entry with request and changes
|
32
|
-
|
33
|
-
|
35
|
+
filename = f"{APP_TIMESTAMP}_changes.txt"
|
36
|
+
history_file = history_dir / filename
|
34
37
|
history_content = f"""Request: {request}
|
35
38
|
Timestamp: {APP_TIMESTAMP}
|
36
39
|
|
@@ -38,9 +41,4 @@ Changes:
|
|
38
41
|
{content}
|
39
42
|
"""
|
40
43
|
history_file.write_text(history_content)
|
41
|
-
return history_file
|
42
|
-
|
43
|
-
def get_history_file_path(workdir: Path) -> Path:
|
44
|
-
"""Get path for a history file with app timestamp"""
|
45
|
-
history_path = get_history_path(workdir)
|
46
|
-
return history_path / f"{APP_TIMESTAMP}_changes.txt"
|
44
|
+
return history_file
|
janito/change/parser.py
ADDED
@@ -0,0 +1,289 @@
|
|
1
|
+
import uuid
|
2
|
+
from dataclasses import dataclass, field
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import List, Optional
|
5
|
+
from rich.console import Console
|
6
|
+
from janito.config import config
|
7
|
+
from janito.clear_statement_parser.parser import Statement, StatementParser
|
8
|
+
|
9
|
+
console = Console(stderr=True)
|
10
|
+
|
11
|
+
from .prompts import CHANGE_REQUEST_PROMPT
|
12
|
+
|
13
|
+
|
14
|
+
import uuid
|
15
|
+
from dataclasses import dataclass, field
|
16
|
+
from pathlib import Path
|
17
|
+
from typing import List, Optional, Dict, Union
|
18
|
+
from rich.console import Console
|
19
|
+
from enum import Enum, auto
|
20
|
+
|
21
|
+
class ChangeOperation(Enum):
|
22
|
+
CREATE_FILE = auto()
|
23
|
+
REPLACE_FILE = auto()
|
24
|
+
RENAME_FILE = auto()
|
25
|
+
REMOVE_FILE = auto()
|
26
|
+
MODIFY_FILE = auto()
|
27
|
+
MOVE_FILE = auto()
|
28
|
+
|
29
|
+
@dataclass
|
30
|
+
class TextChange:
|
31
|
+
"""Represents a search and replace/delete operation"""
|
32
|
+
search_content: Optional[str] = None
|
33
|
+
replace_content: Optional[str] = None
|
34
|
+
reason: Optional[str] = None
|
35
|
+
operation: Optional[str] = None
|
36
|
+
|
37
|
+
@property
|
38
|
+
def is_append(self) -> bool:
|
39
|
+
return self.operation == 'Append'
|
40
|
+
|
41
|
+
@property
|
42
|
+
def is_delete(self) -> bool:
|
43
|
+
return self.operation == 'Delete' or (self.search_content and not self.replace_content)
|
44
|
+
|
45
|
+
def validate(self) -> bool:
|
46
|
+
"""Validate the text change operation"""
|
47
|
+
if not self.search_content and self.replace_content is None:
|
48
|
+
return False
|
49
|
+
return True
|
50
|
+
|
51
|
+
@dataclass
|
52
|
+
class FileChange:
|
53
|
+
"""Represents a file change operation"""
|
54
|
+
operation: ChangeOperation
|
55
|
+
name: Path # Changed back from path to name
|
56
|
+
target: Optional[Path] = None
|
57
|
+
source: Optional[Path] = None
|
58
|
+
content: Optional[str] = None
|
59
|
+
text_changes: List[TextChange] = field(default_factory=list)
|
60
|
+
original_content: Optional[str] = None
|
61
|
+
reason: Optional[str] = None
|
62
|
+
|
63
|
+
def add_text_changes(self, changes: List[TextChange]):
|
64
|
+
"""Add multiple text changes to the existing list"""
|
65
|
+
self.text_changes.extend(changes)
|
66
|
+
|
67
|
+
@classmethod
|
68
|
+
def from_dict(cls, data: Dict[str, Union[str, Path]]) -> 'FileChange':
|
69
|
+
"""Create FileChange instance from dictionary data"""
|
70
|
+
operation = ChangeOperation[data['operation'].upper()]
|
71
|
+
return cls(
|
72
|
+
operation=operation,
|
73
|
+
name=Path(data['name']), # Changed back to name
|
74
|
+
target=Path(data['target']) if data.get('target') else None,
|
75
|
+
source=Path(data.get('source')) if data.get('source') else None,
|
76
|
+
content=data.get('content'),
|
77
|
+
reason=data.get('reason')
|
78
|
+
)
|
79
|
+
|
80
|
+
def validate_required_parameters(self) -> bool:
|
81
|
+
"""Validate the file change operation and raise detailed errors if parameters are missing"""
|
82
|
+
if self.operation == ChangeOperation.RENAME_FILE:
|
83
|
+
if not self.source:
|
84
|
+
raise ValueError(f"Missing 'source' parameter for {self.operation.name}")
|
85
|
+
if not self.target:
|
86
|
+
raise ValueError(f"Missing 'target' parameter for {self.operation.name}")
|
87
|
+
|
88
|
+
elif self.operation in (ChangeOperation.CREATE_FILE, ChangeOperation.REPLACE_FILE):
|
89
|
+
if not self.content:
|
90
|
+
raise ValueError(f"Missing 'content' parameter for {self.operation.name}")
|
91
|
+
|
92
|
+
elif self.operation == ChangeOperation.MODIFY_FILE:
|
93
|
+
if not self.text_changes:
|
94
|
+
raise ValueError(f"No closed text changes found for {self.operation.name}")
|
95
|
+
|
96
|
+
return True
|
97
|
+
|
98
|
+
class CommandParser:
|
99
|
+
def __init__(self, debug: bool = False):
|
100
|
+
self.debug = debug
|
101
|
+
self.console = Console(stderr=True)
|
102
|
+
|
103
|
+
def parse_statements(self, statements: List[Statement]) -> List[FileChange]:
|
104
|
+
"""Parse a list of Statement objects into FileChange objects"""
|
105
|
+
if self.debug:
|
106
|
+
self.console.print("[dim]Starting to parse statements...[/dim]")
|
107
|
+
|
108
|
+
changes = []
|
109
|
+
|
110
|
+
for statement in statements:
|
111
|
+
statement_key = statement.name.upper().replace(' ', '_')
|
112
|
+
supported_opers = [op.name.title().upper() for op in ChangeOperation]
|
113
|
+
if statement_key not in supported_opers:
|
114
|
+
raise Exception(f"{statement_key} not in supported statements: {supported_opers}")
|
115
|
+
|
116
|
+
change = self.convert_statement_to_filechange(statement)
|
117
|
+
if not change:
|
118
|
+
raise Exception(f"Invalid change found: {statement.name}")
|
119
|
+
if not change.validate_required_parameters():
|
120
|
+
raise Exception(f"Missing required parameters for change: {statement.name}")
|
121
|
+
changes.append(change)
|
122
|
+
return changes
|
123
|
+
|
124
|
+
def convert_statement_to_filechange(self, statement: Statement) -> Optional[FileChange]:
|
125
|
+
"""Convert a Statement to a FileChange object"""
|
126
|
+
try:
|
127
|
+
operation = ChangeOperation[statement.name.upper().replace(' ', '_')]
|
128
|
+
change = FileChange(
|
129
|
+
operation=operation,
|
130
|
+
name=Path(statement.parameters.get('name', '')), # Changed back to name
|
131
|
+
reason=statement.parameters.get('reason')
|
132
|
+
)
|
133
|
+
|
134
|
+
if 'target' in statement.parameters:
|
135
|
+
change.target = Path(statement.parameters['target'])
|
136
|
+
if 'source' in statement.parameters:
|
137
|
+
change.source = Path(statement.parameters['source'])
|
138
|
+
change.name = Path(statement.parameters['source']) # Changed back to name
|
139
|
+
|
140
|
+
content = statement.parameters.get('content')
|
141
|
+
if content:
|
142
|
+
change.content = self._clean_content(content)
|
143
|
+
|
144
|
+
# Handle multiple Changes blocks - combine all text changes
|
145
|
+
all_text_changes = []
|
146
|
+
for block_name, block_statements in statement.blocks:
|
147
|
+
# Handle both numbered (Changes#1) and unnumbered (Changes) blocks
|
148
|
+
base_name = block_name.split('#')[0]
|
149
|
+
if base_name == 'Changes':
|
150
|
+
if self.debug:
|
151
|
+
self.console.print(f"[dim]Processing Changes block: {block_name}[/dim]")
|
152
|
+
new_changes = self.parse_modifications_from_list(block_statements)
|
153
|
+
all_text_changes.extend(new_changes)
|
154
|
+
|
155
|
+
if all_text_changes:
|
156
|
+
change.text_changes = all_text_changes
|
157
|
+
|
158
|
+
return change
|
159
|
+
except Exception as e:
|
160
|
+
if self.debug:
|
161
|
+
self.console.print(f"[red]Error converting statement: {e}[/red]")
|
162
|
+
return None
|
163
|
+
|
164
|
+
def parse_modifications_from_list(self, mod_statements: List[Statement]) -> List[TextChange]:
|
165
|
+
"""Convert parsed modifications list to TextChange objects"""
|
166
|
+
modifications = []
|
167
|
+
|
168
|
+
for statement in mod_statements:
|
169
|
+
try:
|
170
|
+
if statement.name == 'Replace':
|
171
|
+
mod = TextChange(
|
172
|
+
search_content=self._clean_content(statement.parameters.get('search', '')),
|
173
|
+
replace_content=self._clean_content(statement.parameters.get('with', '')),
|
174
|
+
reason=statement.parameters.get('reason'),
|
175
|
+
operation='Replace'
|
176
|
+
)
|
177
|
+
elif statement.name == 'Delete':
|
178
|
+
mod = TextChange(
|
179
|
+
search_content=self._clean_content(statement.parameters.get('search', '')),
|
180
|
+
reason=statement.parameters.get('reason'),
|
181
|
+
operation='Delete'
|
182
|
+
)
|
183
|
+
elif statement.name == 'Append':
|
184
|
+
mod = TextChange(
|
185
|
+
search_content='',
|
186
|
+
replace_content=self._clean_content(statement.parameters.get('content', '')),
|
187
|
+
reason=statement.parameters.get('reason'),
|
188
|
+
operation='Append'
|
189
|
+
)
|
190
|
+
else:
|
191
|
+
continue
|
192
|
+
|
193
|
+
if mod.validate():
|
194
|
+
modifications.append(mod)
|
195
|
+
except Exception as e:
|
196
|
+
if self.debug:
|
197
|
+
self.console.print(f"[red]Error processing modification: {e}[/red]")
|
198
|
+
continue
|
199
|
+
|
200
|
+
return modifications
|
201
|
+
|
202
|
+
@staticmethod
|
203
|
+
def _clean_content(content: str) -> str:
|
204
|
+
"""Clean content by removing leading dots and normalizing line endings"""
|
205
|
+
if not content:
|
206
|
+
return ''
|
207
|
+
lines = content.splitlines()
|
208
|
+
cleaned_lines = [line[1:] if line.startswith('.') else line for line in lines]
|
209
|
+
return '\n'.join(cleaned_lines)
|
210
|
+
|
211
|
+
def extract_instructions_section(response_text: str) -> Optional[str]:
|
212
|
+
"""Extract text between BEGIN_INSTRUCTIONS and END_INSTRUCTIONS markers using exact line matching"""
|
213
|
+
try:
|
214
|
+
lines = response_text.splitlines()
|
215
|
+
start_marker = "BEGIN_INSTRUCTIONS"
|
216
|
+
end_marker = "END_INSTRUCTIONS"
|
217
|
+
|
218
|
+
# Find exact line matches for markers
|
219
|
+
start_idx = None
|
220
|
+
end_idx = None
|
221
|
+
|
222
|
+
for i, line in enumerate(lines):
|
223
|
+
line = line.strip()
|
224
|
+
if line == start_marker and start_idx is None:
|
225
|
+
start_idx = i
|
226
|
+
continue
|
227
|
+
if line == end_marker and start_idx is not None:
|
228
|
+
end_idx = i
|
229
|
+
break
|
230
|
+
|
231
|
+
if start_idx is None or end_idx is None:
|
232
|
+
if config.debug:
|
233
|
+
if start_idx is None:
|
234
|
+
console.print("[yellow]BEGIN_CHANGES marker not found[/yellow]")
|
235
|
+
else:
|
236
|
+
console.print("[yellow]END_CHANGES marker not found[/yellow]")
|
237
|
+
return None
|
238
|
+
|
239
|
+
# Extract lines between markers (exclusive)
|
240
|
+
changes_text = '\n'.join(lines[start_idx + 1:end_idx])
|
241
|
+
if not changes_text.strip():
|
242
|
+
if config.debug:
|
243
|
+
console.print("[yellow]Empty changes section found[/yellow]")
|
244
|
+
return None
|
245
|
+
|
246
|
+
return changes_text.strip()
|
247
|
+
|
248
|
+
except Exception as e:
|
249
|
+
console.print(f"[red]Error extracting changes section: {e}[/red]")
|
250
|
+
return None
|
251
|
+
|
252
|
+
def parse_response(response_text: str) -> List[FileChange]:
|
253
|
+
"""Parse a response string into FileChange objects"""
|
254
|
+
parser = CommandParser()
|
255
|
+
statement_parser = StatementParser()
|
256
|
+
|
257
|
+
# First extract the changes section
|
258
|
+
instructions = extract_instructions_section(response_text)
|
259
|
+
if not instructions:
|
260
|
+
if config.debug:
|
261
|
+
console.print("[yellow]No changes section found in response[/yellow]")
|
262
|
+
return []
|
263
|
+
|
264
|
+
statements = statement_parser.parse(instructions)
|
265
|
+
return parser.parse_statements(statements)
|
266
|
+
|
267
|
+
def build_change_request_prompt(
|
268
|
+
option_text: str,
|
269
|
+
request: str,
|
270
|
+
files_content_xml: str = ""
|
271
|
+
) -> str:
|
272
|
+
"""Build prompt for change request details
|
273
|
+
|
274
|
+
Args:
|
275
|
+
option_text: Formatted text describing the selected option
|
276
|
+
request: The original user request
|
277
|
+
files_content_xml: Content of relevant files in XML format
|
278
|
+
|
279
|
+
Returns:
|
280
|
+
Formatted prompt string
|
281
|
+
"""
|
282
|
+
short_uuid = str(uuid.uuid4())[:8]
|
283
|
+
|
284
|
+
return CHANGE_REQUEST_PROMPT.format(
|
285
|
+
option_text=option_text,
|
286
|
+
request=request,
|
287
|
+
files_content=files_content_xml,
|
288
|
+
uuid=short_uuid
|
289
|
+
)
|
janito/change/play.py
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import Tuple, Optional
|
3
|
+
from rich.console import Console
|
4
|
+
from rich.prompt import Confirm
|
5
|
+
from .parser import parse_response
|
6
|
+
from .preview import setup_workspace_dir_preview
|
7
|
+
from .applier.main import ChangeApplier
|
8
|
+
from .viewer import preview_all_changes # Add this import
|
9
|
+
from ..config import config # Add this import
|
10
|
+
|
11
|
+
|
12
|
+
def play_saved_changes(history_file: Path) -> Tuple[bool, Optional[Path]]:
|
13
|
+
"""
|
14
|
+
Replay changes from a saved history file
|
15
|
+
Returns:
|
16
|
+
success: True if changes were applied successfully
|
17
|
+
history_file: Path to the history file that was played
|
18
|
+
"""
|
19
|
+
console = Console()
|
20
|
+
|
21
|
+
if not history_file.exists():
|
22
|
+
console.print(f"[red]History file not found: {history_file}[/red]")
|
23
|
+
return False, None
|
24
|
+
|
25
|
+
content = history_file.read_text()
|
26
|
+
changes = parse_response(content)
|
27
|
+
|
28
|
+
if not changes:
|
29
|
+
console.print("[yellow]No changes found in history file[/yellow]")
|
30
|
+
return False, None
|
31
|
+
|
32
|
+
|
33
|
+
# Create preview directory and apply changes
|
34
|
+
_, preview_dir = setup_workspace_dir_preview()
|
35
|
+
applier = ChangeApplier(preview_dir)
|
36
|
+
|
37
|
+
success, _ = applier.apply_changes(changes, debug=True)
|
38
|
+
if success:
|
39
|
+
preview_all_changes(console, changes)
|
40
|
+
|
41
|
+
if not config.auto_apply:
|
42
|
+
apply_changes = Confirm.ask("[cyan]Apply changes to working dir?[/cyan]", default=False)
|
43
|
+
else:
|
44
|
+
apply_changes = True
|
45
|
+
console.print("[cyan]Auto-applying changes to working dir...[/cyan]")
|
46
|
+
|
47
|
+
if apply_changes:
|
48
|
+
applier.apply_to_workspace_dir(changes)
|
49
|
+
console.print("[green]Changes applied successfully[/green]")
|
50
|
+
else:
|
51
|
+
console.print("[yellow]Changes were not applied[/yellow]")
|
52
|
+
return False, history_file
|
53
|
+
|
54
|
+
return success, history_file
|
janito/change/preview.py
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
import shutil
|
3
|
+
import tempfile
|
4
|
+
from typing import List, Set, Tuple
|
5
|
+
from datetime import datetime
|
6
|
+
from rich.console import Console
|
7
|
+
from rich.panel import Panel
|
8
|
+
|
9
|
+
from janito.config import config
|
10
|
+
|
11
|
+
def create_backup() -> Path:
|
12
|
+
"""Create backup directory and restore script.
|
13
|
+
Returns the path to the backup directory."""
|
14
|
+
backup_dir = config.workspace_dir / '.janito' / 'backups' / datetime.now().strftime('%Y%m%d_%H%M%S')
|
15
|
+
backup_dir.parent.mkdir(parents=True, exist_ok=True)
|
16
|
+
|
17
|
+
# Copy existing files to backup directory
|
18
|
+
if config.workspace_dir.exists():
|
19
|
+
shutil.copytree(config.workspace_dir, backup_dir, ignore=shutil.ignore_patterns('.janito', '.git'))
|
20
|
+
|
21
|
+
# Create restore script
|
22
|
+
restore_script = config.workspace_dir / '.janito' / 'restore.sh'
|
23
|
+
restore_script.parent.mkdir(parents=True, exist_ok=True)
|
24
|
+
script_content = f"""#!/bin/bash
|
25
|
+
# Restore script generated by Janito
|
26
|
+
# Restores files from backup created at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
27
|
+
|
28
|
+
# Exit on error
|
29
|
+
set -e
|
30
|
+
|
31
|
+
# Get backup directory from argument or use latest
|
32
|
+
BACKUP_DIR="$1"
|
33
|
+
if [ -z "$BACKUP_DIR" ]; then
|
34
|
+
BACKUP_DIR="{backup_dir}"
|
35
|
+
echo "No backup directory specified, using latest: $BACKUP_DIR"
|
36
|
+
fi
|
37
|
+
|
38
|
+
# Show usage if --help is provided
|
39
|
+
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
40
|
+
echo "Usage: $0 [backup_directory]"
|
41
|
+
echo ""
|
42
|
+
echo "If no backup directory is provided, uses the latest backup at:"
|
43
|
+
echo "{backup_dir}"
|
44
|
+
exit 0
|
45
|
+
fi
|
46
|
+
|
47
|
+
# Check if backup directory exists
|
48
|
+
if [ ! -d "$BACKUP_DIR" ]; then
|
49
|
+
echo "Error: Backup directory not found at $BACKUP_DIR"
|
50
|
+
exit 1
|
51
|
+
fi
|
52
|
+
|
53
|
+
# Restore files from backup
|
54
|
+
echo "Restoring files from backup..."
|
55
|
+
cp -r "$BACKUP_DIR"/* "{config.workspace_dir}/"
|
56
|
+
|
57
|
+
echo "Files restored successfully from $BACKUP_DIR"
|
58
|
+
"""
|
59
|
+
restore_script.write_text(script_content)
|
60
|
+
restore_script.chmod(0o755)
|
61
|
+
|
62
|
+
return backup_dir
|
63
|
+
|
64
|
+
def setup_preview_directory() -> Path:
|
65
|
+
"""Creates and sets up preview directory with working directory contents.
|
66
|
+
Returns the path to the preview directory."""
|
67
|
+
preview_dir = Path(tempfile.mkdtemp())
|
68
|
+
|
69
|
+
# Copy existing files to preview directory if workspace_dir exists
|
70
|
+
if config.workspace_dir.exists():
|
71
|
+
shutil.copytree(config.workspace_dir, preview_dir, dirs_exist_ok=True,
|
72
|
+
ignore=shutil.ignore_patterns('.janito', '.git'))
|
73
|
+
|
74
|
+
return preview_dir
|
75
|
+
|
76
|
+
def setup_workspace_dir_preview() -> tuple[Path, Path]:
|
77
|
+
"""Sets up both backup and preview directories.
|
78
|
+
Returns (backup_dir, preview_dir) tuple."""
|
79
|
+
backup_dir = create_backup()
|
80
|
+
preview_dir = setup_preview_directory()
|
81
|
+
return backup_dir, preview_dir
|
82
|
+
|