lambda-watcher 0.2.1__tar.gz → 0.3.0__tar.gz

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.
Files changed (55) hide show
  1. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/PKG-INFO +1 -1
  2. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/pyproject.toml +1 -1
  3. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/__init__.py +1 -1
  4. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/diffing/render_html.py +427 -97
  5. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher.egg-info/PKG-INFO +1 -1
  6. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_diff.py +2 -2
  7. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_render_html.py +129 -1
  8. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/LICENSE +0 -0
  9. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/README.md +0 -0
  10. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/setup.cfg +0 -0
  11. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/__main__.py +0 -0
  12. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/analysis/__init__.py +0 -0
  13. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/analysis/deps.py +0 -0
  14. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/analysis/envvars.py +0 -0
  15. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/analysis/handler.py +0 -0
  16. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/analysis/inventory.py +0 -0
  17. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/analysis/runtime.py +0 -0
  18. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/analysis/secrets.py +0 -0
  19. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/analysis/services.py +0 -0
  20. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/cli.py +0 -0
  21. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/config.py +0 -0
  22. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/db.py +0 -0
  23. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/diffing/__init__.py +0 -0
  24. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/diffing/build.py +0 -0
  25. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/diffing/compare.py +0 -0
  26. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/diffing/highlight.py +0 -0
  27. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/diffing/icons.py +0 -0
  28. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/diffing/intraline.py +0 -0
  29. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/diffing/render_text.py +0 -0
  30. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/extract.py +0 -0
  31. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/gitmirror.py +0 -0
  32. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/identify.py +0 -0
  33. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/ingest.py +0 -0
  34. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/notify.py +0 -0
  35. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/reindex.py +0 -0
  36. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/service.py +0 -0
  37. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/store.py +0 -0
  38. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/templates.py +0 -0
  39. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/utils.py +0 -0
  40. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher/watcher.py +0 -0
  41. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher.egg-info/SOURCES.txt +0 -0
  42. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher.egg-info/dependency_links.txt +0 -0
  43. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher.egg-info/entry_points.txt +0 -0
  44. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher.egg-info/requires.txt +0 -0
  45. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/src/lambda_watcher.egg-info/top_level.txt +0 -0
  46. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_analysis.py +0 -0
  47. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_cli.py +0 -0
  48. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_docs.py +0 -0
  49. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_extract.py +0 -0
  50. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_identify.py +0 -0
  51. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_ingest.py +0 -0
  52. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_no_window.py +0 -0
  53. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_service.py +0 -0
  54. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_utils.py +0 -0
  55. {lambda_watcher-0.2.1 → lambda_watcher-0.3.0}/tests/test_watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-watcher
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions.
5
5
  Author: Lambda Watcher contributors
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lambda-watcher"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,4 +1,4 @@
1
1
  """Watch a downloads folder for AWS Lambda deployment packages and version them."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.0"
4
4
  __all__ = ["__version__"]
@@ -130,44 +130,52 @@ table.grid td.label { width: 210px; color: var(--muted); }
130
130
  .tok.removed { background: var(--del-bg); border-color: var(--del-gutter); color: var(--del-fg); }
131
131
 
132
132
  /* ---- toolbar --------------------------------------------------------- */
133
- .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px;
134
- position: sticky; top: 0; background: var(--bg); padding: 10px 0; z-index: 5;
133
+ .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 10px;
134
+ position: sticky; top: 0; background: var(--bg); padding: 10px 0; z-index: 6;
135
135
  box-shadow: 0 1px 0 var(--border); }
136
136
  .toolbar #shown-count { margin-left: auto; white-space: nowrap; flex: 0 0 auto; color: var(--faint); }
137
- .toolbar input[type=search] { flex: 1 1 220px; min-width: 140px; padding: 6px 10px; border-radius: 6px;
137
+ .toolbar input[type=search] { flex: 1 1 240px; min-width: 150px; padding: 7px 11px; border-radius: 7px;
138
138
  border: 1px solid var(--border); background: var(--panel); color: var(--text); font-size: 13px; }
139
- .toolbar input[type=search]:focus { outline: none; border-color: var(--accent); }
139
+ .toolbar input[type=search]:focus { outline: none; border-color: var(--accent);
140
+ box-shadow: 0 0 0 3px var(--accent-wash); }
140
141
  .toolbar label { color: var(--muted); font-size: 13px; display: inline-flex; gap: 6px;
141
142
  align-items: center; cursor: pointer; user-select: none; }
142
- .toolbar button { padding: 6px 11px; border-radius: 6px; border: 1px solid var(--border);
143
- background: var(--panel); color: var(--muted); font-size: 13px; cursor: pointer; }
144
- .toolbar button:hover { border-color: var(--faint); color: var(--text); }
145
143
 
146
- /* ---- one file -------------------------------------------------------- */
147
- details.file { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px;
144
+ /* ---- the file list --------------------------------------------------- */
145
+ /* One panel of rows rather than a stack of cards, because the list is an index
146
+ now: the diff it points at opens beside it instead of pushing the rest of
147
+ the list down the page. A row is a button, since that is what it does. */
148
+ .files { border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
148
149
  background: var(--panel); }
149
- details.file[open] { background: var(--bg); }
150
- details.file > summary { cursor: pointer; padding: 8px 12px; display: flex; gap: 10px;
151
- align-items: center; list-style: none; font-size: 13px; border-radius: 7px; }
152
- details.file > summary::-webkit-details-marker { display: none; }
153
- details.file > summary:hover { background: var(--panel); }
154
- /* The header stays put while a long file scrolls past, so the path is still
155
- readable eighty lines into its own diff. */
156
- details.file[open] > summary { position: sticky; top: var(--toolbar-h, 54px); z-index: 3;
157
- background: var(--panel);
158
- border-bottom: 1px solid var(--border); border-radius: 7px 7px 0 0; }
159
- summary .path { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
160
- summary .path .p { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
150
+ .file { border-top: 1px solid var(--rule); }
151
+ /* Transparent rather than absent so every row is the same height, whichever
152
+ one the filter left at the top. */
153
+ .file:first-child, .file.first-shown { border-top-color: transparent; }
154
+ .row { display: flex; gap: 10px; align-items: center; width: 100%; padding: 9px 14px;
155
+ font: inherit; color: var(--text); text-align: left; background: none; border: 0;
156
+ cursor: pointer; }
157
+ .row:hover { background: var(--sunken); }
158
+ .row:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
159
+ /* The chevron points where the diff will appear: to the side, not downwards.
160
+ Drawn in CSS because a vendored diff runs to thousands of rows, and each one
161
+ would otherwise carry its own copy of the glyph. */
162
+ .row::after { content: ""; flex: 0 0 auto; width: 6px; height: 6px; margin-left: 2px;
163
+ border: 1.6px solid var(--faint); border-left: 0; border-bottom: 0;
164
+ transform: rotate(45deg); transition: transform .15s ease, border-color .15s ease; }
165
+ .row:hover::after { border-color: var(--accent); transform: translateX(2px) rotate(45deg); }
166
+ .file.active .row { background: var(--accent-wash); box-shadow: inset 3px 0 0 var(--accent); }
167
+ .file.active .row::after { border-color: var(--accent); transform: translateX(2px) rotate(45deg); }
168
+ .row .path { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
169
+ .row .path .p { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
161
170
  /* The tint hugs the changed part exactly — padding here would open a gap in
162
171
  the middle of a path and read as though the name contained a space. */
163
- summary .path .ren { background: var(--sunken); border-radius: 3px; }
164
- summary .path .was { color: var(--faint); }
165
- summary .stat-line { font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap;
172
+ .path .ren { background: var(--sunken); border-radius: 3px; }
173
+ .path .was { color: var(--faint); }
174
+ .stat-line { font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap;
166
175
  display: flex; gap: 8px; align-items: baseline; color: var(--faint); }
167
176
 
168
177
  /* ---- the diff itself ------------------------------------------------- */
169
178
  .diff { overflow-x: auto; border-top: 1px solid var(--border); }
170
- details.file[open] > .diff { border-top: none; }
171
179
  .diff table { border-collapse: collapse; width: 100%; font-family: var(--mono);
172
180
  font-size: 12.5px; line-height: 1.5; }
173
181
  .diff td { padding: 0 8px; white-space: pre; vertical-align: top; }
@@ -224,49 +232,273 @@ footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--border
224
232
  .moved-list { margin: 0; padding: 10px 16px 12px 34px; list-style: disc;
225
233
  color: var(--muted); font-size: 12px; line-height: 1.9; }
226
234
  .moved-list .hint { color: var(--faint); }
235
+ /* ---- the sheet ------------------------------------------------------- */
236
+ /* Where the diff lands when a row is clicked. Code wants width, and a block
237
+ that opened downwards spent the page's widest dimension on the file list it
238
+ had just pushed out of view; this keeps the list where it was, so the next
239
+ file is one click away rather than one scroll back.
240
+
241
+ A wide window docks the sheet and the page makes room beside it. A narrow
242
+ one slides it over the page with a scrim, because a laptop in portrait has
243
+ no room to dock and half a diff is worse than a covered list. */
244
+ :root { --sheet-w: 760px; }
245
+ .scrim { position: fixed; inset: 0; z-index: 30; background: rgba(8, 13, 20, .44);
246
+ opacity: 0; visibility: hidden; transition: opacity .26s ease, visibility 0s linear .26s; }
247
+ body.sheet-open .scrim { opacity: 1; visibility: visible; transition: opacity .26s ease; }
248
+ .sheet { position: fixed; top: 0; right: 0; bottom: 0; z-index: 40;
249
+ width: min(100%, var(--sheet-w)); display: flex; flex-direction: column;
250
+ background: var(--bg); border-left: 1px solid var(--border);
251
+ box-shadow: -24px 0 60px -30px rgba(6, 11, 18, .5);
252
+ /* `visibility` rather than `display`, so the sheet can animate out and still
253
+ leave nothing behind for the keyboard to land on while it is shut. It turns
254
+ visible on the same frame it is asked to — a transition would leave it
255
+ unfocusable for the length of the slide, which is exactly when the script
256
+ is moving focus into it — and back to hidden only once the slide is over. */
257
+ transform: translateX(100%); visibility: hidden;
258
+ transition: transform .26s cubic-bezier(.22, .61, .36, 1), visibility 0s linear .26s; }
259
+ body.sheet-open .sheet { transform: none; visibility: visible;
260
+ transition: transform .26s cubic-bezier(.22, .61, .36, 1); }
261
+ .sheet-head { padding: 12px 14px 11px; border-bottom: 1px solid var(--border);
262
+ background: var(--panel); display: flex; flex-direction: column; gap: 7px; }
263
+ .sheet-bar { display: flex; align-items: center; gap: 10px; }
264
+ .sheet-title { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
265
+ .sheet-title .path { display: flex; align-items: center; gap: 8px; min-width: 0; }
266
+ .sheet-title .p { font-family: var(--mono); font-size: 13px; font-weight: 600;
267
+ overflow-wrap: anywhere; }
268
+ /* Wrapping rather than clipping: a file whose counts are a sentence — "missing
269
+ from the archive" — is exactly the one whose reader needs to read them. */
270
+ .sheet-sub { display: flex; align-items: baseline; gap: 12px; min-height: 18px;
271
+ flex-wrap: wrap; color: var(--faint); font-size: 12px; }
272
+ .sheet-sub .ver { margin-left: auto; font-family: var(--mono); white-space: nowrap; }
273
+ .sheet-nav { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; }
274
+ .sheet-nav .pos { color: var(--faint); font-size: 12px; padding: 0 3px;
275
+ font-variant-numeric: tabular-nums; white-space: nowrap; }
276
+ .iconbtn { display: inline-flex; align-items: center; justify-content: center;
277
+ width: 28px; height: 28px; padding: 0; border-radius: 7px; border: 1px solid transparent;
278
+ background: none; color: var(--muted); cursor: pointer; }
279
+ .iconbtn:hover:not(:disabled) { background: var(--sunken); border-color: var(--border);
280
+ color: var(--text); }
281
+ .iconbtn:disabled { opacity: .3; cursor: default; }
282
+ .iconbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
283
+ .iconbtn svg { width: 16px; height: 16px; fill: none; stroke: currentColor;
284
+ stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
285
+ .iconbtn.close { margin-left: 5px; }
286
+ .sheet-body { flex: 1; overflow: auto; overscroll-behavior: contain; }
287
+ .sheet-body:focus { outline: none; }
288
+ /* The panel's own top rule would double the header's. */
289
+ .sheet-body > .body > :first-child { border-top: none; }
290
+ /* One file wants sixty columns and the next wants two hundred, and which one
291
+ is on screen is not something the page can know — so the edge is draggable
292
+ wherever there is a pointer and room to dock. */
293
+ .grip { position: absolute; left: 0; top: 0; bottom: 0; width: 11px; display: none;
294
+ cursor: col-resize; }
295
+ .grip::before { content: ""; position: absolute; left: 4px; top: 50%; width: 3px; height: 44px;
296
+ margin-top: -22px; border-radius: 3px; background: var(--border); transition: background .15s; }
297
+ .grip:hover::before, body.dragging .grip::before { background: var(--accent); }
298
+ body.dragging { user-select: none; cursor: col-resize; }
299
+ body.dragging, body.dragging .sheet { transition: none; }
300
+
301
+ @media (min-width: 1280px) {
302
+ :root { --sheet-w: min(50vw, 960px); }
303
+ .scrim { display: none; }
304
+ .grip { display: block; }
305
+ body { transition: padding-right .26s cubic-bezier(.22, .61, .36, 1); }
306
+ body.sheet-open { padding-right: min(var(--sheet-w), 76vw); }
307
+ }
308
+ /* Nothing to dock into: the sheet is over the page, so the page stops scrolling
309
+ underneath it. */
310
+ @media (max-width: 1279.98px) {
311
+ body.sheet-open { overflow: hidden; }
312
+ }
313
+ @media (prefers-reduced-motion: reduce) {
314
+ body, .sheet, .scrim, .row::after { transition: none; }
315
+ }
316
+ /* On paper there is no clicking, so every diff is printed under its own row and
317
+ the chrome that only answers a pointer is left out. */
318
+ @media print {
319
+ .toolbar, .scrim, .sheet-nav, .grip { display: none !important; }
320
+ .sheet { position: static; transform: none; visibility: visible; width: auto;
321
+ border-left: 0; box-shadow: none; }
322
+ body.sheet-open { padding-right: 0; overflow: visible; }
323
+ .file .body[hidden] { display: block; }
324
+ }
227
325
  .hidden { display: none !important; }
228
326
  """
