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,33 @@
|
|
1
|
+
|
2
|
+
def adjust_indentation(original: str, replacement: str) -> str:
|
3
|
+
"""Adjust replacement text indentation based on original text"""
|
4
|
+
if not original or not replacement:
|
5
|
+
return replacement
|
6
|
+
|
7
|
+
# Get first non-empty lines to compare indentation
|
8
|
+
orig_lines = original.splitlines()
|
9
|
+
repl_lines = replacement.splitlines()
|
10
|
+
|
11
|
+
orig_first = next((l for l in orig_lines if l.strip()), '')
|
12
|
+
repl_first = next((l for l in repl_lines if l.strip()), '')
|
13
|
+
|
14
|
+
# Calculate indentation difference
|
15
|
+
orig_indent = len(orig_first) - len(orig_first.lstrip())
|
16
|
+
repl_indent = len(repl_first) - len(repl_first.lstrip())
|
17
|
+
indent_delta = orig_indent - repl_indent
|
18
|
+
|
19
|
+
if indent_delta == 0:
|
20
|
+
return replacement
|
21
|
+
|
22
|
+
# Adjust indentation for all lines
|
23
|
+
adjusted_lines = []
|
24
|
+
for line in repl_lines:
|
25
|
+
if not line.strip(): # Preserve empty lines
|
26
|
+
adjusted_lines.append(line)
|
27
|
+
continue
|
28
|
+
|
29
|
+
current_indent = len(line) - len(line.lstrip())
|
30
|
+
new_indent = max(0, current_indent + indent_delta)
|
31
|
+
adjusted_lines.append(' ' * new_indent + line.lstrip())
|
32
|
+
|
33
|
+
return '\n'.join(adjusted_lines)
|
@@ -0,0 +1,169 @@
|
|
1
|
+
|
2
|
+
from typing import List, Tuple
|
3
|
+
from janito.config import config
|
4
|
+
from rich.console import Console
|
5
|
+
|
6
|
+
def get_line_boundaries(text: str) -> List[Tuple[int, int, int, int]]:
|
7
|
+
"""Return list of (content_start, content_end, full_start, full_end) for each line.
|
8
|
+
content_start/end exclude leading/trailing whitespace
|
9
|
+
full_start/end include the whitespace and line endings"""
|
10
|
+
boundaries = []
|
11
|
+
start = 0
|
12
|
+
for line in text.splitlines(keepends=True):
|
13
|
+
content = line.strip()
|
14
|
+
if content:
|
15
|
+
content_start = start + len(line) - len(line.lstrip())
|
16
|
+
content_end = start + len(line.rstrip())
|
17
|
+
boundaries.append((content_start, content_end, start, start + len(line)))
|
18
|
+
else:
|
19
|
+
# Empty or whitespace-only lines
|
20
|
+
boundaries.append((start, start, start, start + len(line)))
|
21
|
+
start += len(line)
|
22
|
+
return boundaries
|
23
|
+
|
24
|
+
def normalize_content(text: str) -> Tuple[str, List[Tuple[int, int, int, int]]]:
|
25
|
+
"""Normalize text for searching while preserving position mapping.
|
26
|
+
Returns (normalized_text, line_boundaries)"""
|
27
|
+
# Replace Windows line endings
|
28
|
+
text = text.replace('\r\n', '\n')
|
29
|
+
text = text.replace('\r', '\n')
|
30
|
+
|
31
|
+
# Get line boundaries before normalization
|
32
|
+
boundaries = get_line_boundaries(text)
|
33
|
+
|
34
|
+
# Create normalized version with stripped lines
|
35
|
+
normalized = '\n'.join(line.strip() for line in text.splitlines())
|
36
|
+
|
37
|
+
return normalized, boundaries
|
38
|
+
|
39
|
+
def find_text_positions(text: str, search: str) -> List[Tuple[int, int]]:
|
40
|
+
"""Find all non-overlapping positions of search text in content,
|
41
|
+
comparing without leading/trailing whitespace but returning original positions."""
|
42
|
+
normalized_text, text_boundaries = normalize_content(text)
|
43
|
+
normalized_search, search_boundaries = normalize_content(search)
|
44
|
+
|
45
|
+
positions = []
|
46
|
+
start = 0
|
47
|
+
while True:
|
48
|
+
# Find next occurrence in normalized text
|
49
|
+
pos = normalized_text.find(normalized_search, start)
|
50
|
+
if pos == -1:
|
51
|
+
break
|
52
|
+
|
53
|
+
# Find the corresponding original text boundaries
|
54
|
+
search_lines = normalized_search.count('\n') + 1
|
55
|
+
|
56
|
+
# Get text line number at position
|
57
|
+
line_num = normalized_text.count('\n', 0, pos)
|
58
|
+
|
59
|
+
if line_num + search_lines <= len(text_boundaries):
|
60
|
+
# Get original start position from first line
|
61
|
+
orig_start = text_boundaries[line_num][2] # full_start
|
62
|
+
# Get original end position from last line
|
63
|
+
orig_end = text_boundaries[line_num + search_lines - 1][3] # full_end
|
64
|
+
|
65
|
+
positions.append((orig_start, orig_end))
|
66
|
+
|
67
|
+
start = pos + len(normalized_search)
|
68
|
+
|
69
|
+
return positions
|
70
|
+
|
71
|
+
def format_whitespace_debug(text: str) -> str:
|
72
|
+
"""Format text with visible whitespace markers"""
|
73
|
+
return text.replace(' ', '·').replace('\t', '→').replace('\n', '↵\n')
|
74
|
+
|
75
|
+
def format_context_preview(lines: List[str], max_lines: int = 5) -> str:
|
76
|
+
"""Format context lines for display, limiting the number of lines shown"""
|
77
|
+
if not lines:
|
78
|
+
return "No context lines"
|
79
|
+
preview = lines[:max_lines]
|
80
|
+
suffix = f"\n... and {len(lines) - max_lines} more lines" if len(lines) > max_lines else ""
|
81
|
+
return "\n".join(preview) + suffix
|
82
|
+
|
83
|
+
def parse_and_apply_changes_sequence(input_text: str, changes_text: str) -> str:
|
84
|
+
"""
|
85
|
+
Parse and apply changes to text:
|
86
|
+
= Find and keep line (preserving whitespace)
|
87
|
+
< Remove line at current position
|
88
|
+
> Add line at current position
|
89
|
+
"""
|
90
|
+
def find_initial_start(text_lines, sequence):
|
91
|
+
for i in range(len(text_lines) - len(sequence) + 1):
|
92
|
+
matches = True
|
93
|
+
for j, seq_line in enumerate(sequence):
|
94
|
+
if text_lines[i + j] != seq_line:
|
95
|
+
matches = False
|
96
|
+
break
|
97
|
+
if matches:
|
98
|
+
return i
|
99
|
+
|
100
|
+
if config.debug and i < 20: # Show first 20 attempted matches
|
101
|
+
console = Console()
|
102
|
+
console.print(f"\n[cyan]Checking position {i}:[/cyan]")
|
103
|
+
for j, seq_line in enumerate(sequence):
|
104
|
+
if i + j < len(text_lines):
|
105
|
+
match_status = "=" if text_lines[i + j] == seq_line else "≠"
|
106
|
+
console.print(f" {match_status} Expected: '{seq_line}'")
|
107
|
+
console.print(f" Found: '{text_lines[i + j]}'")
|
108
|
+
return -1
|
109
|
+
|
110
|
+
input_lines = input_text.splitlines()
|
111
|
+
changes = changes_text.splitlines()
|
112
|
+
|
113
|
+
sequence = []
|
114
|
+
# Find the context sequence in the input text
|
115
|
+
for line in changes:
|
116
|
+
if line[0] == '=':
|
117
|
+
sequence.append(line[1:])
|
118
|
+
else:
|
119
|
+
break
|
120
|
+
|
121
|
+
start_pos = find_initial_start(input_lines, sequence)
|
122
|
+
|
123
|
+
if start_pos == -1:
|
124
|
+
if config.debug:
|
125
|
+
console = Console()
|
126
|
+
console.print("\n[red]Failed to find context sequence match in file:[/red]")
|
127
|
+
console.print("[yellow]File content:[/yellow]")
|
128
|
+
for i, line in enumerate(input_lines):
|
129
|
+
console.print(f" {i+1:2d} | '{line}'")
|
130
|
+
return input_text
|
131
|
+
|
132
|
+
if config.debug:
|
133
|
+
console = Console()
|
134
|
+
console.print(f"\n[green]Found context match at line {start_pos + 1}[/green]")
|
135
|
+
|
136
|
+
result_lines = input_lines[:start_pos]
|
137
|
+
i = start_pos
|
138
|
+
|
139
|
+
for change in changes:
|
140
|
+
if not change:
|
141
|
+
if config.debug:
|
142
|
+
console.print(f" Preserving empty line")
|
143
|
+
continue
|
144
|
+
|
145
|
+
prefix = change[0]
|
146
|
+
content = change[1:]
|
147
|
+
|
148
|
+
if prefix == '=':
|
149
|
+
if config.debug:
|
150
|
+
console.print(f" Keep: '{content}'")
|
151
|
+
result_lines.append(content)
|
152
|
+
i += 1
|
153
|
+
elif prefix == '<':
|
154
|
+
if config.debug:
|
155
|
+
console.print(f" Delete: '{content}'")
|
156
|
+
i += 1
|
157
|
+
elif prefix == '>':
|
158
|
+
if config.debug:
|
159
|
+
console.print(f" Add: '{content}'")
|
160
|
+
result_lines.append(content)
|
161
|
+
|
162
|
+
result_lines.extend(input_lines[i:])
|
163
|
+
|
164
|
+
if config.debug:
|
165
|
+
console.print("\n[yellow]Final result:[/yellow]")
|
166
|
+
for i, line in enumerate(result_lines):
|
167
|
+
console.print(f" {i+1:2d} | '{line}'")
|
168
|
+
|
169
|
+
return '\n'.join(result_lines)
|
janito/changehistory.py
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from datetime import datetime
|
3
|
+
from typing import Optional
|
4
|
+
|
5
|
+
# Set fixed timestamp when module is loaded
|
6
|
+
APP_TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S")
|
7
|
+
|
8
|
+
def get_history_path(workdir: Path) -> Path:
|
9
|
+
"""Create and return the history directory path"""
|
10
|
+
history_dir = workdir / '.janito' / 'change_history'
|
11
|
+
history_dir.mkdir(parents=True, exist_ok=True)
|
12
|
+
return history_dir
|
13
|
+
|
14
|
+
def determine_history_file_type(filepath: Path) -> str:
|
15
|
+
"""Determine the type of saved file based on its name"""
|
16
|
+
name = filepath.name.lower()
|
17
|
+
if 'changes' in name:
|
18
|
+
return 'changes'
|
19
|
+
elif 'selected' in name:
|
20
|
+
return 'selected'
|
21
|
+
elif 'analysis' in name:
|
22
|
+
return 'analysis'
|
23
|
+
elif 'response' in name:
|
24
|
+
return 'response'
|
25
|
+
return 'unknown'
|
26
|
+
|
27
|
+
def save_changes_to_history(content: str, request: str, workdir: Path) -> Path:
|
28
|
+
"""Save change content to history folder with timestamp and request info"""
|
29
|
+
history_dir = get_history_path(workdir)
|
30
|
+
|
31
|
+
# Create history entry with request and changes
|
32
|
+
history_file = history_dir / f"changes_{APP_TIMESTAMP}.txt"
|
33
|
+
|
34
|
+
history_content = f"""Request: {request}
|
35
|
+
Timestamp: {APP_TIMESTAMP}
|
36
|
+
|
37
|
+
Changes:
|
38
|
+
{content}
|
39
|
+
"""
|
40
|
+
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"
|
@@ -0,0 +1,12 @@
|
|
1
|
+
from .panels import show_change_preview, preview_all_changes
|
2
|
+
from .styling import set_theme
|
3
|
+
from .themes import ColorTheme, ThemeType, get_theme_by_type
|
4
|
+
|
5
|
+
__all__ = [
|
6
|
+
'show_change_preview',
|
7
|
+
'preview_all_changes',
|
8
|
+
'set_theme',
|
9
|
+
'ColorTheme',
|
10
|
+
'ThemeType',
|
11
|
+
'get_theme_by_type'
|
12
|
+
]
|
@@ -0,0 +1,28 @@
|
|
1
|
+
from typing import List, Tuple
|
2
|
+
|
3
|
+
def find_common_sections(search_lines: List[str], replace_lines: List[str]) -> Tuple[List[str], List[str], List[str], List[str], List[str]]:
|
4
|
+
"""Find common sections between search and replace content"""
|
5
|
+
# Find common lines from top
|
6
|
+
common_top = []
|
7
|
+
for s, r in zip(search_lines, replace_lines):
|
8
|
+
if s == r:
|
9
|
+
common_top.append(s)
|
10
|
+
else:
|
11
|
+
break
|
12
|
+
|
13
|
+
# Find common lines from bottom
|
14
|
+
search_remaining = search_lines[len(common_top):]
|
15
|
+
replace_remaining = replace_lines[len(common_top):]
|
16
|
+
|
17
|
+
common_bottom = []
|
18
|
+
for s, r in zip(reversed(search_remaining), reversed(replace_remaining)):
|
19
|
+
if s == r:
|
20
|
+
common_bottom.insert(0, s)
|
21
|
+
else:
|
22
|
+
break
|
23
|
+
|
24
|
+
# Get the unique middle sections
|
25
|
+
search_middle = search_remaining[:-len(common_bottom)] if common_bottom else search_remaining
|
26
|
+
replace_middle = replace_remaining[:-len(common_bottom)] if common_bottom else replace_remaining
|
27
|
+
|
28
|
+
return common_top, search_middle, replace_middle, common_bottom, search_lines
|
@@ -0,0 +1,268 @@
|
|
1
|
+
from rich.console import Console
|
2
|
+
from rich.panel import Panel
|
3
|
+
from rich.columns import Columns
|
4
|
+
from rich.text import Text
|
5
|
+
from rich.syntax import Syntax
|
6
|
+
from rich.table import Table
|
7
|
+
from rich import box
|
8
|
+
from pathlib import Path
|
9
|
+
from typing import List
|
10
|
+
from datetime import datetime
|
11
|
+
from janito.fileparser import FileChange
|
12
|
+
from janito.config import config
|
13
|
+
from .styling import format_content, create_legend_items, current_theme
|
14
|
+
from .themes import ColorTheme
|
15
|
+
from .diff import find_common_sections
|
16
|
+
|
17
|
+
|
18
|
+
def create_new_file_panel(filepath: Path, content: str) -> Panel:
|
19
|
+
"""Create a panel for new file creation"""
|
20
|
+
size_bytes = len(content.encode('utf-8'))
|
21
|
+
size_str = f"{size_bytes} bytes" if size_bytes < 1024 else f"{size_bytes/1024:.1f} KB"
|
22
|
+
|
23
|
+
# Create metadata table
|
24
|
+
metadata = Table.grid(padding=(0, 1))
|
25
|
+
metadata.add_row("File Size:", size_str)
|
26
|
+
metadata.add_row("Created:", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
27
|
+
|
28
|
+
# Create content preview with empty left panel
|
29
|
+
content_table = Table.grid(padding=(0, 2))
|
30
|
+
content_table.add_column("Before", justify="left")
|
31
|
+
content_table.add_column("After", justify="left")
|
32
|
+
|
33
|
+
empty_panel = Panel(
|
34
|
+
Text("(No previous content)", style="dim"),
|
35
|
+
title="Previous Content",
|
36
|
+
title_align="left",
|
37
|
+
border_style="#E06C75",
|
38
|
+
box=box.ROUNDED
|
39
|
+
)
|
40
|
+
|
41
|
+
content_display = content
|
42
|
+
if filepath.suffix in ['.py', '.js', '.ts', '.java', '.cpp', '.c']:
|
43
|
+
try:
|
44
|
+
content_display = Syntax(content, filepath.suffix.lstrip('.'), theme="monokai")
|
45
|
+
except:
|
46
|
+
pass
|
47
|
+
|
48
|
+
new_panel = Panel(
|
49
|
+
content_display,
|
50
|
+
title="New Content",
|
51
|
+
title_align="left",
|
52
|
+
border_style="#61AFEF",
|
53
|
+
box=box.ROUNDED
|
54
|
+
)
|
55
|
+
|
56
|
+
content_table.add_row(empty_panel, new_panel)
|
57
|
+
|
58
|
+
content = Table.grid(padding=(1, 0))
|
59
|
+
content.add_row(Panel(metadata, title="File Metadata", border_style="white"))
|
60
|
+
content.add_row(Panel(content_table, title="Content Preview", border_style="white"))
|
61
|
+
|
62
|
+
return Panel(
|
63
|
+
content,
|
64
|
+
title=f"[bold]✨ Creating {filepath}[/bold]",
|
65
|
+
title_align="left",
|
66
|
+
border_style="#8AE234",
|
67
|
+
box=box.ROUNDED
|
68
|
+
)
|
69
|
+
|
70
|
+
def create_change_panel(search: str, replace: str | None, description: str, index: int) -> Panel:
|
71
|
+
"""Create a panel for file changes"""
|
72
|
+
operation = 'delete' if replace is None else 'modify'
|
73
|
+
|
74
|
+
if replace is None:
|
75
|
+
return Panel(
|
76
|
+
Text(search, style="red"),
|
77
|
+
title=f"- Content to Delete{' - ' + description if description else ''}",
|
78
|
+
title_align="left",
|
79
|
+
border_style="#E06C75",
|
80
|
+
box=box.ROUNDED
|
81
|
+
)
|
82
|
+
|
83
|
+
search_lines = search.splitlines()
|
84
|
+
replace_lines = replace.splitlines()
|
85
|
+
common_top, search_middle, replace_middle, common_bottom, all_search_lines = find_common_sections(search_lines, replace_lines)
|
86
|
+
|
87
|
+
content_table = Table.grid(padding=(0, 2))
|
88
|
+
content_table.add_column("Current", justify="left", ratio=1)
|
89
|
+
content_table.add_column("New", justify="left", ratio=1)
|
90
|
+
|
91
|
+
# Add column headers
|
92
|
+
content_table.add_row(
|
93
|
+
Text("Current Content", style="bold cyan"),
|
94
|
+
Text("New Content", style="bold cyan")
|
95
|
+
)
|
96
|
+
|
97
|
+
# Add the actual content
|
98
|
+
content_table.add_row(
|
99
|
+
format_content(search_lines, search_lines, replace_lines, True, operation),
|
100
|
+
format_content(replace_lines, search_lines, replace_lines, False, operation)
|
101
|
+
)
|
102
|
+
|
103
|
+
header = f"Change {index}"
|
104
|
+
if description:
|
105
|
+
header += f": {description}"
|
106
|
+
|
107
|
+
return Panel(
|
108
|
+
content_table,
|
109
|
+
title=header,
|
110
|
+
title_align="left",
|
111
|
+
border_style="cyan",
|
112
|
+
box=box.ROUNDED
|
113
|
+
)
|
114
|
+
|
115
|
+
def create_replace_panel(filepath: Path, change: FileChange) -> Panel:
|
116
|
+
"""Create a panel for file replacement with metadata"""
|
117
|
+
old_size = len(change.original_content.encode('utf-8'))
|
118
|
+
new_size = len(change.content.encode('utf-8'))
|
119
|
+
|
120
|
+
# Create metadata table
|
121
|
+
metadata = Table.grid(padding=(0, 1))
|
122
|
+
metadata.add_row("Original Size:", f"{old_size/1024:.1f} KB")
|
123
|
+
metadata.add_row("New Size:", f"{new_size/1024:.1f} KB")
|
124
|
+
metadata.add_row("Size Change:", f"{(new_size - old_size)/1024:+.1f} KB")
|
125
|
+
metadata.add_row("Modified:", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
126
|
+
|
127
|
+
# Create unified diff preview
|
128
|
+
content_table = Table.grid(padding=(0, 2))
|
129
|
+
content_table.add_column("Current", justify="left")
|
130
|
+
content_table.add_column("New", justify="left")
|
131
|
+
|
132
|
+
# Add column headers
|
133
|
+
content_table.add_row(
|
134
|
+
Text("Current Content", style="bold cyan"),
|
135
|
+
Text("New Content", style="bold cyan")
|
136
|
+
)
|
137
|
+
|
138
|
+
# Add the actual content
|
139
|
+
content_table.add_row(
|
140
|
+
format_content(change.original_content.splitlines(),
|
141
|
+
change.original_content.splitlines(),
|
142
|
+
change.content.splitlines(), True),
|
143
|
+
format_content(change.content.splitlines(),
|
144
|
+
change.original_content.splitlines(),
|
145
|
+
change.content.splitlines(), False)
|
146
|
+
)
|
147
|
+
|
148
|
+
content = Table.grid(padding=(1, 0))
|
149
|
+
content.add_row(Panel(metadata, title="File Metadata", border_style="white"))
|
150
|
+
content.add_row(Panel(content_table, title="Content Preview", border_style="white"))
|
151
|
+
|
152
|
+
return Panel(
|
153
|
+
content,
|
154
|
+
title=f"[bold]🔄 Replacing {filepath}[/bold]",
|
155
|
+
title_align="left",
|
156
|
+
border_style="#FFB86C",
|
157
|
+
box=box.ROUNDED
|
158
|
+
)
|
159
|
+
|
160
|
+
def create_remove_file_panel(filepath: Path) -> Panel:
|
161
|
+
"""Create a panel for file removal"""
|
162
|
+
return Panel(
|
163
|
+
Text(f"This file will be deleted", style="red"),
|
164
|
+
title=f"[bold]- Removing {filepath}[/bold]",
|
165
|
+
title_align="left",
|
166
|
+
border_style="#F44336",
|
167
|
+
box=box.HEAVY,
|
168
|
+
padding=(1, 2)
|
169
|
+
)
|
170
|
+
|
171
|
+
def show_change_preview(console: Console, filepath: Path, change: FileChange) -> None:
|
172
|
+
"""Display a preview of changes for a single file"""
|
173
|
+
if change.remove_file:
|
174
|
+
panel = create_remove_file_panel(filepath)
|
175
|
+
console.print(panel)
|
176
|
+
console.print()
|
177
|
+
return
|
178
|
+
|
179
|
+
if change.is_new_file:
|
180
|
+
panel = create_new_file_panel(filepath, change.content)
|
181
|
+
console.print(panel)
|
182
|
+
console.print()
|
183
|
+
return
|
184
|
+
|
185
|
+
if change.replace_file:
|
186
|
+
panel = create_replace_panel(filepath, change)
|
187
|
+
console.print(panel)
|
188
|
+
console.print()
|
189
|
+
return
|
190
|
+
|
191
|
+
main_content = []
|
192
|
+
for i, (search, replace, description) in enumerate(change.search_blocks, 1):
|
193
|
+
panel = create_change_panel(search, replace, description, i)
|
194
|
+
main_content.append(panel)
|
195
|
+
|
196
|
+
file_panel = Panel(
|
197
|
+
Columns(main_content, align="center"),
|
198
|
+
title=f"Modifying {filepath} - {change.description}",
|
199
|
+
title_align="left",
|
200
|
+
border_style="white",
|
201
|
+
box=box.ROUNDED
|
202
|
+
)
|
203
|
+
console.print(file_panel)
|
204
|
+
console.print()
|
205
|
+
|
206
|
+
def preview_all_changes(console: Console, changes: List[FileChange]) -> None:
|
207
|
+
"""Show preview for all file changes"""
|
208
|
+
if config.debug:
|
209
|
+
_print_debug_info(console, changes)
|
210
|
+
|
211
|
+
console.print("\n[bold blue]Change Preview[/bold blue]")
|
212
|
+
|
213
|
+
has_modified_files = any(not change.is_new_file for change in changes)
|
214
|
+
if has_modified_files:
|
215
|
+
_show_legend(console)
|
216
|
+
|
217
|
+
new_files = [change for change in changes if change.is_new_file]
|
218
|
+
modified_files = [change for change in changes if not change.is_new_file]
|
219
|
+
|
220
|
+
for change in new_files:
|
221
|
+
show_change_preview(console, change.path, change)
|
222
|
+
for change in modified_files:
|
223
|
+
show_change_preview(console, change.path, change)
|
224
|
+
|
225
|
+
def _print_debug_info(console: Console, changes: List[FileChange]) -> None:
|
226
|
+
"""Print debug information about file changes"""
|
227
|
+
console.print("\n[blue]Debug: File Changes to Preview:[/blue]")
|
228
|
+
for change in changes:
|
229
|
+
console.print(f"\n[cyan]File:[/cyan] {change.path}")
|
230
|
+
console.print(f" [yellow]Is New File:[/yellow] {change.is_new_file}")
|
231
|
+
console.print(f" [yellow]Description:[/yellow] {change.description}")
|
232
|
+
if change.search_blocks:
|
233
|
+
console.print(" [yellow]Search Blocks:[/yellow]")
|
234
|
+
for i, (search, replace, desc) in enumerate(change.search_blocks, 1):
|
235
|
+
console.print(f" Block {i}:")
|
236
|
+
console.print(f" Description: {desc or 'No description'}")
|
237
|
+
console.print(f" Operation: {'Replace' if replace else 'Delete'}")
|
238
|
+
console.print(f" Search Length: {len(search)} chars")
|
239
|
+
if replace:
|
240
|
+
console.print(f" Replace Length: {len(replace)} chars")
|
241
|
+
console.print("\n[blue]End Debug File Changes[/blue]\n")
|
242
|
+
|
243
|
+
def _show_legend(console: Console) -> None:
|
244
|
+
"""Show the unified legend status bar"""
|
245
|
+
legend = create_legend_items()
|
246
|
+
|
247
|
+
# Calculate panel width based on legend content
|
248
|
+
legend_width = len(str(legend)) + 10 # Add padding for borders
|
249
|
+
|
250
|
+
legend_panel = Panel(
|
251
|
+
legend,
|
252
|
+
title="Changes Legend",
|
253
|
+
title_align="center",
|
254
|
+
border_style="white",
|
255
|
+
box=box.ROUNDED,
|
256
|
+
padding=(0, 2),
|
257
|
+
width=legend_width
|
258
|
+
)
|
259
|
+
|
260
|
+
# Create a full-width container and center the legend panel
|
261
|
+
container = Columns(
|
262
|
+
[legend_panel],
|
263
|
+
align="center",
|
264
|
+
expand=True
|
265
|
+
)
|
266
|
+
|
267
|
+
console.print(container)
|
268
|
+
console.print()
|
@@ -0,0 +1,59 @@
|
|
1
|
+
from rich.text import Text
|
2
|
+
from rich.console import Console
|
3
|
+
from typing import List
|
4
|
+
from .themes import DEFAULT_THEME, ColorTheme, ThemeType, get_theme_by_type
|
5
|
+
|
6
|
+
current_theme = DEFAULT_THEME
|
7
|
+
|
8
|
+
def set_theme(theme: ColorTheme) -> None:
|
9
|
+
"""Set the current color theme"""
|
10
|
+
global current_theme
|
11
|
+
current_theme = theme
|
12
|
+
|
13
|
+
def format_content(lines: List[str], search_lines: List[str], replace_lines: List[str], is_search: bool, operation: str = 'modify') -> Text:
|
14
|
+
"""Format content with highlighting using consistent colors and line numbers"""
|
15
|
+
text = Text()
|
16
|
+
|
17
|
+
# Create sets of lines for comparison
|
18
|
+
search_set = set(search_lines)
|
19
|
+
replace_set = set(replace_lines)
|
20
|
+
common_lines = search_set & replace_set
|
21
|
+
new_lines = replace_set - search_set
|
22
|
+
|
23
|
+
def add_line(line: str, prefix: str = " ", line_type: str = 'unchanged'):
|
24
|
+
# Ensure line_type is one of the valid types
|
25
|
+
valid_types = {'unchanged', 'deleted', 'modified', 'added'}
|
26
|
+
if line_type not in valid_types:
|
27
|
+
line_type = 'unchanged'
|
28
|
+
|
29
|
+
bg_color = current_theme.line_backgrounds.get(line_type, current_theme.line_backgrounds['unchanged'])
|
30
|
+
style = f"{current_theme.text_color} on {bg_color}"
|
31
|
+
|
32
|
+
# Add prefix with background
|
33
|
+
text.append(prefix, style=style)
|
34
|
+
# Add line content with background and pad with spaces
|
35
|
+
text.append(" " + line, style=style)
|
36
|
+
# Add newline with same background
|
37
|
+
text.append(" " * 1000 + "\n", style=style)
|
38
|
+
|
39
|
+
for line in lines:
|
40
|
+
if line in common_lines:
|
41
|
+
add_line(line, " ", 'unchanged')
|
42
|
+
elif not is_search and line in new_lines:
|
43
|
+
add_line(line, "✚", 'added')
|
44
|
+
else:
|
45
|
+
prefix = "✕" if is_search else "✚"
|
46
|
+
line_type = 'deleted' if is_search else 'modified'
|
47
|
+
add_line(line, prefix, line_type)
|
48
|
+
|
49
|
+
return text
|
50
|
+
|
51
|
+
def create_legend_items() -> Text:
|
52
|
+
"""Create unified legend status bar"""
|
53
|
+
legend = Text()
|
54
|
+
legend.append(" Unchanged ", style=f"{current_theme.text_color} on {current_theme.line_backgrounds['unchanged']}")
|
55
|
+
legend.append(" │ ", style="dim")
|
56
|
+
legend.append(" ✕ Deleted ", style=f"{current_theme.text_color} on {current_theme.line_backgrounds['deleted']}")
|
57
|
+
legend.append(" │ ", style="dim")
|
58
|
+
legend.append(" ✚ Added ", style=f"{current_theme.text_color} on {current_theme.line_backgrounds['added']}")
|
59
|
+
return legend
|
@@ -0,0 +1,57 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from typing import Dict
|
3
|
+
from enum import Enum
|
4
|
+
|
5
|
+
class ThemeType(Enum):
|
6
|
+
DARK = "dark"
|
7
|
+
LIGHT = "light"
|
8
|
+
|
9
|
+
# Dark theme colors
|
10
|
+
DARK_LINE_BACKGROUNDS = {
|
11
|
+
'unchanged': '#1A1A1A',
|
12
|
+
'added': '#003300',
|
13
|
+
'deleted': '#660000',
|
14
|
+
'modified': '#000030'
|
15
|
+
}
|
16
|
+
|
17
|
+
# Light theme colors
|
18
|
+
LIGHT_LINE_BACKGROUNDS = {
|
19
|
+
'unchanged': 'grey93', # Light gray for unchanged
|
20
|
+
'added': 'light_green', # Semantic light green for additions
|
21
|
+
'deleted': 'light_red', # Semantic light red for deletions
|
22
|
+
'modified': 'light_blue' # Semantic light blue for modifications
|
23
|
+
}
|
24
|
+
|
25
|
+
@dataclass
|
26
|
+
class ColorTheme:
|
27
|
+
text_color: str
|
28
|
+
line_backgrounds: Dict[str, str]
|
29
|
+
theme_type: ThemeType
|
30
|
+
|
31
|
+
# Predefined themes
|
32
|
+
DARK_THEME = ColorTheme(
|
33
|
+
text_color="white",
|
34
|
+
line_backgrounds=DARK_LINE_BACKGROUNDS,
|
35
|
+
theme_type=ThemeType.DARK
|
36
|
+
)
|
37
|
+
|
38
|
+
LIGHT_THEME = ColorTheme(
|
39
|
+
text_color="black",
|
40
|
+
line_backgrounds=LIGHT_LINE_BACKGROUNDS,
|
41
|
+
theme_type=ThemeType.LIGHT
|
42
|
+
)
|
43
|
+
|
44
|
+
DEFAULT_THEME = DARK_THEME
|
45
|
+
|
46
|
+
def validate_theme(theme: ColorTheme) -> bool:
|
47
|
+
"""Validate that a theme contains all required colors"""
|
48
|
+
required_line_backgrounds = {'unchanged', 'added', 'deleted', 'modified'}
|
49
|
+
|
50
|
+
if not isinstance(theme, ColorTheme):
|
51
|
+
return False
|
52
|
+
|
53
|
+
return all(bg in theme.line_backgrounds for bg in required_line_backgrounds)
|
54
|
+
|
55
|
+
def get_theme_by_type(theme_type: ThemeType) -> ColorTheme:
|
56
|
+
"""Get a predefined theme by type"""
|
57
|
+
return LIGHT_THEME if theme_type == ThemeType.LIGHT else DARK_THEME
|
janito/cli/__init__.py
ADDED