difflicious 0.1.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.
- difflicious/__init__.py +6 -0
- difflicious/app.py +505 -0
- difflicious/cli.py +77 -0
- difflicious/diff_parser.py +525 -0
- difflicious/dummy_data.json +44 -0
- difflicious/git_operations.py +1005 -0
- difflicious/services/__init__.py +1 -0
- difflicious/services/base_service.py +32 -0
- difflicious/services/diff_service.py +403 -0
- difflicious/services/exceptions.py +19 -0
- difflicious/services/git_service.py +135 -0
- difflicious/services/syntax_service.py +162 -0
- difflicious/services/template_service.py +382 -0
- difflicious/static/css/styles.css +885 -0
- difflicious/static/css/tailwind.css +1 -0
- difflicious/static/css/tailwind.input.css +5 -0
- difflicious/static/js/app.js +1002 -0
- difflicious/static/js/diff-interactions.js +1617 -0
- difflicious/templates/base.html +54 -0
- difflicious/templates/diff_file.html +90 -0
- difflicious/templates/diff_groups.html +29 -0
- difflicious/templates/diff_hunk.html +170 -0
- difflicious/templates/index.html +54 -0
- difflicious/templates/partials/empty_state.html +29 -0
- difflicious/templates/partials/global_controls.html +23 -0
- difflicious/templates/partials/loading_state.html +7 -0
- difflicious/templates/partials/toolbar.html +165 -0
- difflicious-0.1.0.dist-info/METADATA +190 -0
- difflicious-0.1.0.dist-info/RECORD +32 -0
- difflicious-0.1.0.dist-info/WHEEL +4 -0
- difflicious-0.1.0.dist-info/entry_points.txt +2 -0
- difflicious-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Service for server-side syntax highlighting using Pygments."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pygments import highlight
|
|
9
|
+
from pygments.formatters import HtmlFormatter
|
|
10
|
+
from pygments.lexers import get_lexer_by_name, guess_lexer_for_filename
|
|
11
|
+
from pygments.util import ClassNotFound
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SyntaxHighlightingService:
|
|
17
|
+
"""Service for server-side code syntax highlighting."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
"""Initialize the syntax highlighting service."""
|
|
21
|
+
# Configure HTML formatters for both themes
|
|
22
|
+
self.light_formatter = HtmlFormatter(
|
|
23
|
+
nowrap=True, # # Don't wrap in <pre> tags
|
|
24
|
+
noclasses=True, # # Use inline styles for consistency
|
|
25
|
+
style="default", # # Use default theme for light mode
|
|
26
|
+
cssclass="highlight", # CSS class for highlighted code
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
self.dark_formatter = HtmlFormatter(
|
|
30
|
+
nowrap=True, # # Don't wrap in <pre> tags
|
|
31
|
+
noclasses=True, # # Use inline styles for consistency
|
|
32
|
+
style="one-dark", # # Use one-dark theme for dark mode
|
|
33
|
+
cssclass="highlight", # CSS class for highlighted code
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Cache lexers by file extension for performance
|
|
37
|
+
self._lexer_cache: dict[str, Any] = {}
|
|
38
|
+
|
|
39
|
+
# Language detection mapping (same as current frontend)
|
|
40
|
+
self.language_map = {
|
|
41
|
+
"js": "javascript",
|
|
42
|
+
"jsx": "javascript",
|
|
43
|
+
"ts": "typescript",
|
|
44
|
+
"tsx": "typescript",
|
|
45
|
+
"py": "python",
|
|
46
|
+
"html": "html",
|
|
47
|
+
"htm": "html",
|
|
48
|
+
"css": "css",
|
|
49
|
+
"scss": "scss",
|
|
50
|
+
"sass": "sass",
|
|
51
|
+
"less": "less",
|
|
52
|
+
"json": "json",
|
|
53
|
+
"xml": "xml",
|
|
54
|
+
"yaml": "yaml",
|
|
55
|
+
"yml": "yaml",
|
|
56
|
+
"md": "markdown",
|
|
57
|
+
"sh": "bash",
|
|
58
|
+
"bash": "bash",
|
|
59
|
+
"zsh": "bash",
|
|
60
|
+
"php": "php",
|
|
61
|
+
"rb": "ruby",
|
|
62
|
+
"go": "go",
|
|
63
|
+
"rs": "rust",
|
|
64
|
+
"java": "java",
|
|
65
|
+
"c": "c",
|
|
66
|
+
"cpp": "cpp",
|
|
67
|
+
"cc": "cpp",
|
|
68
|
+
"cxx": "cpp",
|
|
69
|
+
"h": "c",
|
|
70
|
+
"hpp": "cpp",
|
|
71
|
+
"cs": "csharp",
|
|
72
|
+
"sql": "sql",
|
|
73
|
+
"r": "r",
|
|
74
|
+
"swift": "swift",
|
|
75
|
+
"kt": "kotlin",
|
|
76
|
+
"scala": "scala",
|
|
77
|
+
"clj": "clojure",
|
|
78
|
+
"ex": "elixir",
|
|
79
|
+
"exs": "elixir",
|
|
80
|
+
"dockerfile": "dockerfile",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def highlight_diff_line(
|
|
84
|
+
self, content: str, file_path: str, theme: str = "light"
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Highlight a single line of diff content.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
content: The code content to highlight
|
|
90
|
+
file_path: Path to determine language
|
|
91
|
+
theme: Theme to use ("light" or "dark")
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
HTML-highlighted code content
|
|
95
|
+
"""
|
|
96
|
+
if not content or not content.strip():
|
|
97
|
+
return content
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
# Preserve only leading indentation explicitly using nbsp; leave the rest normal
|
|
101
|
+
leading_match = re.match(r"^[\t ]+", content)
|
|
102
|
+
leading = leading_match.group(0) if leading_match else ""
|
|
103
|
+
rest = content[len(leading) :]
|
|
104
|
+
|
|
105
|
+
# Convert leading spaces/tabs to non-breaking spaces (tabs -> 4 spaces)
|
|
106
|
+
nbsp_prefix = (
|
|
107
|
+
leading.replace("\t", " " * 4).replace(" ", " ") if leading else ""
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
lexer = self._get_cached_lexer(file_path)
|
|
111
|
+
formatter = self.dark_formatter if theme == "dark" else self.light_formatter
|
|
112
|
+
highlighted = highlight(rest, lexer, formatter)
|
|
113
|
+
return (nbsp_prefix + str(highlighted)).rstrip("\n")
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.debug(f"Highlighting failed for {file_path}: {e}")
|
|
116
|
+
return content # Fallback to plain text
|
|
117
|
+
|
|
118
|
+
def _get_cached_lexer(self, file_path: str) -> Any:
|
|
119
|
+
"""Get lexer for file, using cache for performance."""
|
|
120
|
+
file_ext = Path(file_path).suffix.lower().lstrip(".")
|
|
121
|
+
|
|
122
|
+
if file_ext not in self._lexer_cache:
|
|
123
|
+
try:
|
|
124
|
+
# Try mapped language first
|
|
125
|
+
if file_ext in self.language_map:
|
|
126
|
+
language = self.language_map[file_ext]
|
|
127
|
+
lexer = get_lexer_by_name(language)
|
|
128
|
+
else:
|
|
129
|
+
# Fall back to filename-based detection
|
|
130
|
+
lexer = guess_lexer_for_filename(file_path, "")
|
|
131
|
+
|
|
132
|
+
self._lexer_cache[file_ext] = lexer
|
|
133
|
+
logger.debug(
|
|
134
|
+
f"Cached lexer for {file_ext}: {getattr(lexer, 'name', 'unknown')}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
except ClassNotFound:
|
|
138
|
+
# Default to text lexer for unknown files
|
|
139
|
+
lexer = get_lexer_by_name("text")
|
|
140
|
+
self._lexer_cache[file_ext] = lexer
|
|
141
|
+
logger.debug(f"Using text lexer for unknown extension: {file_ext}")
|
|
142
|
+
|
|
143
|
+
return self._lexer_cache[file_ext]
|
|
144
|
+
|
|
145
|
+
def get_css_styles(self) -> str:
|
|
146
|
+
"""Get CSS styles for syntax highlighting for both light and dark themes.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
CSS styles as string with theme-specific rules
|
|
150
|
+
"""
|
|
151
|
+
light_styles = str(self.light_formatter.get_style_defs(".highlight"))
|
|
152
|
+
dark_styles = str(
|
|
153
|
+
self.dark_formatter.get_style_defs('[data-theme="dark"] .highlight')
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return f"""
|
|
157
|
+
/* Light theme syntax highlighting */
|
|
158
|
+
{light_styles}
|
|
159
|
+
|
|
160
|
+
/* Dark theme syntax highlighting */
|
|
161
|
+
{dark_styles}
|
|
162
|
+
"""
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Service for preparing data for Jinja2 template rendering."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from .base_service import BaseService
|
|
7
|
+
from .diff_service import DiffService
|
|
8
|
+
from .git_service import GitService
|
|
9
|
+
from .syntax_service import SyntaxHighlightingService
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TemplateRenderingService(BaseService):
|
|
15
|
+
"""Service for preparing diff data for template rendering."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, repo_path: Optional[str] = None):
|
|
18
|
+
"""Initialize template rendering service."""
|
|
19
|
+
super().__init__(repo_path)
|
|
20
|
+
self.diff_service = DiffService(repo_path)
|
|
21
|
+
self.git_service = GitService(repo_path)
|
|
22
|
+
self.syntax_service = SyntaxHighlightingService()
|
|
23
|
+
|
|
24
|
+
def prepare_diff_data_for_template(
|
|
25
|
+
self,
|
|
26
|
+
base_commit: Optional[str] = None,
|
|
27
|
+
target_commit: Optional[str] = None,
|
|
28
|
+
unstaged: bool = True,
|
|
29
|
+
staged: bool = True,
|
|
30
|
+
untracked: bool = False,
|
|
31
|
+
file_path: Optional[str] = None,
|
|
32
|
+
search_filter: Optional[str] = None,
|
|
33
|
+
expand_files: bool = False,
|
|
34
|
+
base_ref: Optional[str] = None,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Prepare complete diff data optimized for Jinja2 template rendering.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
base_commit: Base commit for comparison
|
|
40
|
+
target_commit: Target commit for comparison
|
|
41
|
+
unstaged: Include unstaged changes
|
|
42
|
+
untracked: Include untracked files
|
|
43
|
+
file_path: Filter to specific file
|
|
44
|
+
search_filter: Search term for filtering files
|
|
45
|
+
expand_files: Whether to expand files by default
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Dictionary containing all data needed for template rendering
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
# Get basic repository information
|
|
52
|
+
repo_status = self.git_service.get_repository_status()
|
|
53
|
+
branch_info = self.git_service.get_branch_information()
|
|
54
|
+
|
|
55
|
+
# Get diff data with explicit logic for the two main use cases
|
|
56
|
+
current_branch = repo_status.get("current_branch", "unknown")
|
|
57
|
+
is_head_comparison = (
|
|
58
|
+
base_ref in ["HEAD", current_branch] if base_ref else False
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
logger.info(
|
|
62
|
+
f"Template service: base_ref='{base_ref}', current_branch='{current_branch}', use_head={is_head_comparison}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if base_ref:
|
|
66
|
+
if is_head_comparison:
|
|
67
|
+
# Working directory vs HEAD comparison
|
|
68
|
+
# Always get both staged and unstaged files, keep them separate
|
|
69
|
+
grouped_diffs = self.diff_service.get_grouped_diffs(
|
|
70
|
+
base_ref="HEAD",
|
|
71
|
+
unstaged=True, # Always get unstaged files for HEAD comparisons
|
|
72
|
+
untracked=untracked,
|
|
73
|
+
file_path=file_path,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# For HEAD comparisons, staged changes are always visible
|
|
77
|
+
# Files can appear in both groups if they have both staged AND unstaged changes
|
|
78
|
+
# This is correct behavior - it shows what's ready to commit vs what's still being worked on
|
|
79
|
+
|
|
80
|
+
# Remove unstaged group based on checkbox selection (staged always visible)
|
|
81
|
+
if not unstaged and "unstaged" in grouped_diffs:
|
|
82
|
+
del grouped_diffs["unstaged"]
|
|
83
|
+
|
|
84
|
+
# Ensure staged changes are always included in HEAD comparison mode
|
|
85
|
+
# The git operations always include staged changes, so we don't need to modify the request
|
|
86
|
+
else:
|
|
87
|
+
# Working directory vs branch comparison - always show changes
|
|
88
|
+
grouped_diffs = self.diff_service.get_grouped_diffs(
|
|
89
|
+
base_ref=base_ref,
|
|
90
|
+
unstaged=True, # Always show changes for branch comparisons
|
|
91
|
+
untracked=untracked,
|
|
92
|
+
file_path=file_path,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
grouped_diffs = self._combine_unstaged_and_staged_as_changes(
|
|
96
|
+
grouped_diffs
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
# Default behavior: compare to default branch - always show changes
|
|
100
|
+
grouped_diffs = self.diff_service.get_grouped_diffs(
|
|
101
|
+
base_ref=base_ref,
|
|
102
|
+
unstaged=True, # Always show changes for branch comparisons
|
|
103
|
+
untracked=untracked,
|
|
104
|
+
file_path=file_path,
|
|
105
|
+
)
|
|
106
|
+
grouped_diffs = self._combine_unstaged_and_staged_as_changes(
|
|
107
|
+
grouped_diffs
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Process and enhance diff data for template rendering
|
|
111
|
+
enhanced_groups = self._enhance_diff_data_for_templates(
|
|
112
|
+
grouped_diffs, search_filter, expand_files
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Calculate totals
|
|
116
|
+
total_files = sum(group["count"] for group in enhanced_groups.values())
|
|
117
|
+
|
|
118
|
+
# Pass through the UI state parameters as received from the user
|
|
119
|
+
ui_unstaged = unstaged
|
|
120
|
+
ui_staged = staged
|
|
121
|
+
|
|
122
|
+
logger.info(
|
|
123
|
+
f"Template final: base_ref='{base_ref}', current_branch='{current_branch}', is_head_comparison={is_head_comparison}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
# Repository info
|
|
128
|
+
"repo_status": repo_status,
|
|
129
|
+
"branches": branch_info.get("branches", {}),
|
|
130
|
+
"current_branch": current_branch,
|
|
131
|
+
# Diff data
|
|
132
|
+
"groups": enhanced_groups,
|
|
133
|
+
"total_files": total_files,
|
|
134
|
+
# UI state
|
|
135
|
+
# Dropdown selection: default to current branch if not specified in URL
|
|
136
|
+
"current_base_ref": base_ref or current_branch,
|
|
137
|
+
"unstaged": ui_unstaged,
|
|
138
|
+
"staged": ui_staged,
|
|
139
|
+
"untracked": untracked,
|
|
140
|
+
"search_filter": search_filter,
|
|
141
|
+
# Template helpers
|
|
142
|
+
"syntax_css": self.syntax_service.get_css_styles(),
|
|
143
|
+
"loading": False,
|
|
144
|
+
# Comparison mode
|
|
145
|
+
"is_head_comparison": is_head_comparison,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error(f"Failed to prepare template data: {e}")
|
|
150
|
+
return self._get_error_template_data(str(e))
|
|
151
|
+
|
|
152
|
+
def _enhance_diff_data_for_templates(
|
|
153
|
+
self,
|
|
154
|
+
grouped_diffs: dict[str, Any],
|
|
155
|
+
search_filter: Optional[str] = None,
|
|
156
|
+
expand_files: bool = False,
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Enhance diff data with syntax highlighting and template-specific features."""
|
|
159
|
+
|
|
160
|
+
enhanced_groups: dict[str, Any] = {}
|
|
161
|
+
|
|
162
|
+
for group_key, group_data in grouped_diffs.items():
|
|
163
|
+
enhanced_files: list[dict[str, Any]] = []
|
|
164
|
+
|
|
165
|
+
for file_data in group_data.get("files", []):
|
|
166
|
+
# Apply search filter
|
|
167
|
+
if (
|
|
168
|
+
search_filter is not None
|
|
169
|
+
and search_filter.lower() not in file_data.get("path", "").lower()
|
|
170
|
+
):
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
# Add template-specific properties
|
|
174
|
+
enhanced_file = {
|
|
175
|
+
**file_data,
|
|
176
|
+
"expanded": expand_files, # Control initial expansion state
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Process hunks with syntax highlighting
|
|
180
|
+
if file_data.get("hunks"):
|
|
181
|
+
enhanced_file["hunks"] = self._process_hunks_for_template(
|
|
182
|
+
file_data["hunks"],
|
|
183
|
+
file_data.get("path", ""),
|
|
184
|
+
file_data.get(
|
|
185
|
+
"line_count"
|
|
186
|
+
), # Pass file line count for boundary checks
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
enhanced_files.append(enhanced_file)
|
|
190
|
+
|
|
191
|
+
enhanced_groups[group_key] = {
|
|
192
|
+
"files": enhanced_files,
|
|
193
|
+
"count": len(enhanced_files),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return enhanced_groups
|
|
197
|
+
|
|
198
|
+
def _combine_unstaged_and_staged_as_changes(
|
|
199
|
+
self, grouped_diffs: dict[str, Any]
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
"""Combine 'unstaged' and 'staged' groups into a single 'changes' group.
|
|
202
|
+
|
|
203
|
+
Removes the original groups if present.
|
|
204
|
+
"""
|
|
205
|
+
# Deduplicate by file path. Prefer unstaged entry (represents latest state).
|
|
206
|
+
path_to_file: dict[str, dict[str, Any]] = {}
|
|
207
|
+
|
|
208
|
+
# First, add unstaged files if present
|
|
209
|
+
for file_entry in grouped_diffs.get("unstaged", {}).get("files", []):
|
|
210
|
+
path = file_entry.get("path")
|
|
211
|
+
if path and path not in path_to_file:
|
|
212
|
+
path_to_file[path] = file_entry
|
|
213
|
+
|
|
214
|
+
# Then add staged files that are not already present
|
|
215
|
+
for file_entry in grouped_diffs.get("staged", {}).get("files", []):
|
|
216
|
+
path = file_entry.get("path")
|
|
217
|
+
if path and path not in path_to_file:
|
|
218
|
+
path_to_file[path] = file_entry
|
|
219
|
+
|
|
220
|
+
changes_files = list(path_to_file.values())
|
|
221
|
+
|
|
222
|
+
grouped_diffs["changes"] = {
|
|
223
|
+
"files": changes_files,
|
|
224
|
+
"count": len(changes_files),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if "unstaged" in grouped_diffs:
|
|
228
|
+
del grouped_diffs["unstaged"]
|
|
229
|
+
if "staged" in grouped_diffs:
|
|
230
|
+
del grouped_diffs["staged"]
|
|
231
|
+
|
|
232
|
+
return grouped_diffs
|
|
233
|
+
|
|
234
|
+
def _process_hunks_for_template(
|
|
235
|
+
self,
|
|
236
|
+
hunks: list[dict[str, Any]],
|
|
237
|
+
file_path: str,
|
|
238
|
+
file_line_count: Optional[int] = None,
|
|
239
|
+
) -> list[dict[str, Any]]:
|
|
240
|
+
"""Process hunks with syntax highlighting for template rendering."""
|
|
241
|
+
|
|
242
|
+
processed_hunks = []
|
|
243
|
+
|
|
244
|
+
for hunk_index, hunk in enumerate(hunks):
|
|
245
|
+
# Calculate canonical old/new ranges from hunk metadata (not row count)
|
|
246
|
+
right_start = hunk.get("new_start", 1)
|
|
247
|
+
right_count = hunk.get("new_count", 0)
|
|
248
|
+
right_end = right_start + max(right_count, 0) - 1
|
|
249
|
+
|
|
250
|
+
left_start = hunk.get("old_start", 1)
|
|
251
|
+
left_count = hunk.get("old_count", 0)
|
|
252
|
+
left_end = left_start + max(left_count, 0) - 1
|
|
253
|
+
|
|
254
|
+
# Find the last line of the previous hunk, if it exists
|
|
255
|
+
previous_hunk = hunks[hunk_index - 1] if hunk_index > 0 else None
|
|
256
|
+
previous_hunk_end = (
|
|
257
|
+
previous_hunk.get("new_start", 1)
|
|
258
|
+
+ previous_hunk.get("new_count", 0)
|
|
259
|
+
- 1
|
|
260
|
+
if previous_hunk
|
|
261
|
+
else 0
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Find the first line of the next hunk, if it exists
|
|
265
|
+
next_hunk = hunks[hunk_index + 1] if hunk_index < len(hunks) - 1 else None
|
|
266
|
+
next_hunk_start = (
|
|
267
|
+
next_hunk.get("new_start", 1) if next_hunk else file_line_count
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Calculate expansion target ranges (10 lines by default)
|
|
271
|
+
expand_before_start = max(previous_hunk_end + 1, right_start - 10)
|
|
272
|
+
expand_before_end = right_start - 1
|
|
273
|
+
expand_after_start = right_end + 1
|
|
274
|
+
expand_after_end = min(
|
|
275
|
+
next_hunk_start - 1 if next_hunk_start else right_end + 10,
|
|
276
|
+
right_end + 10,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
processed_hunk = {
|
|
280
|
+
**hunk,
|
|
281
|
+
"index": hunk_index,
|
|
282
|
+
"can_expand_before": self._can_expand_context(
|
|
283
|
+
hunks, hunk_index, "before"
|
|
284
|
+
),
|
|
285
|
+
"can_expand_after": self._can_expand_context(
|
|
286
|
+
hunks, hunk_index, "after"
|
|
287
|
+
),
|
|
288
|
+
# Right side (new file) visible range
|
|
289
|
+
"line_start": right_start,
|
|
290
|
+
"line_end": right_end,
|
|
291
|
+
"line_count": max(right_count, 0),
|
|
292
|
+
# Left side (old file) visible range for numbering continuity
|
|
293
|
+
"left_start": left_start,
|
|
294
|
+
"left_end": left_end,
|
|
295
|
+
"expand_before_start": expand_before_start,
|
|
296
|
+
"expand_before_end": expand_before_end,
|
|
297
|
+
"expand_after_start": expand_after_start,
|
|
298
|
+
"expand_after_end": expand_after_end,
|
|
299
|
+
"file_line_count": file_line_count,
|
|
300
|
+
"lines": [],
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Process each line with syntax highlighting
|
|
304
|
+
for line in hunk.get("lines", []):
|
|
305
|
+
processed_line = {
|
|
306
|
+
**line,
|
|
307
|
+
"left": self._process_line_side(line.get("left"), file_path),
|
|
308
|
+
"right": self._process_line_side(line.get("right"), file_path),
|
|
309
|
+
}
|
|
310
|
+
processed_hunk["lines"].append(processed_line)
|
|
311
|
+
|
|
312
|
+
processed_hunks.append(processed_hunk)
|
|
313
|
+
|
|
314
|
+
return processed_hunks
|
|
315
|
+
|
|
316
|
+
def _process_line_side(
|
|
317
|
+
self, line_side: Optional[dict[str, Any]], file_path: str
|
|
318
|
+
) -> Optional[dict[str, Any]]:
|
|
319
|
+
"""Process one side of a diff line with syntax highlighting."""
|
|
320
|
+
|
|
321
|
+
if not line_side or not line_side.get("content"):
|
|
322
|
+
return line_side
|
|
323
|
+
|
|
324
|
+
# Add highlighted content
|
|
325
|
+
highlighted_content = self.syntax_service.highlight_diff_line(
|
|
326
|
+
line_side["content"], file_path
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return {**line_side, "highlighted_content": highlighted_content}
|
|
330
|
+
|
|
331
|
+
def _can_expand_context(
|
|
332
|
+
self, hunks: list[dict[str, Any]], hunk_index: int, direction: str
|
|
333
|
+
) -> bool:
|
|
334
|
+
"""Determine if context can be expanded for a hunk."""
|
|
335
|
+
|
|
336
|
+
if direction == "before":
|
|
337
|
+
# Can expand before if doesn't start at line 1
|
|
338
|
+
current_hunk = hunks[hunk_index]
|
|
339
|
+
hunk_start = current_hunk.get("new_start", 1)
|
|
340
|
+
return bool(hunk_start > 1)
|
|
341
|
+
elif direction == "after":
|
|
342
|
+
# Can expand after only if:
|
|
343
|
+
# 1. Not the last hunk (there are more hunks below) AND
|
|
344
|
+
# 2. There's actually space between this hunk and the next hunk
|
|
345
|
+
if hunk_index >= len(hunks) - 1:
|
|
346
|
+
return False # This is the last hunk
|
|
347
|
+
|
|
348
|
+
# Check if there's space between current hunk and next hunk
|
|
349
|
+
current_hunk = hunks[hunk_index]
|
|
350
|
+
next_hunk = hunks[hunk_index + 1]
|
|
351
|
+
|
|
352
|
+
# Calculate end using the actual line count, not new_count
|
|
353
|
+
current_line_count = len(current_hunk.get("lines", []))
|
|
354
|
+
current_end = current_hunk.get("new_start", 1) + current_line_count - 1
|
|
355
|
+
next_start = next_hunk.get("new_start", 1)
|
|
356
|
+
|
|
357
|
+
# Only show down arrow if there's at least 1 line gap between hunks
|
|
358
|
+
return bool(next_start > current_end + 1)
|
|
359
|
+
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
def _get_error_template_data(self, error_message: str) -> dict[str, Any]:
|
|
363
|
+
"""Get template data for error states."""
|
|
364
|
+
return {
|
|
365
|
+
"repo_status": {"current_branch": "error", "git_available": False},
|
|
366
|
+
"branches": {"all": [], "current": "error", "default": "main"},
|
|
367
|
+
"groups": {
|
|
368
|
+
"untracked": {"files": [], "count": 0},
|
|
369
|
+
"unstaged": {"files": [], "count": 0},
|
|
370
|
+
"staged": {"files": [], "count": 0},
|
|
371
|
+
},
|
|
372
|
+
"total_files": 0,
|
|
373
|
+
"current_base_ref": "main",
|
|
374
|
+
"unstaged": True,
|
|
375
|
+
"staged": True,
|
|
376
|
+
"untracked": False,
|
|
377
|
+
"search_filter": "",
|
|
378
|
+
"syntax_css": "",
|
|
379
|
+
"loading": False,
|
|
380
|
+
"error": error_message,
|
|
381
|
+
"is_head_comparison": False,
|
|
382
|
+
}
|