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.
@@ -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(" ", "&nbsp;") 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
+ }