229
327
 
328
+ #: What the page does once it is open: narrow the list, and move one file's diff
329
+ #: into the sheet and back out again. Moving rather than copying is the whole
330
+ #: design — the diff exists once, so the sheet cannot drift from the row, and
331
+ #: closing it leaves the page exactly as it was rendered. One ``<script>`` at the
332
+ #: end of the body, so everything it binds to is already parsed.
230
333
  JS = """
231
334
  (function () {
232
335
  var search = document.getElementById('filter');
233
336
  var vendorToggle = document.getElementById('vendor');
234
- var files = Array.prototype.slice.call(document.querySelectorAll('details.file'));
337
+ var counter = document.getElementById('shown-count');
338
+ var files = Array.prototype.slice.call(document.querySelectorAll('.files .file'));
339
+ var scrim = document.getElementById('scrim');
340
+ var host = document.getElementById('sheet-body');
341
+ var titleBox = document.getElementById('sheet-title');
342
+ var statBox = document.getElementById('sheet-stat');
343
+ var posBox = document.getElementById('sheet-pos');
344
+ var prevBtn = document.getElementById('sheet-prev');
345
+ var nextBtn = document.getElementById('sheet-next');
346
+ var closeBtn = document.getElementById('sheet-close');
347
+ var grip = document.getElementById('sheet-grip');
348
+ var openFile = null; // whose diff is sitting in the sheet right now
349
+ var opener = null; // the row that put it there, to hand focus back to
350
+
351
+ function listed() {
352
+ return files.filter(function (el) { return !el.classList.contains('hidden'); });
353
+ }
354
+
355
+ function empty(node) {
356
+ while (node && node.firstChild) { node.removeChild(node.firstChild); }
357
+ }
358
+
359
+ // The sheet header says the same things the row does, so it is built from a
360
+ // copy of the row rather than from a second set of markup on every file.
361
+ function copyInto(node, source) {
362
+ empty(node);
363
+ if (node && source) { node.appendChild(source.cloneNode(true)); }
364
+ }
365
+
366
+ function position() {
367
+ var rows = listed();
368
+ var at = openFile ? rows.indexOf(openFile) : -1;
369
+ if (posBox) { posBox.textContent = at < 0 ? '' : (at + 1) + ' / ' + rows.length; }
370
+ if (prevBtn) { prevBtn.disabled = at <= 0; }
371
+ if (nextBtn) { nextBtn.disabled = at < 0 || at >= rows.length - 1; }
372
+ }
373
+
374
+ // The diff is moved into the sheet rather than copied, so this puts it back
375
+ // where it came from. One diff exists at a time, wherever it is showing.
376
+ function park() {
377
+ if (!openFile) { return; }
378
+ var body = host.firstElementChild;
379
+ if (body) { body.hidden = true; openFile.appendChild(body); }
380
+ openFile.classList.remove('active');
381
+ var row = openFile.querySelector('.row');
382
+ if (row) { row.setAttribute('aria-expanded', 'false'); }
383
+ openFile = null;
384
+ }
385
+
386
+ function show(file, takeFocus) {
387
+ var row = file && file.querySelector('.row');
388
+ if (!row || !host) { return; }
389
+ park();
390
+ openFile = file;
391
+ file.classList.add('active');
392
+ row.setAttribute('aria-expanded', 'true');
393
+ var body = file.querySelector('.body');
394
+ if (body) { body.hidden = false; host.appendChild(body); }
395
+ host.scrollTop = 0;
396
+ empty(titleBox);
397
+ ['.chip', '.path'].forEach(function (part) {
398
+ var found = row.querySelector(part);
399
+ if (found && titleBox) { titleBox.appendChild(found.cloneNode(true)); }
400
+ });
401
+ copyInto(statBox, row.querySelector('.stat-line'));
402
+ document.body.classList.add('sheet-open');
403
+ position();
404
+ if (takeFocus) { host.focus(); }
405
+ }
406
+
407
+ function close() {
408
+ park();
409
+ document.body.classList.remove('sheet-open');
410
+ empty(titleBox);
411
+ empty(statBox);
412
+ position();
413
+ if (opener) { opener.focus(); opener = null; }
414
+ }
415
+
416
+ function step(delta) {
417
+ var rows = listed();
418
+ var at = openFile ? rows.indexOf(openFile) : -1;
419
+ var next = at < 0 ? null : rows[at + delta];
420
+ if (next) {
421
+ // Focus stays on the button that is walking the list, so the next press
422
+ // lands on it too.
423
+ show(next, false);
424
+ opener = next.querySelector('.row');
425
+ }
426
+ }
235
427
 
236
428
  function apply() {
237
- var term = (search.value || '').toLowerCase();
429
+ var term = (search ? search.value : '').toLowerCase();
238
430
  var showVendor = vendorToggle ? vendorToggle.checked : true;
239
431
  var shown = 0;
432
+ var total = 0;
433
+ var first = true;
240
434
  files.forEach(function (el) {
241
435
  var path = (el.getAttribute('data-path') || '').toLowerCase();
242
436
  var isVendor = el.getAttribute('data-vendor') === '1';
243
437
  var ok = (!term || path.indexOf(term) !== -1) && (showVendor || !isVendor);
438
+ // A move block stands for every file it folded up, so both halves of the
439
+ // count are sums of `data-files` rather than counts of rows.
440
+ var covers = parseInt(el.getAttribute('data-files') || '1', 10);
244
441
  el.classList.toggle('hidden', !ok);
245
- if (ok) shown += parseInt(el.getAttribute('data-files') || '1', 10);
442
+ el.classList.toggle('first-shown', ok && first);
443
+ total += covers;
444
+ if (ok) { shown += covers; first = false; }
246
445
  });
247
- var counter = document.getElementById('shown-count');
248
- if (counter) counter.textContent = shown + ' of ' + files.length + ' files shown';
446
+ if (counter) { counter.textContent = shown + ' of ' + total + ' files shown'; }
447
+ // Leaving the sheet open on a file the filter just took off the list would
448
+ // show a diff with no row to close it from.
449
+ if (openFile && openFile.classList.contains('hidden')) { close(); } else { position(); }
249
450
  }
250
451
 
251
- if (search) search.addEventListener('input', apply);
252
- if (vendorToggle) vendorToggle.addEventListener('change', apply);
452
+ files.forEach(function (file) {
453
+ var row = file.querySelector('.row');
454
+ if (!row) { return; }
455
+ row.addEventListener('click', function () {
456
+ if (file === openFile) { close(); return; }
457
+ opener = row;
458
+ show(file, true);
459
+ });
460
+ });
461
+
462
+ if (search) { search.addEventListener('input', apply); }
463
+ if (vendorToggle) { vendorToggle.addEventListener('change', apply); }
464
+ if (closeBtn) { closeBtn.addEventListener('click', close); }
465
+ if (scrim) { scrim.addEventListener('click', close); }
466
+ if (prevBtn) { prevBtn.addEventListener('click', function () { step(-1); }); }
467
+ if (nextBtn) { nextBtn.addEventListener('click', function () { step(1); }); }
468
+
469
+ document.addEventListener('keydown', function (ev) {
470
+ if (!openFile || ev.ctrlKey || ev.metaKey || ev.altKey) { return; }
471
+ if (ev.key === 'Escape') { close(); return; }
472
+ var tag = (ev.target.tagName || '').toLowerCase();
473
+ if (tag === 'input' || tag === 'textarea') { return; }
474
+ if (ev.key === 'j') { step(1); ev.preventDefault(); }
475
+ if (ev.key === 'k') { step(-1); ev.preventDefault(); }
476
+ });
253
477
 
254
- var toolbar = document.querySelector('.toolbar');
255
- function measure() {
256
- if (!toolbar) return;
257
- document.documentElement.style.setProperty('--toolbar-h', toolbar.offsetHeight + 'px');
478
+ if (grip && window.PointerEvent) {
479
+ grip.addEventListener('pointerdown', function (down) {
480
+ down.preventDefault();
481
+ grip.setPointerCapture(down.pointerId);
482
+ document.body.classList.add('dragging');
483
+ function drag(move) {
484
+ // Clamped so a drag can neither shut the sheet nor bury the list
485
+ // behind it, whichever direction it is thrown in.
486
+ var width = Math.min(Math.max(window.innerWidth - move.clientX, 380),
487
+ Math.max(window.innerWidth - 320, 380));
488
+ document.documentElement.style.setProperty('--sheet-w', Math.round(width) + 'px');
489
+ }
490
+ function drop() {
491
+ document.body.classList.remove('dragging');
492
+ grip.removeEventListener('pointermove', drag);
493
+ grip.removeEventListener('pointerup', drop);
494
+ grip.removeEventListener('pointercancel', drop);
495
+ }
496
+ grip.addEventListener('pointermove', drag);
497
+ grip.addEventListener('pointerup', drop);
498
+ grip.addEventListener('pointercancel', drop);
499
+ });
258
500
  }
259
- window.addEventListener('resize', measure);
260
- measure();
261
501
 
262
- var expand = document.getElementById('expand-all');
263
- var collapse = document.getElementById('collapse-all');
264
- if (expand) expand.addEventListener('click', function () {
265
- files.forEach(function (f) { if (!f.classList.contains('hidden')) f.open = true; });
266
- });
267
- if (collapse) collapse.addEventListener('click', function () {
268
- files.forEach(function (f) { f.open = false; });
269
- });
270
502
  apply();
271
503
  })();
272
504
  """
