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,6 @@
1
+ """Difflicious - A sleek web-based git diff visualization tool."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "Drew"
5
+ __email__ = "noreply@example.com"
6
+ __description__ = "A sleek web-based git diff visualization tool for developers"
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()