git2report 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.
git2report/web.py ADDED
@@ -0,0 +1,1207 @@
1
+ """Interactive Terminal-Style Web GUI for Git2Report.
2
+
3
+ Provides an authentic CLI / Hacker terminal experience in the browser to configure,
4
+ execute, stream live logs, preview, and download engineering progress reports.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import mimetypes
11
+ import os
12
+ import subprocess
13
+ import threading
14
+ import urllib.parse
15
+ import webbrowser
16
+ from http import HTTPStatus
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from git2report.cache import default_cache
22
+ from git2report.models import GitReportError
23
+ from git2report.pipeline import Options, run_pipeline
24
+ from git2report.report import render_html, render_markdown, render_pdf
25
+ from git2report.summarizer import get_summarizer
26
+ from git2report.timeutil import parse_since
27
+
28
+ logger = logging.getLogger("git2report.web")
29
+
30
+
31
+ def discover_local_repos() -> list[dict[str, str]]:
32
+ """Scan current folder and cloned_repos/ for existing Git repositories."""
33
+ repos = []
34
+ seen = set()
35
+
36
+ def check(p: Path, label: str):
37
+ resolved = str(p.resolve())
38
+ if resolved not in seen and (p / ".git").exists():
39
+ seen.add(resolved)
40
+ repos.append({"path": str(p).replace("\\", "/"), "name": label})
41
+
42
+ # Current directory
43
+ check(Path("."), "Current Repo (.)")
44
+
45
+ # Cloned repos
46
+ cloned_dir = Path("cloned_repos")
47
+ if cloned_dir.exists():
48
+ for sub in cloned_dir.iterdir():
49
+ if sub.is_dir():
50
+ check(sub, f"cloned_repos/{sub.name}")
51
+
52
+ # Parent directory check
53
+ parent = Path("..")
54
+ if parent.exists():
55
+ for sub in parent.iterdir():
56
+ if sub.is_dir() and sub.name != "git2report":
57
+ check(sub, f"../{sub.name}")
58
+
59
+ return repos
60
+
61
+
62
+ TERMINAL_HTML = """<!DOCTYPE html>
63
+ <html lang="en">
64
+ <head>
65
+ <meta charset="UTF-8">
66
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
67
+ <title>git2report@terminal: ~</title>
68
+ <link rel="preconnect" href="https://fonts.googleapis.com">
69
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
70
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&display=swap" rel="stylesheet">
71
+ <style>
72
+ :root {
73
+ --term-bg: #0B0E14;
74
+ --term-card: #0F131D;
75
+ --term-border: #1E2638;
76
+ --term-border-glow: #00ff8833;
77
+ --text-main: #E2E8F0;
78
+ --text-muted: #64748B;
79
+ --prompt-user: #00FF88;
80
+ --prompt-host: #38BDF8;
81
+ --prompt-path: #FACC15;
82
+ --prompt-symbol: #EC4899;
83
+ --accent-green: #00FF88;
84
+ --accent-cyan: #38BDF8;
85
+ --accent-yellow: #FACC15;
86
+ --accent-red: #EF4444;
87
+ --accent-purple: #A855F7;
88
+ }
89
+
90
+ * { box-sizing: border-box; margin: 0; padding: 0; }
91
+
92
+ body {
93
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
94
+ background-color: #05070A;
95
+ background-image:
96
+ radial-gradient(ellipse at 50% 0%, rgba(14, 165, 233, 0.08) 0%, transparent 60%),
97
+ linear-gradient(rgba(255, 255, 255, 0.015) 1px, transparent 1px),
98
+ linear-gradient(90deg, rgba(255, 255, 255, 0.015) 1px, transparent 1px);
99
+ background-size: 100% 100%, 32px 32px, 32px 32px;
100
+ color: var(--text-main);
101
+ min-height: 100vh;
102
+ padding: 16px;
103
+ display: flex;
104
+ flex-direction: column;
105
+ align-items: center;
106
+ }
107
+
108
+ .term-window {
109
+ width: 100%;
110
+ max-width: 1360px;
111
+ background: var(--term-bg);
112
+ border: 1px solid var(--term-border);
113
+ border-radius: 12px;
114
+ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.8), 0 0 1px 1px var(--term-border);
115
+ overflow: hidden;
116
+ display: flex;
117
+ flex-direction: column;
118
+ }
119
+
120
+ /* Top Title Bar */
121
+ .title-bar {
122
+ background: #090C12;
123
+ border-bottom: 1px solid var(--term-border);
124
+ padding: 10px 16px;
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: space-between;
128
+ user-select: none;
129
+ }
130
+
131
+ .window-controls {
132
+ display: flex;
133
+ gap: 8px;
134
+ align-items: center;
135
+ }
136
+
137
+ .win-dot {
138
+ width: 12px;
139
+ height: 12px;
140
+ border-radius: 50%;
141
+ display: inline-block;
142
+ }
143
+
144
+ .dot-close { background: #FF5F56; border: 1px solid #E0443E; }
145
+ .dot-min { background: #FFBD2E; border: 1px solid #DEA123; }
146
+ .dot-max { background: #27C93F; border: 1px solid #1AAB29; }
147
+
148
+ .window-title {
149
+ font-size: 0.8rem;
150
+ font-weight: 600;
151
+ color: var(--text-muted);
152
+ letter-spacing: 0.05em;
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 8px;
156
+ }
157
+
158
+ .session-badge {
159
+ font-size: 0.7rem;
160
+ background: rgba(0, 255, 136, 0.1);
161
+ border: 1px solid rgba(0, 255, 136, 0.3);
162
+ color: var(--accent-green);
163
+ padding: 2px 8px;
164
+ border-radius: 4px;
165
+ font-weight: 600;
166
+ }
167
+
168
+ /* Terminal Body Content */
169
+ .term-content {
170
+ padding: 20px 24px;
171
+ display: flex;
172
+ flex-direction: column;
173
+ gap: 18px;
174
+ }
175
+
176
+ /* ASCII Banner */
177
+ .ascii-banner {
178
+ color: var(--accent-green);
179
+ font-size: 0.7rem;
180
+ line-height: 1.15;
181
+ white-space: pre;
182
+ user-select: none;
183
+ overflow-x: auto;
184
+ text-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
185
+ }
186
+
187
+ /* Prompt line */
188
+ .cli-bar {
189
+ background: var(--term-card);
190
+ border: 1px solid var(--term-border);
191
+ border-radius: 8px;
192
+ padding: 12px 16px;
193
+ font-size: 0.88rem;
194
+ display: flex;
195
+ flex-wrap: wrap;
196
+ align-items: center;
197
+ gap: 8px;
198
+ }
199
+
200
+ .prompt-label {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 2px;
204
+ font-weight: 700;
205
+ }
206
+ .p-user { color: var(--prompt-user); }
207
+ .p-at { color: var(--text-muted); }
208
+ .p-host { color: var(--prompt-host); }
209
+ .p-colon { color: var(--text-muted); }
210
+ .p-path { color: var(--prompt-path); }
211
+ .p-dollar { color: var(--prompt-symbol); margin-left: 4px; }
212
+
213
+ .command-display {
214
+ color: #FFFFFF;
215
+ font-weight: 600;
216
+ flex: 1;
217
+ min-width: 250px;
218
+ word-break: break-all;
219
+ }
220
+
221
+ .cursor-blink {
222
+ display: inline-block;
223
+ width: 8px;
224
+ height: 15px;
225
+ background: var(--accent-green);
226
+ animation: blink 1s step-start infinite;
227
+ vertical-align: middle;
228
+ margin-left: 2px;
229
+ }
230
+
231
+ @keyframes blink {
232
+ 50% { opacity: 0; }
233
+ }
234
+
235
+ /* Quick Action Shortcuts */
236
+ .quick-actions {
237
+ display: flex;
238
+ flex-wrap: wrap;
239
+ align-items: center;
240
+ gap: 8px;
241
+ font-size: 0.78rem;
242
+ }
243
+
244
+ .qa-title {
245
+ color: var(--text-muted);
246
+ font-size: 0.75rem;
247
+ text-transform: uppercase;
248
+ letter-spacing: 0.05em;
249
+ }
250
+
251
+ .btn-qa {
252
+ background: rgba(255, 255, 255, 0.04);
253
+ border: 1px solid var(--term-border);
254
+ color: var(--accent-cyan);
255
+ padding: 5px 10px;
256
+ border-radius: 6px;
257
+ font-family: inherit;
258
+ font-size: 0.75rem;
259
+ cursor: pointer;
260
+ transition: all 0.15s;
261
+ }
262
+
263
+ .btn-qa:hover {
264
+ background: rgba(56, 189, 248, 0.15);
265
+ border-color: var(--accent-cyan);
266
+ box-shadow: 0 0 10px rgba(56, 189, 248, 0.2);
267
+ }
268
+
269
+ .btn-qa.danger {
270
+ color: var(--accent-red);
271
+ }
272
+ .btn-qa.danger:hover {
273
+ background: rgba(239, 68, 68, 0.15);
274
+ border-color: var(--accent-red);
275
+ }
276
+
277
+ /* Split Workspace Grid */
278
+ .workspace-grid {
279
+ display: grid;
280
+ grid-template-columns: 460px 1fr;
281
+ gap: 20px;
282
+ }
283
+
284
+ @media (max-width: 1024px) {
285
+ .workspace-grid { grid-template-columns: 1fr; }
286
+ }
287
+
288
+ .terminal-pane {
289
+ background: var(--term-card);
290
+ border: 1px solid var(--term-border);
291
+ border-radius: 8px;
292
+ display: flex;
293
+ flex-direction: column;
294
+ overflow: hidden;
295
+ }
296
+
297
+ .pane-header {
298
+ background: #090C12;
299
+ padding: 10px 14px;
300
+ border-bottom: 1px solid var(--term-border);
301
+ font-size: 0.78rem;
302
+ font-weight: 700;
303
+ color: var(--accent-cyan);
304
+ display: flex;
305
+ align-items: center;
306
+ justify-content: space-between;
307
+ letter-spacing: 0.05em;
308
+ }
309
+
310
+ .pane-body {
311
+ padding: 16px;
312
+ display: flex;
313
+ flex-direction: column;
314
+ gap: 16px;
315
+ }
316
+
317
+ /* Terminal Fields */
318
+ .arg-group {
319
+ display: flex;
320
+ flex-direction: column;
321
+ gap: 6px;
322
+ }
323
+
324
+ .arg-label {
325
+ font-size: 0.78rem;
326
+ color: var(--accent-yellow);
327
+ font-weight: 600;
328
+ display: flex;
329
+ align-items: center;
330
+ gap: 6px;
331
+ }
332
+
333
+ .arg-desc {
334
+ font-size: 0.7rem;
335
+ color: var(--text-muted);
336
+ margin-left: 4px;
337
+ }
338
+
339
+ .term-input, .term-select {
340
+ background: #06090F;
341
+ border: 1px solid var(--term-border);
342
+ color: #FFFFFF;
343
+ padding: 8px 12px;
344
+ border-radius: 6px;
345
+ font-family: inherit;
346
+ font-size: 0.85rem;
347
+ outline: none;
348
+ transition: all 0.2s;
349
+ width: 100%;
350
+ }
351
+
352
+ .term-input:focus, .term-select:focus {
353
+ border-color: var(--accent-green);
354
+ box-shadow: 0 0 0 1px var(--accent-green), 0 0 10px rgba(0, 255, 136, 0.15);
355
+ }
356
+
357
+ .preset-pills {
358
+ display: flex;
359
+ flex-wrap: wrap;
360
+ gap: 6px;
361
+ margin-top: 2px;
362
+ }
363
+
364
+ .p-pill {
365
+ background: rgba(255, 255, 255, 0.03);
366
+ border: 1px solid var(--term-border);
367
+ color: var(--text-muted);
368
+ padding: 3px 8px;
369
+ border-radius: 4px;
370
+ font-size: 0.72rem;
371
+ cursor: pointer;
372
+ transition: all 0.15s;
373
+ }
374
+
375
+ .p-pill:hover, .p-pill.active {
376
+ background: rgba(0, 255, 136, 0.15);
377
+ border-color: var(--accent-green);
378
+ color: var(--accent-green);
379
+ }
380
+
381
+ .checkbox-row {
382
+ display: flex;
383
+ gap: 16px;
384
+ background: #06090F;
385
+ padding: 8px 12px;
386
+ border-radius: 6px;
387
+ border: 1px solid var(--term-border);
388
+ font-size: 0.8rem;
389
+ }
390
+
391
+ .checkbox-item {
392
+ display: flex;
393
+ align-items: center;
394
+ gap: 6px;
395
+ cursor: pointer;
396
+ color: var(--text-main);
397
+ }
398
+
399
+ .btn-exec {
400
+ background: linear-gradient(135deg, #00FF88 0%, #10B981 100%);
401
+ color: #05070A;
402
+ border: none;
403
+ padding: 12px;
404
+ border-radius: 6px;
405
+ font-family: inherit;
406
+ font-size: 0.88rem;
407
+ font-weight: 800;
408
+ letter-spacing: 0.05em;
409
+ cursor: pointer;
410
+ display: flex;
411
+ align-items: center;
412
+ justify-content: center;
413
+ gap: 8px;
414
+ box-shadow: 0 4px 16px rgba(0, 255, 136, 0.3);
415
+ transition: all 0.2s;
416
+ }
417
+
418
+ .btn-exec:hover:not(:disabled) {
419
+ transform: translateY(-1px);
420
+ box-shadow: 0 6px 24px rgba(0, 255, 136, 0.5);
421
+ }
422
+
423
+ .btn-exec:disabled {
424
+ opacity: 0.5;
425
+ cursor: not-allowed;
426
+ }
427
+
428
+ /* Output Stream Section */
429
+ .stream-box {
430
+ background: #06090F;
431
+ border: 1px solid var(--term-border);
432
+ border-radius: 6px;
433
+ padding: 14px;
434
+ font-size: 0.8rem;
435
+ height: 220px;
436
+ overflow-y: auto;
437
+ white-space: pre-wrap;
438
+ color: #94A3B8;
439
+ line-height: 1.5;
440
+ }
441
+
442
+ .stream-line {
443
+ display: block;
444
+ margin-bottom: 2px;
445
+ }
446
+
447
+ .stream-line.info { color: var(--accent-cyan); }
448
+ .stream-line.success { color: var(--accent-green); font-weight: 700; }
449
+ .stream-line.error { color: var(--accent-red); font-weight: 700; }
450
+
451
+ /* ASCII KPI Table */
452
+ .ascii-kpi-card {
453
+ background: #06090F;
454
+ border: 1px solid var(--term-border);
455
+ border-radius: 6px;
456
+ padding: 12px 14px;
457
+ font-size: 0.8rem;
458
+ display: grid;
459
+ grid-template-columns: repeat(4, 1fr);
460
+ gap: 12px;
461
+ }
462
+
463
+ @media (max-width: 768px) {
464
+ .ascii-kpi-card { grid-template-columns: repeat(2, 1fr); }
465
+ }
466
+
467
+ .kpi-unit-title {
468
+ font-size: 0.68rem;
469
+ color: var(--text-muted);
470
+ text-transform: uppercase;
471
+ letter-spacing: 0.06em;
472
+ }
473
+
474
+ .kpi-unit-val {
475
+ font-size: 1.2rem;
476
+ font-weight: 700;
477
+ color: #FFFFFF;
478
+ margin-top: 2px;
479
+ }
480
+
481
+ /* Downloads Bar */
482
+ .term-export-bar {
483
+ display: flex;
484
+ flex-wrap: wrap;
485
+ align-items: center;
486
+ justify-content: space-between;
487
+ gap: 10px;
488
+ background: #06090F;
489
+ border: 1px solid var(--term-border);
490
+ border-radius: 6px;
491
+ padding: 10px 14px;
492
+ }
493
+
494
+ .export-btns {
495
+ display: flex;
496
+ gap: 8px;
497
+ }
498
+
499
+ .btn-export {
500
+ background: rgba(255, 255, 255, 0.05);
501
+ border: 1px solid var(--term-border);
502
+ color: var(--accent-green);
503
+ padding: 6px 12px;
504
+ border-radius: 5px;
505
+ font-family: inherit;
506
+ font-size: 0.78rem;
507
+ font-weight: 700;
508
+ cursor: pointer;
509
+ text-decoration: none;
510
+ display: inline-flex;
511
+ align-items: center;
512
+ gap: 6px;
513
+ transition: all 0.15s;
514
+ }
515
+
516
+ .btn-export:hover {
517
+ background: rgba(0, 255, 136, 0.15);
518
+ border-color: var(--accent-green);
519
+ box-shadow: 0 0 10px rgba(0, 255, 136, 0.2);
520
+ }
521
+
522
+ .btn-export.secondary {
523
+ color: var(--accent-cyan);
524
+ }
525
+ .btn-export.secondary:hover {
526
+ background: rgba(56, 189, 248, 0.15);
527
+ border-color: var(--accent-cyan);
528
+ box-shadow: 0 0 10px rgba(56, 189, 248, 0.2);
529
+ }
530
+
531
+ /* Preview Frame */
532
+ .preview-container {
533
+ width: 100%;
534
+ height: 520px;
535
+ background: white;
536
+ border: 1px solid var(--term-border);
537
+ border-radius: 6px;
538
+ overflow: hidden;
539
+ }
540
+
541
+ .preview-frame {
542
+ width: 100%;
543
+ height: 100%;
544
+ border: none;
545
+ }
546
+
547
+ .tab-bar {
548
+ display: flex;
549
+ gap: 6px;
550
+ border-bottom: 1px solid var(--term-border);
551
+ padding: 0 12px;
552
+ background: #090C12;
553
+ }
554
+
555
+ .t-tab {
556
+ background: transparent;
557
+ border: none;
558
+ border-bottom: 2px solid transparent;
559
+ color: var(--text-muted);
560
+ padding: 8px 12px;
561
+ font-family: inherit;
562
+ font-size: 0.78rem;
563
+ font-weight: 600;
564
+ cursor: pointer;
565
+ }
566
+
567
+ .t-tab.active {
568
+ color: var(--accent-green);
569
+ border-bottom-color: var(--accent-green);
570
+ }
571
+ </style>
572
+ </head>
573
+ <body>
574
+
575
+ <div class="term-window">
576
+ <!-- Top macOS / Linux Window Bar -->
577
+ <div class="title-bar">
578
+ <div class="window-controls">
579
+ <span class="win-dot dot-close"></span>
580
+ <span class="win-dot dot-min"></span>
581
+ <span class="win-dot dot-max"></span>
582
+ </div>
583
+ <div class="window-title">
584
+ <span>terminal@git2report: ~ (pts/0 - zsh)</span>
585
+ </div>
586
+ <div class="session-badge">
587
+ <span>● DAEMON ACTIVE</span>
588
+ </div>
589
+ </div>
590
+
591
+ <div class="term-content">
592
+ <!-- ASCII Header Art -->
593
+ <pre class="ascii-banner">
594
+ ██████╗ ██╗████████╗██████╗ ██████╗ ███████╗██████╗ ██████╗ ██████╗ ████████╗
595
+ ██╔════╝ ██║╚══██╔══╝╚════██╗██╔══██╗██╔════╝██╔══██╗██╔═══██╗██╔══██╗╚══██╔══╝
596
+ ██║ ███╗██║ ██║ █████╔╝██████╔╝█████╗ ██████╔╝██║ ██║██████╔╝ ██║
597
+ ██║ ██║██║ ██║ ██╔═══╝ ██╔══██╗██╔══╝ ██╔═══╝ ██║ ██║██╔══██╗ ██║
598
+ ╚██████╔╝██║ ██║ ███████╗██║ ██║███████╗██║ ╚██████╔╝██║ ██║ ██║
599
+ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
600
+ [ v0.1.0 :: AUTONOMOUS EVIDENCE-LINKED REPOSITORY REPORTING SYSTEM ]
601
+ </pre>
602
+
603
+ <!-- Dynamic CLI Command Line -->
604
+ <div class="cli-bar">
605
+ <div class="prompt-label">
606
+ <span class="p-user">root</span><span class="p-at">@</span><span class="p-host">git2report</span><span class="p-colon">:</span><span class="p-path">~</span><span class="p-dollar">$</span>
607
+ </div>
608
+ <div class="command-display" id="commandPreview">
609
+ git2report --repo cloned_repos/CampusThrift --since 2025-01-01 --audience guide --llm gemini --all
610
+ </div>
611
+ <span class="cursor-blink"></span>
612
+ </div>
613
+
614
+ <!-- Quick Command Shortcuts -->
615
+ <div class="quick-actions">
616
+ <span class="qa-title">Presets:</span>
617
+ <button type="button" class="btn-qa" onclick="loadPreset('campusthrift')">[$ CAMPUS THRIFT (FULL)]</button>
618
+ <button type="button" class="btn-qa" onclick="loadPreset('current')">[$ CURRENT REPO (.)]</button>
619
+ <button type="button" class="btn-qa" onclick="loadPreset('offline')">[$ FAST OFFLINE MODE]</button>
620
+ <button type="button" class="btn-qa danger" onclick="clearCache()">[$ CLEAR CACHE]</button>
621
+ </div>
622
+
623
+ <!-- Main Workspace Grid -->
624
+ <div class="workspace-grid">
625
+ <!-- LEFT PANE: CONFIGURATION & COMMAND PROMPT -->
626
+ <div class="terminal-pane">
627
+ <div class="pane-header">
628
+ <span>[1] CLI FLAGS &amp; PARAMETERS</span>
629
+ <span style="color: var(--text-muted);">STDIN</span>
630
+ </div>
631
+
632
+ <form id="termForm" class="pane-body" onsubmit="executeRun(event)">
633
+ <!-- Target Repo -->
634
+ <div class="arg-group">
635
+ <label class="arg-label" for="repoInput">
636
+ <span>--repo, -r</span>
637
+ <span class="arg-desc">&lt;path or github url&gt;</span>
638
+ </label>
639
+ <input type="text" id="repoInput" class="term-input" value="cloned_repos/CampusThrift" oninput="updateCliPreview()" required>
640
+ <div class="preset-pills" id="discoveredReposPills">
641
+ <!-- Dynamically populated -->
642
+ </div>
643
+ </div>
644
+
645
+ <!-- Time Window -->
646
+ <div class="arg-group">
647
+ <label class="arg-label" for="sinceInput">
648
+ <span>--since, -s</span>
649
+ <span class="arg-desc">&lt;7d | 14d | 30d | YYYY-MM-DD&gt;</span>
650
+ </label>
651
+ <input type="text" id="sinceInput" class="term-input" value="2025-01-01" oninput="updateCliPreview()" required>
652
+ <div class="preset-pills">
653
+ <span class="p-pill" onclick="setSinceVal('7d')">7d</span>
654
+ <span class="p-pill" onclick="setSinceVal('14d')">14d</span>
655
+ <span class="p-pill" onclick="setSinceVal('30d')">30d</span>
656
+ <span class="p-pill active" onclick="setSinceVal('2025-01-01')">2025-01-01 (All)</span>
657
+ </div>
658
+ </div>
659
+
660
+ <!-- Audience Persona -->
661
+ <div class="arg-group">
662
+ <label class="arg-label" for="audienceSelect">
663
+ <span>--audience, -a</span>
664
+ <span class="arg-desc">&lt;guide | client | team&gt;</span>
665
+ </label>
666
+ <select id="audienceSelect" class="term-select" onchange="updateCliPreview()">
667
+ <option value="guide" selected>guide (Academic &amp; Architecture)</option>
668
+ <option value="client">client (Business &amp; User-Facing)</option>
669
+ <option value="team">team (Release-Notes Technical)</option>
670
+ </select>
671
+ </div>
672
+
673
+ <!-- AI Engine -->
674
+ <div class="arg-group">
675
+ <label class="arg-label" for="llmSelect">
676
+ <span>--llm</span>
677
+ <span class="arg-desc">&lt;gemini | ollama | offline&gt;</span>
678
+ </label>
679
+ <select id="llmSelect" class="term-select" onchange="onLlmChange()">
680
+ <option value="gemini" selected>gemini (Google Gemini + Smart Cache)</option>
681
+ <option value="ollama">ollama (Local Private AI / Offline Model)</option>
682
+ <option value="offline">offline (Deterministic Heuristic / Zero AI)</option>
683
+ </select>
684
+ </div>
685
+
686
+ <!-- Model Selection (Shows for Ollama & Gemini) -->
687
+ <div class="arg-group" id="modelGroup" style="display: none;">
688
+ <div style="display: flex; justify-content: space-between; align-items: center;">
689
+ <label class="arg-label" for="modelInput">
690
+ <span>--model</span>
691
+ <span class="arg-desc" id="modelLabelDesc">&lt;model name&gt;</span>
692
+ </label>
693
+ <span id="ollamaStatusBadge" style="font-size: 0.68rem; color: var(--text-muted);"></span>
694
+ </div>
695
+ <input type="text" id="modelInput" class="term-input" value="llama3:8b" placeholder="e.g. llama3:8b, mistral, deepseek-r1:8b" oninput="updateCliPreview()">
696
+ <div class="preset-pills" id="modelPills">
697
+ <span class="p-pill active" onclick="setModelVal('llama3:8b')">llama3:8b</span>
698
+ <span class="p-pill" onclick="setModelVal('llama3.2')">llama3.2</span>
699
+ <span class="p-pill" onclick="setModelVal('mistral')">mistral</span>
700
+ <span class="p-pill" onclick="setModelVal('deepseek-r1:8b')">deepseek-r1:8b</span>
701
+ <span class="p-pill" onclick="setModelVal('phi4')">phi4</span>
702
+ <span class="p-pill" onclick="setModelVal('qwen2.5')">qwen2.5</span>
703
+ </div>
704
+ </div>
705
+
706
+ <!-- Planned Goals -->
707
+ <div class="arg-group">
708
+ <label class="arg-label" for="nextGoalInput">
709
+ <span>--next, -n</span>
710
+ <span class="arg-desc">&lt;optional sprint goal&gt;</span>
711
+ </label>
712
+ <input type="text" id="nextGoalInput" class="term-input" placeholder="e.g. Implement realtime chat" oninput="updateCliPreview()">
713
+ </div>
714
+
715
+ <!-- Format Switches -->
716
+ <div class="arg-group">
717
+ <label class="arg-label">
718
+ <span>--formats</span>
719
+ <span class="arg-desc">&lt;emit outputs&gt;</span>
720
+ </label>
721
+ <div class="checkbox-row">
722
+ <label class="checkbox-item"><input type="checkbox" id="fmtPdf" checked> .pdf</label>
723
+ <label class="checkbox-item"><input type="checkbox" id="fmtHtml" checked> .html</label>
724
+ <label class="checkbox-item"><input type="checkbox" id="fmtMd" checked> .md</label>
725
+ </div>
726
+ </div>
727
+
728
+ <button type="submit" id="btnExecute" class="btn-exec">
729
+ <span>▶ RUN PIPELINE (ENTER)</span>
730
+ </button>
731
+ </form>
732
+ </div>
733
+
734
+ <!-- RIGHT PANE: LIVE TERMINAL LOGS & RESULTS -->
735
+ <div class="terminal-pane">
736
+ <div class="pane-header">
737
+ <span>[2] EXECUTION CONSOLE &amp; PREVIEW</span>
738
+ <span id="execStatus" style="color: var(--accent-green);">STDOUT: IDLE</span>
739
+ </div>
740
+
741
+ <div class="tab-bar">
742
+ <button class="t-tab active" id="tabStreamBtn" onclick="switchRightTab('stream')">💻 STDOUT STREAM</button>
743
+ <button class="t-tab" id="tabPreviewBtn" onclick="switchRightTab('preview')">👁 SPLIT PREVIEW</button>
744
+ </div>
745
+
746
+ <div class="pane-body">
747
+ <!-- Terminal Output Stream -->
748
+ <div id="viewStream">
749
+ <div class="stream-box" id="termStream">
750
+ <span class="stream-line info">[+] Git2Report System Initialized.</span>
751
+ <span class="stream-line">[*] Ready for execution. Press [▶ RUN PIPELINE] or hit ENTER.</span>
752
+ </div>
753
+ </div>
754
+
755
+ <!-- Live HTML Report Preview -->
756
+ <div id="viewPreview" style="display: none;">
757
+ <div class="preview-container">
758
+ <iframe id="reportPreviewFrame" class="preview-frame"></iframe>
759
+ </div>
760
+ </div>
761
+
762
+ <!-- ASCII KPI Box -->
763
+ <div class="ascii-kpi-card" id="kpiBox" style="display: none;">
764
+ <div>
765
+ <div class="kpi-unit-title">Commits</div>
766
+ <div class="kpi-unit-val" id="kpiCommits" style="color: var(--accent-cyan);">0</div>
767
+ </div>
768
+ <div>
769
+ <div class="kpi-unit-title">Code Churn</div>
770
+ <div class="kpi-unit-val" id="kpiChurn" style="color: var(--accent-green);">0</div>
771
+ </div>
772
+ <div>
773
+ <div class="kpi-unit-title">Top Focus</div>
774
+ <div class="kpi-unit-val" id="kpiTop" style="color: var(--accent-yellow); font-size: 1rem;">-</div>
775
+ </div>
776
+ <div>
777
+ <div class="kpi-unit-title">Test Ratio</div>
778
+ <div class="kpi-unit-val" id="kpiTest" style="color: var(--accent-purple);">0.0</div>
779
+ </div>
780
+ </div>
781
+
782
+ <!-- Terminal Downloads Toolbar -->
783
+ <div class="term-export-bar" id="exportBar" style="display: none;">
784
+ <div style="font-size: 0.78rem; color: var(--accent-green); font-weight: 700;">
785
+ [✓] PIPELINE COMPLETE :: ARTIFACTS READY
786
+ </div>
787
+ <div class="export-btns">
788
+ <a id="btnPdf" href="#" class="btn-export" download>⤓ report.pdf</a>
789
+ <a id="btnHtml" href="#" class="btn-export secondary" download>⤓ report.html</a>
790
+ <a id="btnMd" href="#" class="btn-export secondary" download>⤓ report.md</a>
791
+ </div>
792
+ </div>
793
+
794
+ </div>
795
+ </div>
796
+ </div>
797
+ </div>
798
+ </div>
799
+
800
+ <script>
801
+ // Discover local repositories
802
+ window.addEventListener('DOMContentLoaded', async () => {
803
+ try {
804
+ const res = await fetch('/api/repos');
805
+ const data = await res.json();
806
+ const container = document.getElementById('discoveredReposPills');
807
+ data.repos.forEach(r => {
808
+ const pill = document.createElement('span');
809
+ pill.className = 'p-pill';
810
+ pill.innerText = r.name;
811
+ pill.onclick = () => {
812
+ document.getElementById('repoInput').value = r.path;
813
+ updateCliPreview();
814
+ };
815
+ container.appendChild(pill);
816
+ });
817
+ } catch (e) {
818
+ console.error(e);
819
+ }
820
+ updateCliPreview();
821
+ checkOllamaStatus();
822
+ });
823
+
824
+ async function checkOllamaStatus() {
825
+ const badge = document.getElementById('ollamaStatusBadge');
826
+ try {
827
+ const res = await fetch('/api/ollama/status');
828
+ const data = await res.json();
829
+ if (data.online) {
830
+ badge.innerHTML = `<span style="color: var(--accent-green);">● OLLAMA ONLINE</span>`;
831
+ if (data.models && data.models.length > 0) {
832
+ const container = document.getElementById('modelPills');
833
+ container.innerHTML = '';
834
+ data.models.forEach((m, idx) => {
835
+ const pill = document.createElement('span');
836
+ pill.className = 'p-pill' + (idx === 0 ? ' active' : '');
837
+ pill.innerText = m;
838
+ pill.onclick = () => setModelVal(m);
839
+ container.appendChild(pill);
840
+ });
841
+ document.getElementById('modelInput').value = data.models[0];
842
+ updateCliPreview();
843
+ }
844
+ } else {
845
+ badge.innerHTML = `<span style="color: var(--text-muted);">○ OLLAMA OFFLINE (run 'ollama serve')</span>`;
846
+ }
847
+ } catch (e) {
848
+ badge.innerHTML = `<span style="color: var(--text-muted);">○ OFFLINE</span>`;
849
+ }
850
+ }
851
+
852
+ function onLlmChange() {
853
+ const llm = document.getElementById('llmSelect').value;
854
+ const modelGroup = document.getElementById('modelGroup');
855
+ if (llm === 'ollama') {
856
+ modelGroup.style.display = 'flex';
857
+ document.getElementById('modelLabelDesc').innerText = '<ollama model, e.g. llama3:8b>';
858
+ checkOllamaStatus();
859
+ } else {
860
+ modelGroup.style.display = 'none';
861
+ }
862
+ updateCliPreview();
863
+ }
864
+
865
+ function setModelVal(val) {
866
+ document.getElementById('modelInput').value = val;
867
+ document.querySelectorAll('#modelPills .p-pill').forEach(p => p.classList.remove('active'));
868
+ event.target.classList.add('active');
869
+ updateCliPreview();
870
+ }
871
+
872
+ function setSinceVal(val) {
873
+ document.getElementById('sinceInput').value = val;
874
+ document.querySelectorAll('.preset-pills .p-pill').forEach(p => p.classList.remove('active'));
875
+ event.target.classList.add('active');
876
+ updateCliPreview();
877
+ }
878
+
879
+ function updateCliPreview() {
880
+ const repo = document.getElementById('repoInput').value.trim() || '.';
881
+ const since = document.getElementById('sinceInput').value.trim() || '7d';
882
+ const audience = document.getElementById('audienceSelect').value;
883
+ const llm = document.getElementById('llmSelect').value;
884
+ const model = document.getElementById('modelInput').value.trim();
885
+ const next = document.getElementById('nextGoalInput').value.trim();
886
+
887
+ let cmd = `git2report --repo ${repo} --since ${since} --audience ${audience} --llm ${llm}`;
888
+ if (llm === 'ollama' && model) {
889
+ cmd += ` --model ${model}`;
890
+ }
891
+ if (next) {
892
+ cmd += ` --next "${next}"`;
893
+ }
894
+ cmd += ' --all';
895
+ document.getElementById('commandPreview').innerText = cmd;
896
+ }
897
+
898
+ function loadPreset(type) {
899
+ if (type === 'campusthrift') {
900
+ document.getElementById('repoInput').value = 'cloned_repos/CampusThrift';
901
+ document.getElementById('sinceInput').value = '2025-01-01';
902
+ document.getElementById('audienceSelect').value = 'guide';
903
+ document.getElementById('llmSelect').value = 'gemini';
904
+ } else if (type === 'current') {
905
+ document.getElementById('repoInput').value = '.';
906
+ document.getElementById('sinceInput').value = '7d';
907
+ document.getElementById('audienceSelect').value = 'team';
908
+ document.getElementById('llmSelect').value = 'gemini';
909
+ } else if (type === 'offline') {
910
+ document.getElementById('llmSelect').value = 'offline';
911
+ }
912
+ onLlmChange();
913
+ }
914
+
915
+ async function clearCache() {
916
+ if (!confirm('Clear smart summary cache (.git2report_cache.json)?')) return;
917
+ try {
918
+ const res = await fetch('/api/cache/clear', { method: 'POST' });
919
+ const data = await res.json();
920
+ appendStreamLine(`[✓] Cache cleared: ${data.cleared || 0} entries removed.`, 'success');
921
+ } catch (e) {
922
+ appendStreamLine(`[!] Failed to clear cache: ${e.message}`, 'error');
923
+ }
924
+ }
925
+
926
+ function switchRightTab(name) {
927
+ document.getElementById('tabStreamBtn').classList.toggle('active', name === 'stream');
928
+ document.getElementById('tabPreviewBtn').classList.toggle('active', name === 'preview');
929
+ document.getElementById('viewStream').style.display = (name === 'stream') ? 'block' : 'none';
930
+ document.getElementById('viewPreview').style.display = (name === 'preview') ? 'block' : 'none';
931
+ }
932
+
933
+ function appendStreamLine(text, type = '') {
934
+ const box = document.getElementById('termStream');
935
+ const line = document.createElement('span');
936
+ line.className = 'stream-line ' + type;
937
+ line.innerText = text;
938
+ box.appendChild(line);
939
+ box.scrollTop = box.scrollHeight;
940
+ }
941
+
942
+ async function executeRun(e) {
943
+ e.preventDefault();
944
+ const btn = document.getElementById('btnExecute');
945
+ btn.disabled = true;
946
+ btn.innerHTML = '<span>⚡ EXECUTING PIPELINE...</span>';
947
+ document.getElementById('execStatus').innerText = 'STDOUT: RUNNING';
948
+ document.getElementById('execStatus').style.color = 'var(--accent-yellow)';
949
+
950
+ switchRightTab('stream');
951
+ const stream = document.getElementById('termStream');
952
+ stream.innerHTML = '';
953
+ appendStreamLine(`$ ${document.getElementById('commandPreview').innerText}`, 'info');
954
+
955
+ const repo = document.getElementById('repoInput').value.trim();
956
+ const since = document.getElementById('sinceInput').value.trim();
957
+ const audience = document.getElementById('audienceSelect').value;
958
+ const llm = document.getElementById('llmSelect').value;
959
+ const model = document.getElementById('modelInput').value.trim();
960
+ const nextGoal = document.getElementById('nextGoalInput').value.trim();
961
+
962
+ const payload = {
963
+ repo,
964
+ since,
965
+ audience,
966
+ llm,
967
+ model: (llm === 'ollama') ? model : '',
968
+ next_goals: nextGoal ? [nextGoal] : [],
969
+ formats: {
970
+ pdf: document.getElementById('fmtPdf').checked,
971
+ html: document.getElementById('fmtHtml').checked,
972
+ md: document.getElementById('fmtMd').checked,
973
+ }
974
+ };
975
+
976
+ try {
977
+ const res = await fetch('/api/generate', {
978
+ method: 'POST',
979
+ headers: { 'Content-Type': 'application/json' },
980
+ body: JSON.stringify(payload)
981
+ });
982
+ const result = await res.json();
983
+
984
+ if (result.error) {
985
+ appendStreamLine(`[ERROR] ${result.error}`, 'error');
986
+ document.getElementById('execStatus').innerText = 'STDOUT: FAILED';
987
+ document.getElementById('execStatus').style.color = 'var(--accent-red)';
988
+ return;
989
+ }
990
+
991
+ (result.logs || []).forEach(l => appendStreamLine(`[+] ${l}`, 'info'));
992
+ appendStreamLine(`[✓] SUCCESS: Generated PDF, HTML & MD in reports/`, 'success');
993
+
994
+ // Update KPI
995
+ document.getElementById('kpiBox').style.display = 'grid';
996
+ document.getElementById('kpiCommits').innerText = result.metrics.commits || '0';
997
+ document.getElementById('kpiChurn').innerText = `+${result.metrics.lines_added || 0} / -${result.metrics.lines_deleted || 0}`;
998
+ document.getElementById('kpiTop').innerText = result.metrics.top_module || '-';
999
+ document.getElementById('kpiTest').innerText = result.metrics.test_to_code_ratio || '0.0';
1000
+
1001
+ // Update Downloads
1002
+ document.getElementById('exportBar').style.display = 'flex';
1003
+ document.getElementById('btnPdf').href = '/api/download?type=pdf&token=' + Date.now();
1004
+ document.getElementById('btnHtml').href = '/api/download?type=html&token=' + Date.now();
1005
+ document.getElementById('btnMd').href = '/api/download?type=md&token=' + Date.now();
1006
+
1007
+ // Update Live Frame
1008
+ document.getElementById('reportPreviewFrame').src = '/api/preview?token=' + Date.now();
1009
+
1010
+ document.getElementById('execStatus').innerText = 'STDOUT: COMPLETED';
1011
+ document.getElementById('execStatus').style.color = 'var(--accent-green)';
1012
+ } catch (err) {
1013
+ appendStreamLine(`[CRITICAL] Server connection failed: ${err.message}`, 'error');
1014
+ document.getElementById('execStatus').innerText = 'STDOUT: ERROR';
1015
+ document.getElementById('execStatus').style.color = 'var(--accent-red)';
1016
+ } finally {
1017
+ btn.disabled = false;
1018
+ btn.innerHTML = '<span>▶ RUN PIPELINE (ENTER)</span>';
1019
+ }
1020
+ }
1021
+ </script>
1022
+ </body>
1023
+ </html>
1024
+ """
1025
+
1026
+
1027
+ class WebStudioHandler(BaseHTTPRequestHandler):
1028
+ last_generated_files: dict[str, Path] = {}
1029
+
1030
+ def log_message(self, format: str, *args: Any) -> None:
1031
+ """Suppress standard access logs to keep terminal output clean."""
1032
+ pass
1033
+
1034
+ def do_GET(self) -> None:
1035
+ parsed = urllib.parse.urlparse(self.path)
1036
+ path = parsed.path
1037
+ query = urllib.parse.parse_qs(parsed.query)
1038
+
1039
+ if path == "/" or path == "/index.html":
1040
+ self.send_response(HTTPStatus.OK)
1041
+ self.send_header("Content-Type", "text/html; charset=utf-8")
1042
+ self.end_headers()
1043
+ self.wfile.write(TERMINAL_HTML.encode("utf-8"))
1044
+ return
1045
+
1046
+ if path == "/api/repos":
1047
+ repos = discover_local_repos()
1048
+ self.send_response(HTTPStatus.OK)
1049
+ self.send_header("Content-Type", "application/json")
1050
+ self.end_headers()
1051
+ self.wfile.write(json.dumps({"repos": repos}).encode("utf-8"))
1052
+ return
1053
+
1054
+ if path == "/api/ollama/status":
1055
+ from git2report.summarizer import list_local_ollama_models
1056
+ models = list_local_ollama_models()
1057
+ self.send_response(HTTPStatus.OK)
1058
+ self.send_header("Content-Type", "application/json")
1059
+ self.end_headers()
1060
+ self.wfile.write(json.dumps({"online": len(models) > 0, "models": models}).encode("utf-8"))
1061
+ return
1062
+
1063
+ if path == "/api/preview":
1064
+ html_path = self.last_generated_files.get("html")
1065
+ if html_path and html_path.exists():
1066
+ content = html_path.read_bytes()
1067
+ self.send_response(HTTPStatus.OK)
1068
+ self.send_header("Content-Type", "text/html; charset=utf-8")
1069
+ self.end_headers()
1070
+ self.wfile.write(content)
1071
+ return
1072
+ self.send_error(HTTPStatus.NOT_FOUND, "No HTML report generated yet.")
1073
+ return
1074
+
1075
+ if path == "/api/download":
1076
+ ftype = query.get("type", ["pdf"])[0].lower()
1077
+ target_path = self.last_generated_files.get(ftype)
1078
+ if target_path and target_path.exists():
1079
+ mime, _ = mimetypes.guess_type(str(target_path))
1080
+ mime = mime or "application/octet-stream"
1081
+ self.send_response(HTTPStatus.OK)
1082
+ self.send_header("Content-Type", mime)
1083
+ self.send_header("Content-Disposition", f'attachment; filename="{target_path.name}"')
1084
+ self.send_header("Content-Length", str(target_path.stat().st_size))
1085
+ self.end_headers()
1086
+ self.wfile.write(target_path.read_bytes())
1087
+ return
1088
+ self.send_error(HTTPStatus.NOT_FOUND, f"File for {ftype} not found.")
1089
+ return
1090
+
1091
+ self.send_error(HTTPStatus.NOT_FOUND, "Not found")
1092
+
1093
+ def do_POST(self) -> None:
1094
+ parsed = urllib.parse.urlparse(self.path)
1095
+ if parsed.path == "/api/cache/clear":
1096
+ cleared = default_cache.clear()
1097
+ self.send_response(HTTPStatus.OK)
1098
+ self.send_header("Content-Type", "application/json")
1099
+ self.end_headers()
1100
+ self.wfile.write(json.dumps({"cleared": cleared}).encode("utf-8"))
1101
+ return
1102
+
1103
+ if parsed.path == "/api/generate":
1104
+ content_len = int(self.headers.get("Content-Length", 0))
1105
+ body = self.rfile.read(content_len).decode("utf-8")
1106
+ try:
1107
+ data = json.loads(body)
1108
+ repo_str = data.get("repo", ".")
1109
+ since_str = data.get("since", "7d")
1110
+ audience = data.get("audience", "guide")
1111
+ llm = data.get("llm", "gemini")
1112
+ model = data.get("model", "")
1113
+ planned = data.get("next_goals", [])
1114
+
1115
+ repo_path = Path(repo_str)
1116
+ if repo_str.startswith(("http://", "https://", "git@")):
1117
+ repo_name = repo_str.rstrip("/").split("/")[-1]
1118
+ if repo_name.endswith(".git"):
1119
+ repo_name = repo_name[:-4]
1120
+ local_dir = Path("cloned_repos") / repo_name
1121
+ local_dir.parent.mkdir(parents=True, exist_ok=True)
1122
+ if not (local_dir / ".git").exists():
1123
+ subprocess.run(["git", "clone", repo_str, str(local_dir)], check=True)
1124
+ repo_path = local_dir
1125
+
1126
+ logs: list[str] = []
1127
+ log_fn = lambda m: logs.append(str(m))
1128
+
1129
+ opts = Options(
1130
+ repo=repo_path,
1131
+ since=parse_since(since_str),
1132
+ audience=audience,
1133
+ planned=planned,
1134
+ include_branches=True
1135
+ )
1136
+
1137
+ chosen_model = model or ("gemini-2.5-flash" if llm == "gemini" else ("llama3:8b" if llm == "ollama" else ""))
1138
+ summarizer = get_summarizer(llm, chosen_model)
1139
+ report_data = run_pipeline(opts, summarizer, log=log_fn)
1140
+
1141
+ reports_dir = Path("reports")
1142
+ reports_dir.mkdir(parents=True, exist_ok=True)
1143
+ clean_name = repo_path.resolve().name.lower()
1144
+ base_out = reports_dir / f"{clean_name}_report"
1145
+
1146
+ # Write files
1147
+ html_p = base_out.with_suffix(".html")
1148
+ html_p.write_text(render_html(report_data), encoding="utf-8")
1149
+ self.last_generated_files["html"] = html_p
1150
+
1151
+ pdf_p = base_out.with_suffix(".pdf")
1152
+ render_pdf(report_data, pdf_p)
1153
+ self.last_generated_files["pdf"] = pdf_p
1154
+
1155
+ md_p = base_out.with_suffix(".md")
1156
+ md_p.write_text(render_markdown(report_data), encoding="utf-8")
1157
+ self.last_generated_files["md"] = md_p
1158
+
1159
+ resp_payload = {
1160
+ "status": "success",
1161
+ "metrics": report_data.metrics,
1162
+ "logs": logs,
1163
+ "files": {
1164
+ "html": str(html_p),
1165
+ "pdf": str(pdf_p),
1166
+ "md": str(md_p)
1167
+ }
1168
+ }
1169
+ self.send_response(HTTPStatus.OK)
1170
+ self.send_header("Content-Type", "application/json")
1171
+ self.end_headers()
1172
+ self.wfile.write(json.dumps(resp_payload).encode("utf-8"))
1173
+ except Exception as exc:
1174
+ self.send_response(HTTPStatus.BAD_REQUEST)
1175
+ self.send_header("Content-Type", "application/json")
1176
+ self.end_headers()
1177
+ self.wfile.write(json.dumps({"error": str(exc)}).encode("utf-8"))
1178
+ return
1179
+
1180
+ self.send_error(HTTPStatus.NOT_FOUND, "Not found")
1181
+
1182
+
1183
+ def launch_web_server(port: int = 5000, open_browser: bool = True) -> None:
1184
+ """Launch the Web Terminal server and open the browser."""
1185
+ for p in range(port, port + 20):
1186
+ try:
1187
+ httpd = ThreadingHTTPServer(("127.0.0.1", p), WebStudioHandler)
1188
+ url = f"http://localhost:{p}"
1189
+ print(f"\n=======================================================")
1190
+ print(f" 📟 GIT2REPORT TERMINAL STUDIO RUNNING AT: {url}")
1191
+ print(f"=======================================================")
1192
+ print(" Press Ctrl+C in this terminal to stop the server.\n")
1193
+ if open_browser:
1194
+ threading.Timer(0.8, lambda: webbrowser.open(url)).start()
1195
+ httpd.serve_forever()
1196
+ return
1197
+ except OSError:
1198
+ continue
1199
+ raise RuntimeError("Could not find an available port to start the web server.")
1200
+
1201
+
1202
+ def main():
1203
+ launch_web_server()
1204
+
1205
+
1206
+ if __name__ == "__main__":
1207
+ main()