@@ -392,8 +624,46 @@ def _row_code(row: _Row, lang: str, old: _Painted, new: _Painted) -> str:
392
624
  return highlight(row.text, lang)
393
625
 
394
626
 
627
+ def _file_block(
628
+ *,
629
+ path_key: str,
630
+ chip_class: str,
631
+ chip_text: str,
632
+ icon: str,
633
+ title: str,
634
+ stat: str,
635
+ body: str,
636
+ is_vendor: bool,
637
+ files: int | None = None,
638
+ ) -> str:
639
+ """One row of the file list, carrying the diff the sheet opens when it is clicked.
640
+
641
+ The row is what the reader scans — kind, icon, path, counts — and ``body``
642
+ is what opens beside it. The body ships inside the row's own element rather
643
+ than in a second list keyed by path, so a block stays one self-contained
644
+ thing to filter, hide or print; the script moves that element into the sheet
645
+ and back out again, which is why no diff is ever on the page twice.
646
+
647
+ ``path_key`` is what the filter box matches on: a file's own path and the
648
+ one it was renamed from, or every member path of a move. ``files`` is how
649
+ many files the block is the only entry for, left off when that is one.
650
+ """
651
+ extra = f' data-files="{files}"' if files is not None else ""
652
+ return (
653
+ f'<article class="file" data-path="{_esc(path_key)}" '
654
+ f'data-vendor="{1 if is_vendor else 0}"{extra}>'
655
+ '<button class="row" type="button" aria-expanded="false" aria-controls="sheet">'
656
+ f'<span class="chip {_esc(chip_class)}">{_esc(chip_text)}</span>'
657
+ f'<span class="path">{icon}<span class="p">{title}</span></span>'
658
+ f'<span class="stat-line">{stat}</span>'
659
+ "</button>"
660
+ f'<div class="body" hidden>{body}</div>'
661
+ "</article>"
662
+ )
663
+
664
+
395
665
  def _render_file(change: FileChange, a_root: Path | None = None, b_root: Path | None = None) -> str:
