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
difflicious/__init__.py
ADDED
difflicious/app.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""Flask web application for Difflicious git diff visualization."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
from flask import Flask, Response, jsonify, render_template, request
|
|
9
|
+
|
|
10
|
+
# Import services
|
|
11
|
+
from difflicious.services.diff_service import DiffService
|
|
12
|
+
from difflicious.services.exceptions import DiffServiceError, GitServiceError
|
|
13
|
+
from difflicious.services.git_service import GitService
|
|
14
|
+
from difflicious.services.template_service import TemplateRenderingService
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_app() -> Flask:
|
|
18
|
+
|
|
19
|
+
# Configure template directory to be relative to package
|
|
20
|
+
template_dir = os.path.join(os.path.dirname(__file__), "templates")
|
|
21
|
+
static_dir = os.path.join(os.path.dirname(__file__), "static")
|
|
22
|
+
|
|
23
|
+
app = Flask(__name__, template_folder=template_dir, static_folder=static_dir)
|
|
24
|
+
|
|
25
|
+
# Font configuration
|
|
26
|
+
AVAILABLE_FONTS = {
|
|
27
|
+
"fira-code": {
|
|
28
|
+
"name": "Fira Code",
|
|
29
|
+
"css_family": "'Fira Code', monospace",
|
|
30
|
+
"google_fonts_url": "https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;500;600&display=swap",
|
|
31
|
+
},
|
|
32
|
+
"jetbrains-mono": {
|
|
33
|
+
"name": "JetBrains Mono",
|
|
34
|
+
"css_family": "'JetBrains Mono', monospace",
|
|
35
|
+
"google_fonts_url": "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&display=swap",
|
|
36
|
+
},
|
|
37
|
+
"source-code-pro": {
|
|
38
|
+
"name": "Source Code Pro",
|
|
39
|
+
"css_family": "'Source Code Pro', monospace",
|
|
40
|
+
"google_fonts_url": "https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@300;400;500;600&display=swap",
|
|
41
|
+
},
|
|
42
|
+
"ibm-plex-mono": {
|
|
43
|
+
"name": "IBM Plex Mono",
|
|
44
|
+
"css_family": "'IBM Plex Mono', monospace",
|
|
45
|
+
"google_fonts_url": "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&display=swap",
|
|
46
|
+
},
|
|
47
|
+
"roboto-mono": {
|
|
48
|
+
"name": "Roboto Mono",
|
|
49
|
+
"css_family": "'Roboto Mono', monospace",
|
|
50
|
+
"google_fonts_url": "https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500&display=swap",
|
|
51
|
+
},
|
|
52
|
+
"inconsolata": {
|
|
53
|
+
"name": "Inconsolata",
|
|
54
|
+
"css_family": "'Inconsolata', monospace",
|
|
55
|
+
"google_fonts_url": "https://fonts.googleapis.com/css2?family=Inconsolata:wght@200;300;400;500;600;700;800;900&display=swap",
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Get font selection from environment variable with default
|
|
60
|
+
selected_font_key = os.getenv("DIFFLICIOUS_FONT", "jetbrains-mono")
|
|
61
|
+
|
|
62
|
+
# Validate font selection and fallback to default
|
|
63
|
+
if selected_font_key not in AVAILABLE_FONTS:
|
|
64
|
+
selected_font_key = "jetbrains-mono"
|
|
65
|
+
|
|
66
|
+
selected_font = AVAILABLE_FONTS[selected_font_key]
|
|
67
|
+
|
|
68
|
+
# Font configuration for templates
|
|
69
|
+
FONT_CONFIG = {
|
|
70
|
+
"selected_font_key": selected_font_key,
|
|
71
|
+
"selected_font": selected_font,
|
|
72
|
+
"available_fonts": AVAILABLE_FONTS,
|
|
73
|
+
"google_fonts_enabled": os.getenv(
|
|
74
|
+
"DIFFLICIOUS_DISABLE_GOOGLE_FONTS", "false"
|
|
75
|
+
).lower()
|
|
76
|
+
!= "true",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Register jinja-partials extension
|
|
80
|
+
import jinja_partials # type: ignore[import-untyped]
|
|
81
|
+
|
|
82
|
+
jinja_partials.register_extensions(app)
|
|
83
|
+
|
|
84
|
+
# Configure logging
|
|
85
|
+
logging.basicConfig(level=logging.INFO)
|
|
86
|
+
logger = logging.getLogger(__name__)
|
|
87
|
+
|
|
88
|
+
@app.context_processor
|
|
89
|
+
def inject_font_config() -> dict[str, dict]:
|
|
90
|
+
"""Inject font configuration into all templates."""
|
|
91
|
+
return {"font_config": FONT_CONFIG}
|
|
92
|
+
|
|
93
|
+
@app.route("/")
|
|
94
|
+
def index() -> str:
|
|
95
|
+
"""Main diff visualization page with server-side rendering."""
|
|
96
|
+
try:
|
|
97
|
+
# Get query parameters
|
|
98
|
+
base_ref = request.args.get("base_ref")
|
|
99
|
+
unstaged = request.args.get("unstaged", "true").lower() == "true"
|
|
100
|
+
staged = request.args.get("staged", "true").lower() == "true"
|
|
101
|
+
untracked = request.args.get("untracked", "false").lower() == "true"
|
|
102
|
+
file_path = request.args.get("file")
|
|
103
|
+
search_filter = request.args.get("search", "").strip()
|
|
104
|
+
expand_files = request.args.get("expand", "false").lower() == "true"
|
|
105
|
+
|
|
106
|
+
# If no base_ref specified, default to current checked-out branch to trigger HEAD comparison mode
|
|
107
|
+
if not base_ref:
|
|
108
|
+
try:
|
|
109
|
+
git_service = GitService()
|
|
110
|
+
repo_status = git_service.get_repository_status()
|
|
111
|
+
current_branch = repo_status.get("current_branch", None)
|
|
112
|
+
# Use current_branch if available so service treats it as HEAD comparison
|
|
113
|
+
if current_branch and current_branch not in ("unknown", "error"):
|
|
114
|
+
base_ref = current_branch
|
|
115
|
+
except Exception:
|
|
116
|
+
# Fallback: leave base_ref as None
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
# Prepare template data
|
|
120
|
+
template_service = TemplateRenderingService()
|
|
121
|
+
template_data = template_service.prepare_diff_data_for_template(
|
|
122
|
+
base_ref=base_ref if base_ref is not None else None,
|
|
123
|
+
unstaged=unstaged,
|
|
124
|
+
staged=staged,
|
|
125
|
+
untracked=untracked,
|
|
126
|
+
file_path=file_path,
|
|
127
|
+
search_filter=search_filter if search_filter else None,
|
|
128
|
+
expand_files=expand_files,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return render_template("index.html", **template_data)
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(f"Failed to render index page: {e}")
|
|
135
|
+
# Render error page
|
|
136
|
+
error_data = {
|
|
137
|
+
"repo_status": {"current_branch": "error", "git_available": False},
|
|
138
|
+
"branches": {"all": [], "current": "error", "default": "main"},
|
|
139
|
+
"groups": {},
|
|
140
|
+
"total_files": 0,
|
|
141
|
+
"error": str(e),
|
|
142
|
+
"loading": False,
|
|
143
|
+
"syntax_css": "",
|
|
144
|
+
"unstaged": True,
|
|
145
|
+
"untracked": False,
|
|
146
|
+
"search_filter": "",
|
|
147
|
+
"current_base_ref": "main",
|
|
148
|
+
}
|
|
149
|
+
return render_template("index.html", **error_data)
|
|
150
|
+
|
|
151
|
+
@app.route("/api/status")
|
|
152
|
+
def api_status() -> Response:
|
|
153
|
+
"""API endpoint for git status information (kept for compatibility)."""
|
|
154
|
+
try:
|
|
155
|
+
git_service = GitService()
|
|
156
|
+
return jsonify(git_service.get_repository_status())
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.error(f"Failed to get git status: {e}")
|
|
159
|
+
return jsonify(
|
|
160
|
+
{
|
|
161
|
+
"status": "error",
|
|
162
|
+
"current_branch": "unknown",
|
|
163
|
+
"repository_name": "unknown",
|
|
164
|
+
"files_changed": 0,
|
|
165
|
+
"git_available": False,
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# DevTools extensions occasionally request a source map named installHook.js.map
|
|
170
|
+
# from the app origin, which causes 404 warnings. Serve a minimal, valid map.
|
|
171
|
+
@app.route("/installHook.js.map")
|
|
172
|
+
def devtools_stub_sourcemap() -> Response:
|
|
173
|
+
stub_map = {
|
|
174
|
+
"version": 3,
|
|
175
|
+
"file": "installHook.js",
|
|
176
|
+
"sources": [],
|
|
177
|
+
"names": [],
|
|
178
|
+
"mappings": "",
|
|
179
|
+
}
|
|
180
|
+
return jsonify(stub_map)
|
|
181
|
+
|
|
182
|
+
@app.route("/api/branches")
|
|
183
|
+
def api_branches() -> Union[Response, tuple[Response, int]]:
|
|
184
|
+
"""API endpoint for git branch information (kept for compatibility)."""
|
|
185
|
+
try:
|
|
186
|
+
git_service = GitService()
|
|
187
|
+
return jsonify(git_service.get_branch_information())
|
|
188
|
+
except GitServiceError as e:
|
|
189
|
+
logger.error(f"Failed to get branch info: {e}")
|
|
190
|
+
return jsonify({"status": "error", "message": str(e)}), 500
|
|
191
|
+
|
|
192
|
+
@app.route("/api/expand-context")
|
|
193
|
+
def api_expand_context() -> Union[Response, tuple[Response, int]]:
|
|
194
|
+
"""API endpoint for context expansion (AJAX for dynamic updates)."""
|
|
195
|
+
file_path = request.args.get("file_path")
|
|
196
|
+
hunk_index = request.args.get("hunk_index", type=int)
|
|
197
|
+
direction = request.args.get("direction") # 'before' or 'after'
|
|
198
|
+
context_lines = request.args.get("context_lines", 10, type=int)
|
|
199
|
+
output_format = request.args.get("format", "plain") # 'plain' or 'pygments'
|
|
200
|
+
|
|
201
|
+
# Get the target line range from the frontend (passed from button data attributes)
|
|
202
|
+
target_start = request.args.get("target_start", type=int)
|
|
203
|
+
target_end = request.args.get("target_end", type=int)
|
|
204
|
+
|
|
205
|
+
if not all([file_path, hunk_index is not None, direction]):
|
|
206
|
+
return (
|
|
207
|
+
jsonify({"status": "error", "message": "Missing required parameters"}),
|
|
208
|
+
400,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if output_format not in ["plain", "pygments"]:
|
|
212
|
+
return (
|
|
213
|
+
jsonify(
|
|
214
|
+
{
|
|
215
|
+
"status": "error",
|
|
216
|
+
"message": "Invalid format parameter. Must be 'plain' or 'pygments'",
|
|
217
|
+
}
|
|
218
|
+
),
|
|
219
|
+
400,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
# Use the target range provided by the frontend if available
|
|
224
|
+
if target_start is not None and target_end is not None:
|
|
225
|
+
start_line = target_start
|
|
226
|
+
end_line = target_end
|
|
227
|
+
else:
|
|
228
|
+
# Fallback: try to calculate from diff data
|
|
229
|
+
diff_service = DiffService()
|
|
230
|
+
grouped_diffs = diff_service.get_grouped_diffs(file_path=file_path)
|
|
231
|
+
|
|
232
|
+
# Find the specific file and hunk
|
|
233
|
+
target_hunk = None
|
|
234
|
+
for group_data in grouped_diffs.values():
|
|
235
|
+
for file_data in group_data["files"]:
|
|
236
|
+
if file_data["path"] == file_path and file_data.get("hunks"):
|
|
237
|
+
if hunk_index is not None and hunk_index < len(
|
|
238
|
+
file_data["hunks"]
|
|
239
|
+
):
|
|
240
|
+
target_hunk = file_data["hunks"][hunk_index]
|
|
241
|
+
break
|
|
242
|
+
if target_hunk:
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
if not target_hunk:
|
|
246
|
+
return (
|
|
247
|
+
jsonify(
|
|
248
|
+
{
|
|
249
|
+
"status": "error",
|
|
250
|
+
"message": f"Hunk {hunk_index} not found in file {file_path}",
|
|
251
|
+
}
|
|
252
|
+
),
|
|
253
|
+
404,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Calculate the line range to fetch based on hunk and direction
|
|
257
|
+
new_start = target_hunk.get("new_start", 1)
|
|
258
|
+
new_count = target_hunk.get("new_count", 0)
|
|
259
|
+
new_end = new_start + max(new_count, 0) - 1
|
|
260
|
+
|
|
261
|
+
if direction == "before":
|
|
262
|
+
# Always anchor the fetch to the right side (new file)
|
|
263
|
+
end_line = new_start - 1
|
|
264
|
+
start_line = max(1, end_line - context_lines + 1)
|
|
265
|
+
else: # direction == "after"
|
|
266
|
+
# Always anchor the fetch to the right side (new file)
|
|
267
|
+
start_line = new_end + 1
|
|
268
|
+
end_line = start_line + context_lines - 1
|
|
269
|
+
|
|
270
|
+
# Fetch the actual lines
|
|
271
|
+
git_service = GitService()
|
|
272
|
+
result = git_service.get_file_lines(file_path or "", start_line, end_line)
|
|
273
|
+
|
|
274
|
+
# If pygments format requested, enhance the result with syntax highlighting
|
|
275
|
+
if output_format == "pygments" and result.get("status") == "ok":
|
|
276
|
+
from difflicious.services.syntax_service import (
|
|
277
|
+
SyntaxHighlightingService,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
syntax_service = SyntaxHighlightingService()
|
|
281
|
+
|
|
282
|
+
enhanced_lines = []
|
|
283
|
+
for line_content in result.get("lines", []):
|
|
284
|
+
if line_content:
|
|
285
|
+
highlighted_content = syntax_service.highlight_diff_line(
|
|
286
|
+
line_content, file_path or ""
|
|
287
|
+
)
|
|
288
|
+
enhanced_lines.append(
|
|
289
|
+
{
|
|
290
|
+
"content": line_content,
|
|
291
|
+
"highlighted_content": highlighted_content,
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
enhanced_lines.append(
|
|
296
|
+
{
|
|
297
|
+
"content": line_content,
|
|
298
|
+
"highlighted_content": line_content,
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
result["lines"] = enhanced_lines
|
|
303
|
+
result["format"] = "pygments"
|
|
304
|
+
result["css_styles"] = syntax_service.get_css_styles()
|
|
305
|
+
else:
|
|
306
|
+
result["format"] = "plain"
|
|
307
|
+
|
|
308
|
+
return jsonify(result)
|
|
309
|
+
|
|
310
|
+
except GitServiceError as e:
|
|
311
|
+
logger.error(f"Context expansion error: {e}")
|
|
312
|
+
return jsonify({"status": "error", "message": str(e)}), 500
|
|
313
|
+
|
|
314
|
+
@app.route("/api/diff")
|
|
315
|
+
def api_diff() -> Union[Response, tuple[Response, int]]:
|
|
316
|
+
"""API endpoint for git diff information."""
|
|
317
|
+
# Get optional query parameters
|
|
318
|
+
unstaged = request.args.get("unstaged", "true").lower() == "true"
|
|
319
|
+
untracked = request.args.get("untracked", "false").lower() == "true"
|
|
320
|
+
file_path = request.args.get("file")
|
|
321
|
+
base_ref = request.args.get("base_ref")
|
|
322
|
+
|
|
323
|
+
# New single-source parameters
|
|
324
|
+
use_head = request.args.get("use_head", "false").lower() == "true"
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
# Use template service logic for proper branch comparison handling
|
|
328
|
+
template_service = TemplateRenderingService()
|
|
329
|
+
|
|
330
|
+
# Get basic repository information
|
|
331
|
+
repo_status = template_service.git_service.get_repository_status()
|
|
332
|
+
current_branch = repo_status.get("current_branch", "unknown")
|
|
333
|
+
|
|
334
|
+
# Determine if this is a HEAD comparison
|
|
335
|
+
is_head_comparison = (
|
|
336
|
+
base_ref in ["HEAD", current_branch] if base_ref else False
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if is_head_comparison:
|
|
340
|
+
# Working directory vs HEAD comparison - use diff service directly
|
|
341
|
+
diff_service = DiffService()
|
|
342
|
+
grouped_data = diff_service.get_grouped_diffs(
|
|
343
|
+
base_ref="HEAD",
|
|
344
|
+
unstaged=unstaged,
|
|
345
|
+
untracked=untracked,
|
|
346
|
+
file_path=file_path,
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
# Working directory vs branch comparison - use template service logic
|
|
350
|
+
# This ensures proper combining of staged/unstaged into "changes" group
|
|
351
|
+
template_data = template_service.prepare_diff_data_for_template(
|
|
352
|
+
base_ref=base_ref,
|
|
353
|
+
unstaged=unstaged,
|
|
354
|
+
staged=True, # Always include staged for branch comparisons
|
|
355
|
+
untracked=untracked,
|
|
356
|
+
file_path=file_path,
|
|
357
|
+
)
|
|
358
|
+
grouped_data = template_data["groups"]
|
|
359
|
+
# Ensure API always exposes an 'unstaged' key for compatibility
|
|
360
|
+
if "unstaged" not in grouped_data and "changes" in grouped_data:
|
|
361
|
+
grouped_data["unstaged"] = grouped_data["changes"]
|
|
362
|
+
# Ensure API always exposes a 'staged' key for compatibility
|
|
363
|
+
if "staged" not in grouped_data:
|
|
364
|
+
grouped_data["staged"] = {"files": [], "count": 0}
|
|
365
|
+
|
|
366
|
+
# Calculate total files across all groups
|
|
367
|
+
total_files = sum(group["count"] for group in grouped_data.values())
|
|
368
|
+
|
|
369
|
+
return jsonify(
|
|
370
|
+
{
|
|
371
|
+
"status": "ok",
|
|
372
|
+
"groups": grouped_data,
|
|
373
|
+
"unstaged": unstaged,
|
|
374
|
+
"untracked": untracked,
|
|
375
|
+
"file_filter": file_path,
|
|
376
|
+
"use_head": use_head,
|
|
377
|
+
"base_ref": base_ref,
|
|
378
|
+
"total_files": total_files,
|
|
379
|
+
}
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
except DiffServiceError as e:
|
|
383
|
+
logger.error(f"Diff service error: {e}")
|
|
384
|
+
return (
|
|
385
|
+
jsonify(
|
|
386
|
+
{
|
|
387
|
+
"status": "error",
|
|
388
|
+
"message": str(e),
|
|
389
|
+
"groups": {
|
|
390
|
+
"untracked": {"files": [], "count": 0},
|
|
391
|
+
"unstaged": {"files": [], "count": 0},
|
|
392
|
+
"staged": {"files": [], "count": 0},
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
),
|
|
396
|
+
500,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
@app.route("/api/file/lines")
|
|
400
|
+
def api_file_lines() -> Union[Response, tuple[Response, int]]:
|
|
401
|
+
"""API endpoint for fetching specific lines from a file (kept for compatibility)."""
|
|
402
|
+
file_path = request.args.get("file_path")
|
|
403
|
+
if not file_path:
|
|
404
|
+
return (
|
|
405
|
+
jsonify(
|
|
406
|
+
{"status": "error", "message": "file_path parameter is required"}
|
|
407
|
+
),
|
|
408
|
+
400,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
start_line = request.args.get("start_line")
|
|
412
|
+
end_line = request.args.get("end_line")
|
|
413
|
+
|
|
414
|
+
if not start_line or not end_line:
|
|
415
|
+
return (
|
|
416
|
+
jsonify(
|
|
417
|
+
{
|
|
418
|
+
"status": "error",
|
|
419
|
+
"message": "start_line and end_line parameters are required",
|
|
420
|
+
}
|
|
421
|
+
),
|
|
422
|
+
400,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
start_line_int = int(start_line)
|
|
427
|
+
end_line_int = int(end_line)
|
|
428
|
+
except ValueError:
|
|
429
|
+
return (
|
|
430
|
+
jsonify(
|
|
431
|
+
{
|
|
432
|
+
"status": "error",
|
|
433
|
+
"message": "start_line and end_line must be valid numbers",
|
|
434
|
+
}
|
|
435
|
+
),
|
|
436
|
+
400,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
git_service = GitService()
|
|
441
|
+
return jsonify(
|
|
442
|
+
git_service.get_file_lines(file_path, start_line_int, end_line_int)
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
except GitServiceError as e:
|
|
446
|
+
logger.error(f"Git service error: {e}")
|
|
447
|
+
return jsonify({"status": "error", "message": str(e)}), 500
|
|
448
|
+
|
|
449
|
+
@app.route("/api/diff/full")
|
|
450
|
+
def api_diff_full() -> Union[Response, tuple[Response, int]]:
|
|
451
|
+
"""API endpoint for complete file diff with unlimited context."""
|
|
452
|
+
file_path = request.args.get("file_path")
|
|
453
|
+
if not file_path:
|
|
454
|
+
return (
|
|
455
|
+
jsonify(
|
|
456
|
+
{"status": "error", "message": "file_path parameter is required"}
|
|
457
|
+
),
|
|
458
|
+
400,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
base_ref = request.args.get("base_ref")
|
|
462
|
+
use_head = request.args.get("use_head", "false").lower() == "true"
|
|
463
|
+
use_cached = request.args.get("use_cached", "false").lower() == "true"
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
diff_service = DiffService()
|
|
467
|
+
result = diff_service.get_full_diff_data(
|
|
468
|
+
file_path=file_path,
|
|
469
|
+
base_ref=base_ref,
|
|
470
|
+
use_head=use_head,
|
|
471
|
+
use_cached=use_cached,
|
|
472
|
+
)
|
|
473
|
+
return jsonify(result)
|
|
474
|
+
|
|
475
|
+
except DiffServiceError as e:
|
|
476
|
+
logger.error(f"Full diff service error: {e}")
|
|
477
|
+
return (
|
|
478
|
+
jsonify(
|
|
479
|
+
{
|
|
480
|
+
"status": "error",
|
|
481
|
+
"message": str(e),
|
|
482
|
+
"file_path": file_path,
|
|
483
|
+
}
|
|
484
|
+
),
|
|
485
|
+
500,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# Serve a tiny placeholder favicon to avoid 404s in the console.
|
|
489
|
+
@app.route("/favicon.ico")
|
|
490
|
+
def favicon() -> Response:
|
|
491
|
+
# 16x16 transparent PNG (very small) encoded as base64
|
|
492
|
+
png_base64 = (
|
|
493
|
+
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAHElEQVR4AWP4//8/AyWYGKAA"
|
|
494
|
+
"GDAwMDAwQwYAAH7iB8o1s3BuAAAAAElFTkSuQmCC"
|
|
495
|
+
)
|
|
496
|
+
png_bytes = base64.b64decode(png_base64)
|
|
497
|
+
return Response(png_bytes, mimetype="image/png")
|
|
498
|
+
|
|
499
|
+
return app
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def run_server(host: str = "127.0.0.1", port: int = 5000, debug: bool = False) -> None:
|
|
503
|
+
"""Run the Flask development server."""
|
|
504
|
+
app = create_app()
|
|
505
|
+
app.run(host=host, port=port, debug=debug)
|
difflicious/cli.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Command-line interface for Difflicious."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from difflicious import __version__
|
|
6
|
+
from difflicious.app import run_server
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.command()
|
|
10
|
+
@click.version_option(version=__version__)
|
|
11
|
+
@click.option(
|
|
12
|
+
"--port",
|
|
13
|
+
default=5000,
|
|
14
|
+
help="Port to run the web server on (default: 5000)",
|
|
15
|
+
)
|
|
16
|
+
@click.option(
|
|
17
|
+
"--host",
|
|
18
|
+
default="127.0.0.1",
|
|
19
|
+
help="Host to bind the web server to (default: 127.0.0.1)",
|
|
20
|
+
)
|
|
21
|
+
@click.option(
|
|
22
|
+
"--debug",
|
|
23
|
+
is_flag=True,
|
|
24
|
+
help="Run in debug mode with auto-reload",
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--list-fonts",
|
|
28
|
+
is_flag=True,
|
|
29
|
+
help="List available fonts and exit",
|
|
30
|
+
)
|
|
31
|
+
def main(port: int, host: str, debug: bool, list_fonts: bool) -> None:
|
|
32
|
+
"""Start the Difflicious web application for git diff visualization.
|
|
33
|
+
|
|
34
|
+
Font customization:
|
|
35
|
+
Set DIFFLICIOUS_FONT to one of: fira-code, jetbrains-mono, source-code-pro,
|
|
36
|
+
ibm-plex-mono, roboto-mono, inconsolata (default: jetbrains-mono)
|
|
37
|
+
|
|
38
|
+
Set DIFFLICIOUS_DISABLE_GOOGLE_FONTS=true to disable Google Fonts CDN loading.
|
|
39
|
+
"""
|
|
40
|
+
if list_fonts:
|
|
41
|
+
from difflicious.app import create_app
|
|
42
|
+
|
|
43
|
+
app = create_app()
|
|
44
|
+
with app.app_context():
|
|
45
|
+
click.echo("Available fonts:")
|
|
46
|
+
# Access the font config from the app context
|
|
47
|
+
import os
|
|
48
|
+
|
|
49
|
+
AVAILABLE_FONTS = {
|
|
50
|
+
"fira-code": "Fira Code",
|
|
51
|
+
"jetbrains-mono": "JetBrains Mono (default)",
|
|
52
|
+
"source-code-pro": "Source Code Pro",
|
|
53
|
+
"ibm-plex-mono": "IBM Plex Mono",
|
|
54
|
+
"roboto-mono": "Roboto Mono",
|
|
55
|
+
"inconsolata": "Inconsolata",
|
|
56
|
+
}
|
|
57
|
+
current_font = os.getenv("DIFFLICIOUS_FONT", "jetbrains-mono")
|
|
58
|
+
for key, name in AVAILABLE_FONTS.items():
|
|
59
|
+
marker = " ā currently selected" if key == current_font else ""
|
|
60
|
+
click.echo(f" {key}: {name}{marker}")
|
|
61
|
+
click.echo(f"\nUsage: export DIFFLICIOUS_FONT={current_font}")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
click.echo(f"Starting Difflicious v{__version__}")
|
|
65
|
+
click.echo(f"Server will run at http://{host}:{port}")
|
|
66
|
+
|
|
67
|
+
if debug:
|
|
68
|
+
click.echo("š§ Debug mode enabled - server will auto-reload on changes")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
run_server(host=host, port=port, debug=debug)
|
|
72
|
+
except KeyboardInterrupt:
|
|
73
|
+
click.echo("\nš Shutting down Difflicious server")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main()
|