sheetdiff 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.
- sheetdiff/__init__.py +8 -0
- sheetdiff/cli.py +42 -0
- sheetdiff/config.py +127 -0
- sheetdiff/core.py +356 -0
- sheetdiff/storage.py +201 -0
- sheetdiff/templates/diff.html +511 -0
- sheetdiff/templates/index.html +325 -0
- sheetdiff/webapp.py +176 -0
- sheetdiff-0.1.0.dist-info/METADATA +190 -0
- sheetdiff-0.1.0.dist-info/RECORD +14 -0
- sheetdiff-0.1.0.dist-info/WHEEL +5 -0
- sheetdiff-0.1.0.dist-info/entry_points.txt +2 -0
- sheetdiff-0.1.0.dist-info/licenses/LICENSE +21 -0
- sheetdiff-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>xl-diff — {{ file_names | join(' vs ') }}</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #eef2e9; --panel: #fffefb; --header: #f4f7f0; --border: #cbdac5;
|
|
13
|
+
--text: #16241a; --muted: #5c6d5e; --gutter: #7c8c7c;
|
|
14
|
+
--add-bg: #e2ede4; --add-bg-strong: #bfe0cc; --add-fg: #1f5c45;
|
|
15
|
+
--del-bg: #f4e4e0; --del-bg-strong: #eec4b9; --del-fg: #7a2e2e;
|
|
16
|
+
--hunk-bg: #f1e6cf; --hunk-fg: #8a6a24; --mod-fg: #a97a2c;
|
|
17
|
+
--accent: #1f6f4d; --brass: #a97a2c; --empty-hatch: rgba(92,109,94,.07);
|
|
18
|
+
}
|
|
19
|
+
:root[data-theme="dark"] {
|
|
20
|
+
--bg: #0b120d; --panel: #101a12; --header: #142017; --border: #223a2a;
|
|
21
|
+
--text: #e9f1e6; --muted: #8fa38f; --gutter: #6f8571;
|
|
22
|
+
--add-bg: rgba(55,168,119,.15); --add-bg-strong: rgba(55,168,119,.4); --add-fg: #63cf9c;
|
|
23
|
+
--del-bg: rgba(226,147,138,.15); --del-bg-strong: rgba(226,147,138,.4); --del-fg: #e2938a;
|
|
24
|
+
--hunk-bg: rgba(216,180,99,.14); --hunk-fg: #d8b463; --mod-fg: #d8b463;
|
|
25
|
+
--accent: #37a877; --brass: #d8b463; --empty-hatch: rgba(255,255,255,.03);
|
|
26
|
+
}
|
|
27
|
+
@media (prefers-color-scheme: dark) {
|
|
28
|
+
:root:not([data-theme="light"]) {
|
|
29
|
+
--bg: #0b120d; --panel: #101a12; --header: #142017; --border: #223a2a;
|
|
30
|
+
--text: #e9f1e6; --muted: #8fa38f; --gutter: #6f8571;
|
|
31
|
+
--add-bg: rgba(55,168,119,.15); --add-bg-strong: rgba(55,168,119,.4); --add-fg: #63cf9c;
|
|
32
|
+
--del-bg: rgba(226,147,138,.15); --del-bg-strong: rgba(226,147,138,.4); --del-fg: #e2938a;
|
|
33
|
+
--hunk-bg: rgba(216,180,99,.14); --hunk-fg: #d8b463; --mod-fg: #d8b463;
|
|
34
|
+
--accent: #37a877; --brass: #d8b463; --empty-hatch: rgba(255,255,255,.03);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
* { box-sizing: border-box; }
|
|
38
|
+
html, body { font-family: 'Inter', -apple-system, Segoe UI, sans-serif; background: var(--bg); color: var(--text); margin: 0; height: 100%; }
|
|
39
|
+
body { display: flex; flex-direction: column; }
|
|
40
|
+
|
|
41
|
+
header.top {
|
|
42
|
+
position: sticky; top: 0; z-index: 30; background: var(--header); border-bottom: 1px solid var(--border);
|
|
43
|
+
padding: 10px 20px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
|
44
|
+
}
|
|
45
|
+
header.top::before {
|
|
46
|
+
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
|
47
|
+
background: linear-gradient(90deg, var(--brass), var(--accent));
|
|
48
|
+
}
|
|
49
|
+
a.back { color: var(--muted); text-decoration: none; font-size: 12.5px; font-weight: 500; }
|
|
50
|
+
a.back:hover { color: var(--accent); }
|
|
51
|
+
.filepath { font-family: 'JetBrains Mono', monospace; font-size: 12.5px; color: var(--text); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
52
|
+
.filepath .chip { display: flex; align-items: center; gap: 5px; }
|
|
53
|
+
.filepath .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
|
54
|
+
.dot.c0 { background: var(--del-fg); } .dot.c1 { background: var(--add-fg); } .dot.c2 { background: var(--brass); }
|
|
55
|
+
.dot.c3 { background: #6c6ba0; } .dot.c4 { background: #a0527c; }
|
|
56
|
+
.filepath .sep { color: var(--muted); }
|
|
57
|
+
|
|
58
|
+
.view-toggle { display: flex; gap: 2px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 2px; }
|
|
59
|
+
.view-toggle button {
|
|
60
|
+
border: none; background: transparent; color: var(--muted); padding: 6px 14px; border-radius: 6px;
|
|
61
|
+
font-size: 12.5px; font-weight: 600; cursor: pointer;
|
|
62
|
+
}
|
|
63
|
+
.view-toggle button.active { background: var(--accent); color: #fdfaf1; }
|
|
64
|
+
|
|
65
|
+
.theme-toggle { border: 1px solid var(--border); background: var(--panel); color: var(--text); border-radius: 999px; padding: 6px 12px; cursor: pointer; font-size: 13px; }
|
|
66
|
+
.stat { margin-left: auto; display: flex; align-items: center; gap: 10px; font-family: 'JetBrains Mono', monospace; font-size: 12.5px; }
|
|
67
|
+
.stat .add { color: var(--add-fg); } .stat .del { color: var(--del-fg); }
|
|
68
|
+
|
|
69
|
+
.tabs { display: flex; gap: 2px; padding: 8px 20px 0; background: var(--header); overflow-x: auto; border-bottom: 1px solid var(--border); }
|
|
70
|
+
.tab {
|
|
71
|
+
border: 1px solid transparent; background: transparent; color: var(--muted);
|
|
72
|
+
padding: 7px 14px; border-radius: 6px 6px 0 0; font-size: 12.5px; font-weight: 500;
|
|
73
|
+
cursor: pointer; white-space: nowrap; font-family: 'JetBrains Mono', monospace;
|
|
74
|
+
}
|
|
75
|
+
.tab.active { background: var(--bg); color: var(--text); border-color: var(--border); border-bottom-color: var(--bg); }
|
|
76
|
+
.tab:hover:not(.active) { color: var(--text); }
|
|
77
|
+
.tab .cnt .add { color: var(--add-fg); } .tab .cnt .del { color: var(--del-fg); }
|
|
78
|
+
.tab .sheet-badge { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 999px; margin-left: 6px; color: #fdfaf1; }
|
|
79
|
+
.sheet-badge.added { background: var(--add-fg); } .sheet-badge.removed { background: var(--del-fg); }
|
|
80
|
+
|
|
81
|
+
.pane { display: none; flex: 1; min-height: 0; flex-direction: column; }
|
|
82
|
+
.pane.active { display: flex; }
|
|
83
|
+
.view-root { display: none; flex: 1; min-height: 0; flex-direction: column; }
|
|
84
|
+
.view-root.active { display: flex; }
|
|
85
|
+
|
|
86
|
+
.panel { background: var(--panel); flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
87
|
+
.scroll { overflow: auto; flex: 1; min-height: 0; }
|
|
88
|
+
table { border-collapse: collapse; width: 100%; table-layout: fixed; }
|
|
89
|
+
|
|
90
|
+
td, th { font-family: 'JetBrains Mono', monospace; font-size: 12.5px; line-height: 20px; padding: 0 10px; white-space: pre; vertical-align: top; }
|
|
91
|
+
td.gutter { width: 44px; min-width: 44px; text-align: right; color: var(--gutter); user-select: none; padding: 0 8px; }
|
|
92
|
+
td.marker { width: 20px; min-width: 20px; text-align: center; user-select: none; font-weight: 600; }
|
|
93
|
+
td.content { width: 100%; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; }
|
|
94
|
+
|
|
95
|
+
tr.line-add td.gutter.new, tr.line-add td.marker, tr.line-add td.content { background: var(--add-bg); }
|
|
96
|
+
tr.line-add td.marker { color: var(--add-fg); }
|
|
97
|
+
tr.line-del td.gutter.old, tr.line-del td.marker, tr.line-del td.content { background: var(--del-bg); }
|
|
98
|
+
tr.line-del td.marker { color: var(--del-fg); }
|
|
99
|
+
|
|
100
|
+
span.seg { padding: 1px 2px; border-radius: 3px; }
|
|
101
|
+
tr.line-add span.seg.hl { background: var(--add-bg-strong); }
|
|
102
|
+
tr.line-del span.seg.hl { background: var(--del-bg-strong); }
|
|
103
|
+
span.col-name { color: var(--muted); }
|
|
104
|
+
|
|
105
|
+
tr.hunk td { background: var(--hunk-bg); color: var(--hunk-fg); font-weight: 500; padding-top: 3px; padding-bottom: 3px; }
|
|
106
|
+
tr.hunk td.gutter { color: var(--hunk-fg); }
|
|
107
|
+
tr.fold td { background: var(--hunk-bg); color: var(--hunk-fg); cursor: pointer; padding-top: 4px; padding-bottom: 4px; opacity: .85; }
|
|
108
|
+
tr.fold:hover td { opacity: 1; }
|
|
109
|
+
tr.fold .fold-icon { display: inline-block; margin-right: 6px; transition: transform .12s; }
|
|
110
|
+
tr.fold.expanded .fold-icon { transform: rotate(90deg); }
|
|
111
|
+
tr.hidden-row { display: none; }
|
|
112
|
+
|
|
113
|
+
.empty, .error { padding: 30px; text-align: center; color: var(--muted); font-size: 13px; }
|
|
114
|
+
.error { color: var(--del-fg); }
|
|
115
|
+
.sheet-banner { padding: 8px 16px; font-size: 12px; color: var(--muted); background: var(--hunk-bg); border-bottom: 1px solid var(--border); }
|
|
116
|
+
.sheet-banner b { color: var(--text); }
|
|
117
|
+
|
|
118
|
+
/* ============ split (VS Code style side-by-side, synced scroll) view ============ */
|
|
119
|
+
.split-sync-group { display: flex; flex: 1; min-height: 0; }
|
|
120
|
+
.split-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--border); }
|
|
121
|
+
.split-pane:last-child { border-right: none; }
|
|
122
|
+
.split-pane-head {
|
|
123
|
+
position: sticky; top: 0; z-index: 5; background: var(--header);
|
|
124
|
+
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
|
|
125
|
+
padding: 7px 10px; border-bottom: 1px solid var(--border); font-family: 'JetBrains Mono', monospace;
|
|
126
|
+
}
|
|
127
|
+
.split-pane-head.old-file { color: var(--del-fg); }
|
|
128
|
+
.split-pane-head.new-file { color: var(--add-fg); }
|
|
129
|
+
.split-scroll { overflow: auto; flex: 1; min-height: 0; }
|
|
130
|
+
|
|
131
|
+
table.split-single { border-collapse: collapse; width: 100%; min-width: max-content; }
|
|
132
|
+
table.split-single td.content { white-space: pre; width: auto; }
|
|
133
|
+
|
|
134
|
+
tr.sline-add td { background: var(--add-bg); }
|
|
135
|
+
tr.sline-add td.marker { color: var(--add-fg); }
|
|
136
|
+
tr.sline-del td { background: var(--del-bg); }
|
|
137
|
+
tr.sline-del td.marker { color: var(--del-fg); }
|
|
138
|
+
tr.sline-mod-old td { background: var(--del-bg); }
|
|
139
|
+
tr.sline-mod-old td.marker { color: var(--mod-fg); }
|
|
140
|
+
tr.sline-mod-new td { background: var(--add-bg); }
|
|
141
|
+
tr.sline-mod-new td.marker { color: var(--mod-fg); }
|
|
142
|
+
tr.sline-mod-old span.seg.hl { background: var(--del-bg-strong); }
|
|
143
|
+
tr.sline-mod-new span.seg.hl { background: var(--add-bg-strong); }
|
|
144
|
+
|
|
145
|
+
td.sline-empty {
|
|
146
|
+
background: repeating-linear-gradient(135deg, transparent, transparent 7px, var(--empty-hatch) 7px, var(--empty-hatch) 14px);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
tr.sp-hunk td, tr.sp-fold td { background: var(--hunk-bg); color: var(--hunk-fg); font-weight: 500; padding-top: 3px; padding-bottom: 3px; }
|
|
150
|
+
tr.sp-fold td { cursor: pointer; padding-top: 4px; padding-bottom: 4px; opacity: .85; }
|
|
151
|
+
tr.sp-fold:hover td { opacity: 1; }
|
|
152
|
+
tr.sp-fold .fold-icon { display: inline-block; margin-right: 6px; transition: transform .12s; }
|
|
153
|
+
tr.sp-fold.expanded .fold-icon { transform: rotate(90deg); }
|
|
154
|
+
</style>
|
|
155
|
+
</head>
|
|
156
|
+
<body>
|
|
157
|
+
|
|
158
|
+
<header class="top">
|
|
159
|
+
<a class="back" href="/">← New comparison</a>
|
|
160
|
+
<div class="filepath">
|
|
161
|
+
{% for n in file_names %}
|
|
162
|
+
<span class="chip"><span class="dot c{{ loop.index0 % 5 }}"></span>{{ n }}</span>{% if not loop.last %}<span class="sep">→</span>{% endif %}
|
|
163
|
+
{% endfor %}
|
|
164
|
+
</div>
|
|
165
|
+
<div class="view-toggle">
|
|
166
|
+
<button id="btn-unified" class="active" onclick="showView('unified')">Unified</button>
|
|
167
|
+
<button id="btn-split" onclick="showView('split')">Split</button>
|
|
168
|
+
</div>
|
|
169
|
+
<button class="theme-toggle" onclick="toggleTheme()">☀️ / 🌙</button>
|
|
170
|
+
</header>
|
|
171
|
+
|
|
172
|
+
<!-- ============ UNIFIED VIEW ============ -->
|
|
173
|
+
<div class="view-root active" id="view-unified">
|
|
174
|
+
{% if multi_file %}
|
|
175
|
+
<nav class="tabs" id="pair-tabs">
|
|
176
|
+
{% for pair in pairs %}
|
|
177
|
+
<button class="tab {{ 'active' if loop.first else '' }}" onclick="showPair('{{ loop.index0 }}')">{{ pair.left_name }} → {{ pair.right_name }}</button>
|
|
178
|
+
{% endfor %}
|
|
179
|
+
</nav>
|
|
180
|
+
{% endif %}
|
|
181
|
+
|
|
182
|
+
{% for pair in pairs %}
|
|
183
|
+
<div class="pane {{ 'active' if loop.first else '' }}" id="pair-{{ loop.index0 }}">
|
|
184
|
+
<nav class="tabs">
|
|
185
|
+
{% for sheet_name, d in pair.sheets.items() %}
|
|
186
|
+
<button class="tab {{ 'active' if loop.first else '' }}" onclick="showSheet(this, '{{ loop.index0 }}')">
|
|
187
|
+
{{ sheet_name }}
|
|
188
|
+
{% if d.sheet_status == 'added' %}<span class="sheet-badge added">NEW</span>{% endif %}
|
|
189
|
+
{% if d.sheet_status == 'removed' %}<span class="sheet-badge removed">DELETED</span>{% endif %}
|
|
190
|
+
{% if d.added or d.removed or d.modified %}
|
|
191
|
+
<span class="cnt">
|
|
192
|
+
{% if d.added or d.modified %}<span class="add">+{{ d.added + d.modified }}</span>{% endif %}
|
|
193
|
+
{% if d.removed or d.modified %}<span class="del">-{{ d.removed + d.modified }}</span>{% endif %}
|
|
194
|
+
</span>
|
|
195
|
+
{% endif %}
|
|
196
|
+
</button>
|
|
197
|
+
{% endfor %}
|
|
198
|
+
</nav>
|
|
199
|
+
|
|
200
|
+
{% for sheet_name, d in pair.sheets.items() %}
|
|
201
|
+
<section class="pane {{ 'active' if loop.first else '' }}" data-sheet-pane>
|
|
202
|
+
<div class="panel">
|
|
203
|
+
{% if d.sheet_status == 'added' %}
|
|
204
|
+
<div class="sheet-banner">Sheet <b>{{ sheet_name }}</b> was added in {{ pair.right_name }}.</div>
|
|
205
|
+
{% elif d.sheet_status == 'removed' %}
|
|
206
|
+
<div class="sheet-banner">Sheet <b>{{ sheet_name }}</b> was deleted (present only in {{ pair.left_name }}).</div>
|
|
207
|
+
{% endif %}
|
|
208
|
+
{% if d.get('error') %}
|
|
209
|
+
<div class="error">Error: {{ d.error }}</div>
|
|
210
|
+
{% elif d.rows|length == 0 %}
|
|
211
|
+
<div class="empty">No rows in this sheet.</div>
|
|
212
|
+
{% else %}
|
|
213
|
+
<div class="scroll">
|
|
214
|
+
<table><tbody>
|
|
215
|
+
{% for item in d.diff_items %}
|
|
216
|
+
{% if item.type == 'hunk' %}
|
|
217
|
+
<tr class="hunk">
|
|
218
|
+
<td class="gutter"></td><td class="gutter"></td><td class="marker"></td>
|
|
219
|
+
<td class="content">@@ -{{ item.old_line }} +{{ item.new_line }} @@</td>
|
|
220
|
+
</tr>
|
|
221
|
+
{% elif item.type == 'fold' %}
|
|
222
|
+
<tr class="fold" onclick="toggleFold(this)">
|
|
223
|
+
<td class="gutter"></td><td class="gutter"></td><td class="marker"></td>
|
|
224
|
+
<td class="content"><span class="fold-icon">▸</span>{{ item.count }} unchanged row{{ 's' if item.count != 1 else '' }}</td>
|
|
225
|
+
</tr>
|
|
226
|
+
{% for row in item.rows %}
|
|
227
|
+
<tr class="hidden-row">
|
|
228
|
+
<td class="gutter old">{{ row.old_line or '' }}</td>
|
|
229
|
+
<td class="gutter new">{{ row.new_line or '' }}</td>
|
|
230
|
+
<td class="marker"></td>
|
|
231
|
+
<td class="content">{% for c in d.columns %}<span class="seg"><span class="col-name">{{ c }}:</span> {{ (row.left or row.right).get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
232
|
+
</tr>
|
|
233
|
+
{% endfor %}
|
|
234
|
+
{% elif item.type == 'row' %}
|
|
235
|
+
{% set row = item.row %}
|
|
236
|
+
{% if row.status == 'unchanged' %}
|
|
237
|
+
<tr>
|
|
238
|
+
<td class="gutter old">{{ row.old_line }}</td>
|
|
239
|
+
<td class="gutter new">{{ row.new_line }}</td>
|
|
240
|
+
<td class="marker"></td>
|
|
241
|
+
<td class="content">{% for c in d.columns %}<span class="seg"><span class="col-name">{{ c }}:</span> {{ row.left.get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
242
|
+
</tr>
|
|
243
|
+
{% elif row.status == 'removed' %}
|
|
244
|
+
<tr class="line-del">
|
|
245
|
+
<td class="gutter old">{{ row.old_line }}</td>
|
|
246
|
+
<td class="gutter new"></td>
|
|
247
|
+
<td class="marker">-</td>
|
|
248
|
+
<td class="content">{% for c in d.columns %}<span class="seg"><span class="col-name">{{ c }}:</span> {{ row.left.get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
249
|
+
</tr>
|
|
250
|
+
{% elif row.status == 'added' %}
|
|
251
|
+
<tr class="line-add">
|
|
252
|
+
<td class="gutter old"></td>
|
|
253
|
+
<td class="gutter new">{{ row.new_line }}</td>
|
|
254
|
+
<td class="marker">+</td>
|
|
255
|
+
<td class="content">{% for c in d.columns %}<span class="seg"><span class="col-name">{{ c }}:</span> {{ row.right.get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
256
|
+
</tr>
|
|
257
|
+
{% else %}
|
|
258
|
+
<tr class="line-del">
|
|
259
|
+
<td class="gutter old">{{ row.old_line }}</td>
|
|
260
|
+
<td class="gutter new"></td>
|
|
261
|
+
<td class="marker">-</td>
|
|
262
|
+
<td class="content">{% for c in d.columns %}<span class="seg{{ ' hl' if c in row.changed else '' }}"><span class="col-name">{{ c }}:</span> {{ row.left.get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
263
|
+
</tr>
|
|
264
|
+
<tr class="line-add">
|
|
265
|
+
<td class="gutter old"></td>
|
|
266
|
+
<td class="gutter new">{{ row.new_line }}</td>
|
|
267
|
+
<td class="marker">+</td>
|
|
268
|
+
<td class="content">{% for c in d.columns %}<span class="seg{{ ' hl' if c in row.changed else '' }}"><span class="col-name">{{ c }}:</span> {{ row.right.get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
269
|
+
</tr>
|
|
270
|
+
{% endif %}
|
|
271
|
+
{% endif %}
|
|
272
|
+
{% endfor %}
|
|
273
|
+
</tbody></table>
|
|
274
|
+
</div>
|
|
275
|
+
{% endif %}
|
|
276
|
+
</div>
|
|
277
|
+
</section>
|
|
278
|
+
{% endfor %}
|
|
279
|
+
</div>
|
|
280
|
+
{% endfor %}
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
<!-- ============ SPLIT VIEW (VS Code style side-by-side) ============ -->
|
|
284
|
+
<div class="view-root" id="view-split">
|
|
285
|
+
{% if multi_file %}
|
|
286
|
+
<nav class="tabs" id="split-pair-tabs">
|
|
287
|
+
{% for pair in pairs %}
|
|
288
|
+
<button class="tab {{ 'active' if loop.first else '' }}" onclick="showSplitPair('{{ loop.index0 }}')">{{ pair.left_name }} → {{ pair.right_name }}</button>
|
|
289
|
+
{% endfor %}
|
|
290
|
+
</nav>
|
|
291
|
+
{% endif %}
|
|
292
|
+
|
|
293
|
+
{% for pair in pairs %}
|
|
294
|
+
<div class="pane {{ 'active' if loop.first else '' }}" id="split-pair-{{ loop.index0 }}">
|
|
295
|
+
<nav class="tabs">
|
|
296
|
+
{% for sheet_name, d in pair.sheets.items() %}
|
|
297
|
+
<button class="tab {{ 'active' if loop.first else '' }}" onclick="showSheet(this, '{{ loop.index0 }}')">
|
|
298
|
+
{{ sheet_name }}
|
|
299
|
+
{% if d.sheet_status == 'added' %}<span class="sheet-badge added">NEW</span>{% endif %}
|
|
300
|
+
{% if d.sheet_status == 'removed' %}<span class="sheet-badge removed">DELETED</span>{% endif %}
|
|
301
|
+
{% if d.added or d.removed or d.modified %}
|
|
302
|
+
<span class="cnt">
|
|
303
|
+
{% if d.added or d.modified %}<span class="add">+{{ d.added + d.modified }}</span>{% endif %}
|
|
304
|
+
{% if d.removed or d.modified %}<span class="del">-{{ d.removed + d.modified }}</span>{% endif %}
|
|
305
|
+
</span>
|
|
306
|
+
{% endif %}
|
|
307
|
+
</button>
|
|
308
|
+
{% endfor %}
|
|
309
|
+
</nav>
|
|
310
|
+
|
|
311
|
+
{% for sheet_name, d in pair.sheets.items() %}
|
|
312
|
+
<section class="pane {{ 'active' if loop.first else '' }}" data-sheet-pane>
|
|
313
|
+
<div class="panel">
|
|
314
|
+
{% if d.sheet_status == 'added' %}
|
|
315
|
+
<div class="sheet-banner">Sheet <b>{{ sheet_name }}</b> was added in {{ pair.right_name }}.</div>
|
|
316
|
+
{% elif d.sheet_status == 'removed' %}
|
|
317
|
+
<div class="sheet-banner">Sheet <b>{{ sheet_name }}</b> was deleted (present only in {{ pair.left_name }}).</div>
|
|
318
|
+
{% endif %}
|
|
319
|
+
{% if d.get('error') %}
|
|
320
|
+
<div class="error">Error: {{ d.error }}</div>
|
|
321
|
+
{% elif d.rows|length == 0 %}
|
|
322
|
+
<div class="empty">No rows in this sheet.</div>
|
|
323
|
+
{% else %}
|
|
324
|
+
<div class="split-sync-group">
|
|
325
|
+
<div class="split-pane">
|
|
326
|
+
<div class="split-pane-head old-file">{{ pair.left_name }}</div>
|
|
327
|
+
<div class="split-scroll">
|
|
328
|
+
<table class="split-single"><tbody>
|
|
329
|
+
{% for item in d.diff_items %}
|
|
330
|
+
{% if item.type == 'hunk' %}
|
|
331
|
+
<tr class="sp-hunk" data-fold-idx="{{ loop.index0 }}">
|
|
332
|
+
<td class="gutter"></td><td class="marker"></td>
|
|
333
|
+
<td class="content">@@ -{{ item.old_line }} @@</td>
|
|
334
|
+
</tr>
|
|
335
|
+
{% elif item.type == 'fold' %}
|
|
336
|
+
<tr class="sp-fold" data-fold-idx="{{ loop.index0 }}" onclick="toggleSplitFold(this)">
|
|
337
|
+
<td class="gutter"></td><td class="marker"></td>
|
|
338
|
+
<td class="content"><span class="fold-icon">▸</span>{{ item.count }} unchanged row{{ 's' if item.count != 1 else '' }}</td>
|
|
339
|
+
</tr>
|
|
340
|
+
{% for row in item.rows %}
|
|
341
|
+
<tr class="hidden-row" data-fold-idx="{{ loop.index0 }}">
|
|
342
|
+
<td class="gutter">{{ row.old_line or '' }}</td><td class="marker"></td>
|
|
343
|
+
<td class="content">{% for c in d.columns %}<span class="seg"><span class="col-name">{{ c }}:</span> {{ (row.left or row.right).get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
344
|
+
</tr>
|
|
345
|
+
{% endfor %}
|
|
346
|
+
{% elif item.type == 'row' %}
|
|
347
|
+
{% set row = item.row %}
|
|
348
|
+
{% if row.status == 'added' %}
|
|
349
|
+
<tr><td class="gutter"></td><td class="marker"></td><td class="content sline-empty"></td></tr>
|
|
350
|
+
{% elif row.status == 'removed' %}
|
|
351
|
+
<tr class="sline-del">
|
|
352
|
+
<td class="gutter">{{ row.old_line }}</td><td class="marker">-</td>
|
|
353
|
+
<td class="content">{% for c in d.columns %}<span class="seg"><span class="col-name">{{ c }}:</span> {{ row.left.get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
354
|
+
</tr>
|
|
355
|
+
{% elif row.status == 'modified' %}
|
|
356
|
+
<tr class="sline-mod-old">
|
|
357
|
+
<td class="gutter">{{ row.old_line }}</td><td class="marker">-</td>
|
|
358
|
+
<td class="content">{% for c in d.columns %}<span class="seg{{ ' hl' if c in row.changed else '' }}"><span class="col-name">{{ c }}:</span> {{ row.left.get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
359
|
+
</tr>
|
|
360
|
+
{% else %}
|
|
361
|
+
<tr>
|
|
362
|
+
<td class="gutter">{{ row.old_line }}</td><td class="marker"></td>
|
|
363
|
+
<td class="content">{% for c in d.columns %}<span class="seg"><span class="col-name">{{ c }}:</span> {{ row.left.get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
364
|
+
</tr>
|
|
365
|
+
{% endif %}
|
|
366
|
+
{% endif %}
|
|
367
|
+
{% endfor %}
|
|
368
|
+
</tbody></table>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
<div class="split-pane">
|
|
372
|
+
<div class="split-pane-head new-file">{{ pair.right_name }}</div>
|
|
373
|
+
<div class="split-scroll">
|
|
374
|
+
<table class="split-single"><tbody>
|
|
375
|
+
{% for item in d.diff_items %}
|
|
376
|
+
{% if item.type == 'hunk' %}
|
|
377
|
+
<tr class="sp-hunk" data-fold-idx="{{ loop.index0 }}">
|
|
378
|
+
<td class="gutter"></td><td class="marker"></td>
|
|
379
|
+
<td class="content">@@ +{{ item.new_line }} @@</td>
|
|
380
|
+
</tr>
|
|
381
|
+
{% elif item.type == 'fold' %}
|
|
382
|
+
<tr class="sp-fold" data-fold-idx="{{ loop.index0 }}" onclick="toggleSplitFold(this)">
|
|
383
|
+
<td class="gutter"></td><td class="marker"></td>
|
|
384
|
+
<td class="content"><span class="fold-icon">▸</span>{{ item.count }} unchanged row{{ 's' if item.count != 1 else '' }}</td>
|
|
385
|
+
</tr>
|
|
386
|
+
{% for row in item.rows %}
|
|
387
|
+
<tr class="hidden-row" data-fold-idx="{{ loop.index0 }}">
|
|
388
|
+
<td class="gutter">{{ row.new_line or '' }}</td><td class="marker"></td>
|
|
389
|
+
<td class="content">{% for c in d.columns %}<span class="seg"><span class="col-name">{{ c }}:</span> {{ (row.left or row.right).get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
390
|
+
</tr>
|
|
391
|
+
{% endfor %}
|
|
392
|
+
{% elif item.type == 'row' %}
|
|
393
|
+
{% set row = item.row %}
|
|
394
|
+
{% if row.status == 'removed' %}
|
|
395
|
+
<tr><td class="gutter"></td><td class="marker"></td><td class="content sline-empty"></td></tr>
|
|
396
|
+
{% elif row.status == 'added' %}
|
|
397
|
+
<tr class="sline-add">
|
|
398
|
+
<td class="gutter">{{ row.new_line }}</td><td class="marker">+</td>
|
|
399
|
+
<td class="content">{% for c in d.columns %}<span class="seg"><span class="col-name">{{ c }}:</span> {{ row.right.get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
400
|
+
</tr>
|
|
401
|
+
{% elif row.status == 'modified' %}
|
|
402
|
+
<tr class="sline-mod-new">
|
|
403
|
+
<td class="gutter">{{ row.new_line }}</td><td class="marker">+</td>
|
|
404
|
+
<td class="content">{% for c in d.columns %}<span class="seg{{ ' hl' if c in row.changed else '' }}"><span class="col-name">{{ c }}:</span> {{ row.right.get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
405
|
+
</tr>
|
|
406
|
+
{% else %}
|
|
407
|
+
<tr>
|
|
408
|
+
<td class="gutter">{{ row.new_line }}</td><td class="marker"></td>
|
|
409
|
+
<td class="content">{% for c in d.columns %}<span class="seg"><span class="col-name">{{ c }}:</span> {{ row.right.get(c, '') }}</span>{% if not loop.last %} {% endif %}{% endfor %}</td>
|
|
410
|
+
</tr>
|
|
411
|
+
{% endif %}
|
|
412
|
+
{% endif %}
|
|
413
|
+
{% endfor %}
|
|
414
|
+
</tbody></table>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
{% endif %}
|
|
419
|
+
</div>
|
|
420
|
+
</section>
|
|
421
|
+
{% endfor %}
|
|
422
|
+
</div>
|
|
423
|
+
{% endfor %}
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<script>
|
|
427
|
+
function showView(name) {
|
|
428
|
+
document.getElementById('view-unified').classList.toggle('active', name === 'unified');
|
|
429
|
+
document.getElementById('view-split').classList.toggle('active', name === 'split');
|
|
430
|
+
document.getElementById('btn-unified').classList.toggle('active', name === 'unified');
|
|
431
|
+
document.getElementById('btn-split').classList.toggle('active', name === 'split');
|
|
432
|
+
}
|
|
433
|
+
function showPair(idx) {
|
|
434
|
+
document.querySelectorAll('#pair-tabs .tab').forEach(function (el, i) { el.classList.toggle('active', String(i) === String(idx)); });
|
|
435
|
+
document.querySelectorAll('#view-unified > .pane').forEach(function (el) { el.classList.remove('active'); });
|
|
436
|
+
document.getElementById('pair-' + idx).classList.add('active');
|
|
437
|
+
}
|
|
438
|
+
function showSplitPair(idx) {
|
|
439
|
+
document.querySelectorAll('#split-pair-tabs .tab').forEach(function (el, i) { el.classList.toggle('active', String(i) === String(idx)); });
|
|
440
|
+
document.querySelectorAll('#view-split > .pane').forEach(function (el) { el.classList.remove('active'); });
|
|
441
|
+
document.getElementById('split-pair-' + idx).classList.add('active');
|
|
442
|
+
}
|
|
443
|
+
function showSheet(btn, idx) {
|
|
444
|
+
const nav = btn.closest('nav');
|
|
445
|
+
const panesContainer = nav.parentElement;
|
|
446
|
+
nav.querySelectorAll('.tab').forEach(function (el) { el.classList.remove('active'); });
|
|
447
|
+
btn.classList.add('active');
|
|
448
|
+
panesContainer.querySelectorAll('[data-sheet-pane]').forEach(function (el, i) {
|
|
449
|
+
el.classList.toggle('active', String(i) === String(idx));
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
function toggleFold(el) {
|
|
453
|
+
el.classList.toggle('expanded');
|
|
454
|
+
var row = el.nextElementSibling;
|
|
455
|
+
while (row && row.classList.contains('hidden-row')) {
|
|
456
|
+
row.style.display = row.style.display === 'table-row' ? 'none' : 'table-row';
|
|
457
|
+
row = row.nextElementSibling;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function toggleSplitFold(el) {
|
|
461
|
+
const willExpand = !el.classList.contains('expanded');
|
|
462
|
+
const idx = el.getAttribute('data-fold-idx');
|
|
463
|
+
const group = el.closest('.split-sync-group');
|
|
464
|
+
group.querySelectorAll('.sp-fold[data-fold-idx="' + idx + '"]').forEach(function (foldEl) {
|
|
465
|
+
foldEl.classList.toggle('expanded', willExpand);
|
|
466
|
+
var row = foldEl.nextElementSibling;
|
|
467
|
+
while (row && row.getAttribute('data-fold-idx') === idx) {
|
|
468
|
+
row.style.display = willExpand ? 'table-row' : 'none';
|
|
469
|
+
row = row.nextElementSibling;
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
function syncScrollPanes(a, b) {
|
|
474
|
+
let guardA = false, guardB = false;
|
|
475
|
+
a.addEventListener('scroll', function () {
|
|
476
|
+
if (guardA) { guardA = false; return; }
|
|
477
|
+
guardB = true;
|
|
478
|
+
b.scrollLeft = a.scrollLeft;
|
|
479
|
+
b.scrollTop = a.scrollTop;
|
|
480
|
+
});
|
|
481
|
+
b.addEventListener('scroll', function () {
|
|
482
|
+
if (guardB) { guardB = false; return; }
|
|
483
|
+
guardA = true;
|
|
484
|
+
a.scrollLeft = b.scrollLeft;
|
|
485
|
+
a.scrollTop = b.scrollTop;
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
document.querySelectorAll('.split-sync-group').forEach(function (group) {
|
|
489
|
+
const scrolls = group.querySelectorAll(':scope > .split-pane > .split-scroll');
|
|
490
|
+
if (scrolls.length === 2) syncScrollPanes(scrolls[0], scrolls[1]);
|
|
491
|
+
});
|
|
492
|
+
function applyTheme(t) {
|
|
493
|
+
if (t) document.documentElement.setAttribute('data-theme', t);
|
|
494
|
+
else document.documentElement.removeAttribute('data-theme');
|
|
495
|
+
}
|
|
496
|
+
function toggleTheme() {
|
|
497
|
+
const current = document.documentElement.getAttribute('data-theme') ||
|
|
498
|
+
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
499
|
+
const next = current === 'dark' ? 'light' : 'dark';
|
|
500
|
+
try { localStorage.setItem('xldiff-theme', next); } catch (e) {}
|
|
501
|
+
applyTheme(next);
|
|
502
|
+
}
|
|
503
|
+
(function () {
|
|
504
|
+
try {
|
|
505
|
+
const saved = localStorage.getItem('xldiff-theme');
|
|
506
|
+
if (saved) applyTheme(saved);
|
|
507
|
+
} catch (e) {}
|
|
508
|
+
})();
|
|
509
|
+
</script>
|
|
510
|
+
</body>
|
|
511
|
+
</html>
|