396
- """Render one file's change as a titled block with its diff table.
666
+ """Render one file's change as a list row and the diff table behind it.
397
667
 
398
668
  A rename is titled as one file with only the moved part written twice
399
669
  (``boto3-{1.34.0 → 1.35.20}.dist-info/METADATA``), rather than as two
@@ -423,22 +693,12 @@ def _render_file(change: FileChange, a_root: Path | None = None, b_root: Path |
423
693
  if change.size_delta:
424
694
  stat += f"<span>{signed(change.size_delta)} B</span>"
425
695
  if note := change.line_count_note:
426
- # The block is collapsed until someone opens it, so a summary row with
427
- # no ``+`` and no ``−`` is all most readers ever see of this file. The
428
- # note is the difference between "measured differently" and "unchanged".
696
+ # The diff is behind a click, so a row with no ``+`` and no ``−`` is all
697
+ # most readers ever see of this file. The note is the difference between
698
+ # "measured differently" and "unchanged".
429
699
  stat += f'<span class="skipped">{_esc(note)}</span>'
430
700
 
431
- parts = [
432
- f'<details class="file" data-path="{_esc(change.path)} {_esc(change.old_path or "")}" '
433
- f'data-vendor="{1 if change.is_vendor else 0}">',
434
- "<summary>",
435
- f'<span class="chip {_esc(change.kind)}">{_esc(change.kind)}</span>',
436
- f'<span class="path">{icons.file_icon(change.path, lang)}'
437
- f'<span class="p">{title}</span></span>',
438
- f'<span class="stat-line">{stat}</span>',
439
- "</summary>",
440
- ]
441
-
701
+ parts: list[str] = []
442
702
  if change.diff_lines:
443
703
  old = _paint(a_root, change.old_path or change.path, lang) if change.old else None
444
704
  new = _paint(b_root, change.path, lang) if change.new else None
@@ -450,7 +710,7 @@ def _render_file(change: FileChange, a_root: Path | None = None, b_root: Path |
450
710
  parts.append(f'<tr class="hunk"><td colspan="4">{_esc(row.text)}</td></tr>')
451
711
  continue
452
712
  css = f' class="{row.css}"' if row.css in ("add", "del") else ""
453
- mark = {"add": "+", "del": "\u2212"}.get(row.css, "")
713
+ mark = {"add": "+", "del": "−"}.get(row.css, "")
454
714
  code = intraline.mark(_row_code(row, lang, old, new), marks.get(index, []))
455
715
  parts.append(
456
716
  f"<tr{css}>"
@@ -490,8 +750,16 @@ def _render_file(change: FileChange, a_root: Path | None = None, b_root: Path |
490
750
  'is on disk.')
491
751
  parts.append(f'<div class="note">{note}</div>')
492
752
 
493
- parts.append("</details>")
494
- return "\n".join(parts)
753
+ return _file_block(
754
+ path_key=f'{change.path} {change.old_path or ""}',
755
+ chip_class=change.kind,
756
+ chip_text=change.kind,
757
+ icon=icons.file_icon(change.path, lang),
758
+ title=title,
759
+ stat=stat,
760
+ body="\n".join(parts),
761
+ is_vendor=change.is_vendor,
762
+ )
495
763
 
496
764
 
497
765
  def _render_word_edits(change: FileChange) -> str:
@@ -541,15 +809,14 @@ def _render_move(group: MoveGroup) -> str:
541
809
  files, so nothing is hidden that expanding will not show. The alternative
542
810
  is twenty blocks whose titles differ only in the filename at the end.
543
811
 
544
- Kept searchable and filterable like any file block: ``data-path`` holds
545
- every member path so the filter box still finds one by name, and
546
- ``data-vendor`` is set only when the whole move is vendored, which is the
547
- dependency-bump case (``boto3-1.{34.0 → 35.20}.dist-info/``).
812
+ Kept searchable and filterable like any file block: the filter box matches
813
+ on every member path, and the block counts as vendored only when the whole
814
+ move is, which is the dependency-bump case
815
+ (``boto3-1.{34.0 → 35.20}.dist-info/``).
548
816
 
549
- ``data-files`` is how many files this block is the *only* entry for, so the
550
- "N of M files shown" counter stays a count of files. The edited members are
551
- left out of it because they follow as blocks of their own and would
552
- otherwise be counted twice.
817
+ The file count it reports to :func:`_file_block` leaves out the edited
818
+ members, so the "N of M files shown" counter stays a count of files: those
819
+ members follow as blocks of their own and would otherwise be counted twice.
553
820
  """
554
821
  head, was, now, tail = rename_label(*group.display_dirs)
555
822
  title = (
@@ -579,18 +846,16 @@ def _render_move(group: MoveGroup) -> str:
579
846
  + "</li>"
580
847
  for c in group.members
581
848
  )
582
- return (
583
- f'<details class="file" data-path="{_esc(searchable)}" '
584
- f'data-vendor="{1 if group.is_vendor else 0}" '
585
- f'data-files="{group.moved - group.edited}">'
586
- "<summary>"
587
- '<span class="chip renamed">moved</span>'
588
- f'<span class="path">{icons.file_icon(group.new_dir + "/", "text")}'
589
- f'<span class="p">{title}</span></span>'
590
- f'<span class="stat-line">{stat}</span>'
591
- "</summary>"
592
- f'<ul class="moved-list">{rows}</ul>'
593
- "</details>"
849
+ return _file_block(
850
+ path_key=searchable,
851
+ chip_class="renamed",
852
+ chip_text="moved",
853
+ icon=icons.file_icon(group.new_dir + "/", "text"),
854
+ title=title,
855
+ stat=stat,
856
+ body=f'<ul class="moved-list">{rows}</ul>',
857
+ is_vendor=group.is_vendor,
858
+ files=group.moved - group.edited,
594
859
  )
595
860
 
596
861
 
@@ -773,8 +1038,68 @@ def _findings_section(diff: VersionDiff) -> str:
773
1038
  return f"<h2>New findings</h2>{table}{resolved}"
774
1039
 
775
1040
 
1041
+ #: Without the script the sheet can never open, so the diffs sit under their own
1042
+ #: rows instead and the controls that would do nothing are taken off the page.
1043
+ NOSCRIPT = """
1044
+ .toolbar, .sheet, .scrim { display: none; }
1045
+ .row { cursor: default; }
1046
+ .row::after { display: none; }
1047
+ .file .body[hidden] { display: block; }
1048
+ """
1049
+
1050
+
1051
+ def _nav_button(ident: str, label: str, glyph: str, extra: str = "") -> str:
1052
+ """One of the sheet's three controls: a 16×16 stroked glyph with a spoken name.
1053
+
1054
+ ``label`` names the keystroke as well as the action (``Next file (j)``),
1055
+ because a shortcut on a page with no menu has nowhere else to be found.
1056
+ """
1057
+ return (
1058
+ f'<button type="button" class="iconbtn{extra}" id="{ident}" '
1059
+ f'title="{_esc(label)}" aria-label="{_esc(label)}">'
1060
+ f'<svg viewBox="0 0 16 16" aria-hidden="true"><path d="{glyph}"/></svg></button>'
1061
+ )
1062
+
1063
+
1064
+ def _sheet(diff: VersionDiff) -> str:
1065
+ """The panel a file's diff opens into, and the scrim behind it on a narrow window.
1066
+
1067
+ One frame per page rather than one per file: the script moves the clicked
1068
+ file's diff into it, so what is written here is everything around that diff
1069
+ — the path, its counts, which two versions are being compared, and the
1070
+ controls that walk the list without going back to it.
1071
+
1072
+ The heading is left empty because it is filled from the row that was
1073
+ clicked; see the ``show`` function in :data:`JS`.
1074
+ """
1075
+ return (
1076
+ '<div class="scrim" id="scrim"></div>'
1077
+ '<aside class="sheet" id="sheet" role="dialog" aria-labelledby="sheet-title">'
1078
+ '<div class="grip" id="sheet-grip" title="Drag to resize"></div>'
1079
+ '<header class="sheet-head"><div class="sheet-bar">'
1080
+ '<div class="sheet-title" id="sheet-title"></div>'
1081
+ '<div class="sheet-nav">'
1082
+ + _nav_button("sheet-prev", "Previous file (k)", "m4.5 9.75 3.5-3.5 3.5 3.5")
1083
+ + '<span class="pos" id="sheet-pos"></span>'
1084
+ + _nav_button("sheet-next", "Next file (j)", "m4.5 6.25 3.5 3.5 3.5-3.5")
1085
+ + _nav_button("sheet-close", "Close (Esc)", "m4.5 4.5 7 7m0-7-7 7", " close")
1086
+ + "</div></div>"
1087
+ '<div class="sheet-sub"><span id="sheet-stat"></span>'
1088
+ f'<span class="ver">v{diff.a_seq:04d} → v{diff.b_seq:04d}</span>'
1089
+ "</div></header>"
1090
+ '<div class="sheet-body" id="sheet-body" tabindex="-1"></div>'
1091
+ "</aside>"
1092
+ )
1093
+
1094
+
776
1095
  def render_html(diff: VersionDiff, generated_by: str = "lambda-watcher") -> str:
777
- """Render the full report as a single HTML document."""
1096
+ """Render the full report as a single HTML document.
1097
+
1098
+ What the reader gets is a summary they can take in without scrolling, then
1099
+ a list of every changed file. Clicking one opens its diff in the sheet
1100
+ beside the list rather than underneath it — see :func:`_sheet` — so the
1101
+ list keeps its place and the code gets the width it wants.
1102
+ """
778
1103
  title = f"{diff.function_name} · v{diff.a_seq:04d} → v{diff.b_seq:04d}"
779
1104
  a_when = format_ts(diff.a_meta.get("ingested_at"))
780
1105
  b_when = format_ts(diff.b_meta.get("ingested_at"))
@@ -790,15 +1115,24 @@ def render_html(diff: VersionDiff, generated_by: str = "lambda-watcher") -> str:
790
1115
  blocks.extend(
791
1116
  _render_file(c, diff.a_root, diff.b_root) for c in row.edited_members
792
1117
  )
793
- file_blocks = "\n".join(blocks)
794
- if not diff.files:
795
- file_blocks = '<div class="empty">No file-level changes between these versions.</div>'
796
-
797
1118
  vendor_toggle = (
798
1119
  '<label><input type="checkbox" id="vendor" checked> show vendored files</label>'
799
1120
  if any(c.is_vendor for c in diff.files)
800
1121
  else ""
801
1122
  )
1123
+ # Two versions with the same tree have nothing to filter and nothing to
1124
+ # open, so that page is one sentence: a search box above it would only
1125
+ # offer to narrow an empty list.
1126
+ toolbar = listing = ""
1127
+ if diff.files:
1128
+ toolbar = (
1129
+ '<div class="toolbar">'
1130
+ '<input type="search" id="filter" placeholder="Filter by path…" autocomplete="off">'
1131
+ f'{vendor_toggle}<span class="sub" id="shown-count"></span></div>'
1132
+ )
1133
+ listing = '<div class="files">{}</div>'.format("\n".join(blocks))
1134
+ else:
1135
+ listing = '<div class="empty">No file-level changes between these versions.</div>'
802
1136
 
803
1137
  return f"""<!DOCTYPE html>
804
1138
  <html lang="en">
@@ -807,6 +1141,7 @@ def render_html(diff: VersionDiff, generated_by: str = "lambda-watcher") -> str:
807
1141
  <meta name="viewport" content="width=device-width, initial-scale=1">
808
1142
  <title>{_esc(title)}</title>
809
1143
  <style>{CSS}{ICON_CSS}</style>
1144
+ <noscript><style>{NOSCRIPT}</style></noscript>
810
1145
  </head>
811
1146
  <body>
812
1147
  {icons.sprite()}
@@ -826,20 +1161,15 @@ def render_html(diff: VersionDiff, generated_by: str = "lambda-watcher") -> str:
826
1161
  {_findings_section(diff)}
827
1162
 
828
1163
  <h2>File changes</h2>
829
- <div class="toolbar">
830
- <input type="search" id="filter" placeholder="Filter by path…" autocomplete="off">
831
- {vendor_toggle}
832
- <button type="button" id="expand-all">Expand all</button>
833
- <button type="button" id="collapse-all">Collapse all</button>
834
- <span class="sub" id="shown-count"></span>
835
- </div>
836
- {file_blocks}
1164
+ {toolbar}
1165
+ {listing}
837
1166
 
838
1167
  <footer>
839
1168
  Generated by {_esc(generated_by)} on {_esc(datetime.now().strftime('%Y-%m-%d %H:%M'))}.
840
1169
  Content hashes ignore zip timestamps, so re-downloading unchanged code does not create a new version.
841
1170
  </footer>
842
1171
  </div>
1172
+ {_sheet(diff)}
843
1173
  <script>{JS}</script>
844
1174
  </body>
845
1175
  </html>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-watcher
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions.
5
5
  Author: Lambda Watcher contributors
6
6
  License: Apache-2.0
@@ -526,8 +526,8 @@ def test_the_html_report_folds_the_move_but_keeps_the_edits(cfg, db, ingestor: I
526
526
  ingestor.ingest(make_zip("fn.zip", after))
527
527
 
528
528
  html = render_html(_diff(cfg, db, ingestor))
529
- assert html.count('details class="file"') == 3 # the move, plus the two edits
530
- assert html.count('<li class="mono">') == 20 # nothing hidden that expanding won't show
529
+ assert html.count('<article class="file"') == 3 # the move, plus the two edits
530
+ assert html.count('<li class="mono">') == 20 # nothing hidden that opening it won't show
531
531
  assert html.count('<div class="diff">') == 2 # both edits keep their patch
532
532
  # The counter stays a count of files, not of blocks.
533
533
  assert 'data-files="18"' in html
@@ -23,7 +23,14 @@ from lambda_watcher.diffing.highlight import (
23
23
  highlight_lines,
24
24
  language_of,
25
25
  )
26
- from lambda_watcher.diffing.render_html import CSS, _Row, _row_code, render_html
26
+ from lambda_watcher.diffing.render_html import (
27
+ CSS,
28
+ JS,
29
+ NOSCRIPT,
30
+ _Row,
31
+ _row_code,
32
+ render_html,
33
+ )
27
34
  from lambda_watcher.ingest import Ingestor
28
35
  from lambda_watcher.utils import LANG_BY_EXT
29
36
 
@@ -371,3 +378,124 @@ def test_the_report_paints_the_code_and_carries_one_sprite(cfg, db, ingestor: In
371
378
  assert 'colspan="4"' in page and 'colspan="3"' not in page
372
379
  # Still one file, no network.
373
380
  assert "http://" not in page and "https://" not in page
381
+
382
+
383
+ # --------------------------------------------------- the list and the sheet
384
+ # A file's diff opens beside the list rather than under it, which makes the
385
+ # page three things that have to agree: markup the script can find, a script
386
+ # that moves exactly one diff into the sheet, and a stylesheet that draws the
387
+ # states the script sets. Nothing here can see a browser, so these pin down the
388
+ # contract between the three — the failures that reach a reader as a row that
389
+ # does nothing when clicked.
390
+ def _report(cfg, db, ingestor, make_zip) -> str:
391
+ """A rendered report listing a first-party file and a vendored one.
392
+
393
+ Listing the vendored file is what puts the "show vendored files" box on the
394
+ page, so this is the page that carries every control the script binds to.
395
+ """
396
+ ingestor.ingest(make_zip("fn.zip", {
397
+ "lambda_function.py": PY_V1, "site-packages/boto3/__init__.py": "V = '1.34.0'\n"}))
398
+ ingestor.ingest(make_zip("fn.zip", {
399
+ "lambda_function.py": PY_V2, "site-packages/boto3/__init__.py": "V = '1.35.20'\n"}))
400
+ return render_html(_diff(cfg, db, ingestor, include_vendor=True))
401
+
402
+
403
+ def test_a_file_is_a_row_with_its_diff_parked_behind_it(cfg, db, ingestor: Ingestor, make_zip):
404
+ """The row is what the reader scans; the diff travels with it, hidden until asked for.
405
+
406
+ Hidden in the markup rather than absent from it is what keeps the page one
407
+ file: there is nothing to fetch when a row is clicked, and a browser with
408
+ no script still has every diff on the page.
409
+ """
410
+ page = _report(cfg, db, ingestor, make_zip)
411
+
412
+ assert '<article class="file"' in page
413
+ assert '<button class="row" type="button" aria-expanded="false" aria-controls="sheet">' in page
414
+ assert '<div class="body" hidden>' in page
415
+ # One body per block, each holding that block's diff and nothing else, so
416
+ # no diff is on the page twice for the script to pick the wrong copy of.
417
+ assert page.count('<div class="body" hidden>') == page.count('<article class="file"') == 2
418
+ assert page.count('<div class="body" hidden><div class="diff">') == 2
419
+ # The block that opened downwards is gone, markers and all.
420
+ assert "<details" not in page and "<summary" not in page
421
+
422
+
423
+ def test_the_page_carries_exactly_one_sheet(cfg, db, ingestor: Ingestor, make_zip):
424
+ """One frame for every row to open into, outside the list the script moves diffs out of.
425
+
426
+ A sheet per file would repeat the diff, and a sheet nested in the list
427
+ would be hidden by the filter that hides its row.
428
+ """
429
+ page = _report(cfg, db, ingestor, make_zip)
430
+
431
+ assert page.count('id="sheet"') == 1
432
+ assert page.count('id="sheet-body"') == 1
433
+ assert page.index('class="files"') < page.index('class="sheet"')
434
+ # The versions being compared are named in the sheet too: it covers the
435
+ # page header outright on a window too narrow to dock it.
436
+ assert 'class="ver">v0001 → v0002</span>' in page
437
+
438
+
439
+ def test_every_handle_the_script_reaches_for_is_on_the_page(cfg, db, ingestor: Ingestor,
440
+ make_zip):
441
+ """The script finds the page by id, so a rename on either side is a dead row.
442
+
443
+ Nothing raises when it happens — ``getElementById`` answers null and the
444
+ listener is never attached — so clicking a file would quietly do nothing.
445
+ """
446
+ page = _report(cfg, db, ingestor, make_zip)
447
+
448
+ wanted = set(re.findall(r"getElementById\('([^']+)'\)", JS))
449
+ assert wanted, "no ids parsed out of the script"
450
+ for ident in sorted(wanted):
451
+ assert f'id="{ident}"' in page, f"the script looks up #{ident}, which nothing renders"
452
+
453
+
454
+ def test_every_state_the_script_sets_is_drawn(cfg, db, ingestor: Ingestor, make_zip):
455
+ """A class the stylesheet has no rule for is a state the reader cannot see."""
456
+ for css_class in ("hidden", "first-shown", "active", "sheet-open", "dragging"):
457
+ assert f"classList.toggle('{css_class}'" in JS or f"classList.add('{css_class}'" in JS
458
+ assert f".{css_class}" in CSS, f"the script sets .{css_class} and nothing draws it"
459
+
460
+
461
+ def test_the_sheet_says_which_key_does_what(cfg, db, ingestor: Ingestor, make_zip):
462
+ """A shortcut on a page with no menu is only discoverable from the control it doubles."""
463
+ page = _report(cfg, db, ingestor, make_zip)
464
+
465
+ for key, action in (("j", "Next file"), ("k", "Previous file"), ("Esc", "Close")):
466
+ assert f'title="{action} ({key})"' in page
467
+ assert f'aria-label="{action} ({key})"' in page
468
+
469
+
470
+ def test_without_a_script_the_diffs_are_still_readable(cfg, db, ingestor: Ingestor, make_zip):
471
+ """No script means no sheet, so the diffs go back to sitting under their own rows.
472
+
473
+ The alternative is a page of rows that do nothing when clicked, with every
474
+ diff on it and no way to reach one.
475
+ """
476
+ page = _report(cfg, db, ingestor, make_zip)
477
+
478
+ assert "<noscript><style>" in page
479
+ assert ".file .body[hidden] { display: block; }" in NOSCRIPT
480
+ # ...and the controls that only a script can answer are taken off the page.
481
+ assert ".toolbar, .sheet, .scrim { display: none; }" in NOSCRIPT
482
+
483
+
484
+ def test_a_report_with_nothing_to_list_offers_nothing_to_filter(cfg, db, ingestor: Ingestor,
485
+ make_zip):
486
+ """Two versions differing only outside the file tree still land on a page that reads.
487
+
488
+ A search box over an empty list invites the reader to narrow it further,
489
+ which is the opposite of what the sentence under it is telling them.
490
+ """
491
+ # A dependency bump and nothing else: the vendored files that changed are
492
+ # hidden by default, and the dependency table above has already explained
493
+ # them, so the list itself has nothing left to show.
494
+ ingestor.ingest(make_zip("fn.zip", {
495
+ "lambda_function.py": PY_V1, "site-packages/boto3/__init__.py": "V = '1.34.0'\n"}))
496
+ ingestor.ingest(make_zip("fn.zip", {
497
+ "lambda_function.py": PY_V1, "site-packages/boto3/__init__.py": "V = '1.35.20'\n"}))
498
+ page = render_html(_diff(cfg, db, ingestor))
499
+
500
+ assert '<div class="empty">' in page
501
+ assert 'id="filter"' not in page and 'class="files"' not in page
File without changes
File without changes
File without changes