pgkiln 0.49.0 → 0.50.0

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.
@@ -15,6 +15,8 @@
15
15
  // - "Check" plans the SQL like the Advisor (POST /builder/code/check)
16
16
  // - screen readers keep the plain textarea; the list is announced through
17
17
  // aria-activedescendant and a polite live region
18
+ // - (sprint 108) read-only code, <pre data-code-view="sql|plpgsql|json|html">, is
19
+ // highlighted with the same lexer and colours (DDL, generated SQL)
18
20
  //
19
21
  // Strict CSP: no inline styles; geometry is set through the CSSOM. Suggestions
20
22
  // and highlighted text are built as text nodes, never parsed as HTML.
@@ -437,15 +439,20 @@
437
439
  gutter.setAttribute('aria-hidden', 'true');
438
440
  const nums = document.createElement('div');
439
441
  nums.className = 'ce-nums';
440
- gutter.append(nums);
442
+ // the caret's line: a band behind the text, its number over the gutter's
443
+ const curNum = document.createElement('div');
444
+ curNum.className = 'ce-cur-num';
445
+ gutter.append(nums, curNum);
441
446
  const main = document.createElement('div');
442
447
  main.className = 'ce-main';
443
448
  const hl = document.createElement('div');
444
449
  hl.className = 'ce-hl';
445
450
  hl.setAttribute('aria-hidden', 'true');
451
+ const cur = document.createElement('div');
452
+ cur.className = 'ce-cur';
446
453
  const inner = document.createElement('pre');
447
454
  inner.className = 'ce-code';
448
- hl.append(inner);
455
+ hl.append(cur, inner);
449
456
  ta.parentNode.insertBefore(wrap, ta);
450
457
  main.append(hl, ta);
451
458
  wrap.append(gutter, main);
@@ -502,7 +509,7 @@
502
509
  ta.setAttribute('aria-autocomplete', 'list');
503
510
  ta.setAttribute('aria-haspopup', 'listbox');
504
511
  ta.setAttribute('aria-controls', list.id);
505
- Object.assign(this, { wrap, gutter, nums, main, hl, inner, list, bar, langEl: lang, hint, pos, msg, check, live, suggest });
512
+ Object.assign(this, { wrap, gutter, nums, cur, curNum, main, hl, inner, list, bar, langEl: lang, hint, pos, msg, check, live, suggest });
506
513
  this.applyLang();
507
514
  }
508
515
 
@@ -661,9 +668,27 @@
661
668
  this.digits = digits;
662
669
  this.gutter.style.setProperty('--ce-digits', String(Math.max(2, digits)));
663
670
  }
671
+ this.placeLine();
664
672
  if (this.pop) this.place();
665
673
  }
666
674
 
675
+ /** The current-line band and number: on the caret's line; hidden while a selection spans lines. */
676
+ placeLine() {
677
+ const ta = this.ta;
678
+ const lines = this.lineStarts;
679
+ const a = lowerBound(lines, ta.selectionStart + 1);
680
+ const b = lowerBound(lines, ta.selectionEnd + 1);
681
+ const hide = a !== b;
682
+ this.cur.hidden = hide;
683
+ this.curNum.hidden = hide;
684
+ if (hide) return;
685
+ const { lh, padTop } = this.metrics();
686
+ const y = padTop + (a - 1) * lh - ta.scrollTop;
687
+ this.cur.style.transform = `translateY(${y}px)`;
688
+ this.curNum.style.transform = `translateY(${y}px)`;
689
+ this.curNum.textContent = String(a);
690
+ }
691
+
667
692
  tokenAt(off) {
668
693
  this.sync();
669
694
  const { T, S, E } = this.toks;
@@ -703,6 +728,7 @@
703
728
  this.sync();
704
729
  if (this.pop && this.ta.selectionStart !== this.ta.selectionEnd) this.close();
705
730
  if (this.updateMatch()) this.schedule(true);
731
+ else this.placeLine();
706
732
  const c = this.ta.selectionStart;
707
733
  const line = lowerBound(this.lineStarts, c + 1);
708
734
  this.pos.textContent = `Ln ${line}, Col ${c - this.lineStarts[line - 1] + 1}`;
@@ -997,12 +1023,41 @@
997
1023
  return lo;
998
1024
  }
999
1025
 
1026
+ /** Highlight a read-only <pre data-code-view>: its text, as spans (text nodes only). Long ones stay plain. */
1027
+ function colorize(pre) {
1028
+ const lang = pre.dataset.codeView in LANG_LABEL ? pre.dataset.codeView : 'text';
1029
+ pre.classList.add('ce-view');
1030
+ const text = pre.textContent;
1031
+ if (lang === 'text' || text.length > 200000) return;
1032
+ const { T, S, E } = lex(lang, text);
1033
+ const frag = document.createDocumentFragment();
1034
+ let pos = 0;
1035
+ for (let k = 0; k < T.length; k++) {
1036
+ const st = Math.max(S[k], pos);
1037
+ if (E[k] <= st) continue;
1038
+ if (st > pos) frag.append(text.slice(pos, st));
1039
+ const span = document.createElement('span');
1040
+ span.className = `t-${T[k]}`;
1041
+ span.textContent = text.slice(st, E[k]);
1042
+ frag.append(span);
1043
+ pos = E[k];
1044
+ }
1045
+ if (pos < text.length) frag.append(text.slice(pos));
1046
+ pre.replaceChildren(frag);
1047
+ }
1048
+
1000
1049
  const editors = new WeakMap();
1050
+ const viewed = new WeakSet();
1001
1051
  function enhance(root) {
1002
1052
  for (const ta of (root || document).querySelectorAll('textarea[data-code]')) {
1003
1053
  if (editors.has(ta) || ta.closest('.ce')) continue;
1004
1054
  editors.set(ta, new Editor(ta));
1005
1055
  }
1056
+ for (const pre of (root || document).querySelectorAll('pre[data-code-view]')) {
1057
+ if (viewed.has(pre)) continue;
1058
+ viewed.add(pre);
1059
+ colorize(pre);
1060
+ }
1006
1061
  }
1007
1062
  window.pgkilnCodeEditor = { enhance, editorOf: (ta) => editors.get(ta) };
1008
1063
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhance());
@@ -8,19 +8,20 @@
8
8
  .dm-toolbar .btn[disabled] { opacity: 0.5; }
9
9
  .dm-status { min-width: 0; }
10
10
  .dm-stage { position: relative; }
11
- .dm-canvas { overflow: auto; max-width: 100%; height: 65vh; min-height: 18rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); }
11
+ .dm-canvas { overflow: auto; max-width: 100%; height: 65vh; min-height: 18rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--canvas, var(--bg)); box-shadow: var(--shadow-xs, none); }
12
12
  .dm-canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
13
13
  .dm-svg { display: block; }
14
- .dm-svg .dm-bg { fill: var(--bg); }
15
- .dm-svg .dm-frame { fill: var(--surface); stroke: var(--border); }
16
- .dm-svg .dm-view .dm-frame { fill: var(--surface-2); }
17
- .dm-svg .dm-head { fill: var(--accent-soft); stroke: var(--border); }
18
- .dm-svg .dm-title { fill: var(--text); }
19
- .dm-svg .dm-col { fill: var(--text); }
20
- .dm-svg .dm-type, .dm-svg .dm-kind, .dm-svg .dm-more { fill: var(--muted); }
21
- .dm-svg .dm-key { fill: var(--gold, #b45309); }
22
- .dm-svg .dm-line, .dm-svg .dm-end { stroke: var(--muted); }
23
- .dm-svg .dm-opt { fill: var(--bg); stroke: var(--muted); }
14
+ /* the boxes and lines: no .dm-svg ancestor in the selectors, so the mini-map's <use> copy (a shadow tree) is themed too */
15
+ .dm-svg .dm-bg { fill: var(--canvas, var(--bg)); }
16
+ .dm-frame { fill: var(--block, var(--surface)); stroke: var(--border-strong, var(--border)); }
17
+ .dm-view .dm-frame { fill: var(--surface-2); }
18
+ path.dm-head { fill: var(--accent-soft); stroke: var(--border-strong, var(--border)); }
19
+ .dm-title { fill: var(--text); }
20
+ .dm-col { fill: var(--text); }
21
+ :is(.dm-type, .dm-kind, .dm-more) { fill: var(--muted); }
22
+ .dm-key { fill: var(--gold, #b45309); }
23
+ :is(.dm-line, .dm-end) { stroke: var(--muted); }
24
+ .dm-opt { fill: var(--canvas, var(--bg)); stroke: var(--muted); }
24
25
  .dm-svg .dm-fk:hover .dm-line, .dm-svg .dm-fk:hover .dm-end { stroke: var(--accent); stroke-width: 2.5; }
25
26
  .dm-svg .dm-link:hover .dm-title, .dm-svg .dm-link:focus .dm-title { fill: var(--accent); text-decoration: underline; }
26
27
  .dm-live .dm-box { cursor: grab; touch-action: none; }
@@ -29,9 +30,9 @@
29
30
  .dm-svg .dm-box:focus-visible .dm-frame { stroke: var(--accent); stroke-width: 2.5; }
30
31
  .dm-svg .dm-dragging .dm-frame { stroke: var(--accent); }
31
32
  .dm-svg text { user-select: none; }
32
- .dm-minimap { position: absolute; right: 12px; bottom: 12px; width: 200px; height: 140px; border: 1px solid var(--border); border-radius: var(--radius);
33
- background: var(--surface); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); cursor: pointer; touch-action: none; }
34
- .dm-minimap-bg { fill: var(--surface); }
33
+ .dm-minimap { position: absolute; right: 12px; bottom: 12px; width: 200px; height: 140px; border: 1px solid var(--border-strong, var(--border)); border-radius: var(--radius);
34
+ background: var(--canvas, var(--surface)); box-shadow: var(--shadow-md, 0 2px 8px rgb(0 0 0 / 20%)); cursor: pointer; touch-action: none; overflow: hidden; }
35
+ .dm-minimap-bg { fill: var(--canvas, var(--surface)); }
35
36
  .dm-minimap-view { fill: var(--accent); fill-opacity: 0.15; stroke: var(--accent); stroke-width: 4; vector-effect: non-scaling-stroke; }
36
37
  .dm-legend { margin: 6px 0 12px; }
37
38
  .dm-search-row { display: flex; gap: 6px; flex-wrap: wrap; }
@@ -54,3 +55,5 @@
54
55
  .dm-svg { max-width: 100%; height: auto; }
55
56
  .dm-dict-table { break-inside: avoid-page; }
56
57
  }
58
+ /* sprint 108: the Mini-map toggle shows that it is on */
59
+ .dm-toolbar .btn[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
@@ -60,8 +60,8 @@
60
60
  .ws-grid .null { color: var(--muted); font-style: italic; }
61
61
  .ws-log { margin: 0; padding: 8px; background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--radius); font-size: 12.5px; line-height: 1.45; white-space: pre; overflow: auto; }
62
62
  .ws-log-sql { color: var(--muted); }
63
- .ws-log-ok { color: #2f7d32; font-weight: 600; }
64
- .ws-log-err { color: #c0392b; white-space: pre-wrap; }
63
+ .ws-log-ok { color: var(--success); font-weight: 600; }
64
+ .ws-log-err { color: var(--danger); white-space: pre-wrap; }
65
65
  .ws-log-warn { color: var(--warn); white-space: pre-wrap; }
66
66
  .ws-notices, .ws-history { list-style: none; margin: 0; padding: 0; font-size: 13px; }
67
67
  .ws-notices li, .ws-history li { display: flex; gap: 8px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--border); min-width: 0; }
@@ -72,7 +72,7 @@
72
72
  .ws-status { display: flex; align-items: center; gap: 12px; padding: 4px 10px; border-top: 1px solid var(--border); background: var(--surface-2); font-size: 12.5px; min-width: 0; }
73
73
  .ws-status .icon { width: 13px; height: 13px; vertical-align: -2px; }
74
74
  .ws-count { color: var(--muted); white-space: nowrap; }
75
- .ws-count.is-err { color: #c0392b; }
75
+ .ws-count.is-err { color: var(--danger); }
76
76
  .ws-count.is-warn { color: var(--warn); }
77
77
  .ws-last { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
78
78
  .ws-time { margin-left: auto; }
@@ -156,8 +156,8 @@ th[aria-sort=descending] .ws-sort::after { content: ' ▼'; font-size: 0.75em; }
156
156
  .ws-hist-bar { display: flex; gap: 6px; margin-bottom: 6px; }
157
157
  .ws-hist-bar input { flex: 1; min-width: 0; }
158
158
  .ws-hist-icon .icon { width: 14px; height: 14px; }
159
- .ws-hist-icon.is-ok { color: #2f7d32; }
160
- .ws-hist-icon.is-err { color: #c0392b; }
159
+ .ws-hist-icon.is-ok { color: var(--success); }
160
+ .ws-hist-icon.is-err { color: var(--danger); }
161
161
  .ws-history li { align-items: center; }
162
162
  .ws-history .btn { flex: none; }
163
163
  @media (max-width: 640px) { .ws-hist-meta { display: none; } }
@@ -166,8 +166,8 @@ th[aria-sort=descending] .ws-sort::after { content: ' ▼'; font-size: 0.75em; }
166
166
  .ws-wtabs { display: none; align-items: stretch; gap: 0; overflow-x: auto; border-bottom: 1px solid var(--border); background: var(--surface-2); flex: none; }
167
167
  .js .ws-wtabs { display: flex; }
168
168
  .ws-wtab { display: flex; align-items: center; border-right: 1px solid var(--border); }
169
- .ws-wtab [role=tab] { border: 0; background: none; color: var(--muted); padding: 5px 4px 5px 10px; font: inherit; font-size: 12.5px; cursor: pointer; white-space: nowrap; max-width: 14rem; overflow: hidden; text-overflow: ellipsis; }
170
- .ws-wtab [role=tab][aria-selected=true] { color: var(--text); box-shadow: inset 0 -2px 0 var(--hot); }
169
+ .ws-wtab [data-ws-wtab] { border: 0; background: none; color: var(--muted); padding: 5px 4px 5px 10px; font: inherit; font-size: 12.5px; cursor: pointer; white-space: nowrap; max-width: 14rem; overflow: hidden; text-overflow: ellipsis; }
170
+ .ws-wtab [data-ws-wtab][aria-current=true] { color: var(--text); box-shadow: inset 0 -2px 0 var(--hot); }
171
171
  .ws-wtab-close, .ws-wtab-new { border: 0; background: none; color: var(--muted); cursor: pointer; padding: 2px 8px; font-size: 14px; line-height: 1; }
172
172
  .ws-wtab-close:hover, .ws-wtab-new:hover { color: var(--text); }
173
173
  .ws-file > summary { list-style: none; max-width: 14rem; }
@@ -291,3 +291,10 @@ details.menu.obj-menu > summary::before, details.menu.obj-menu > summary::after
291
291
  .ch-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 10px 12px; min-width: 0; }
292
292
  .ch-card h2 { font-size: 14px; margin: 0 0 6px; }
293
293
  .ch-edit { max-width: 48rem; margin-top: 16px; }
294
+
295
+ /* (sprint 107) transaction mode: Autocommit / Transaction, the badge, Commit and Rollback */
296
+ .ws-txn { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }
297
+ .ws-txn-badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); max-width: 22rem; overflow-wrap: anywhere; }
298
+ .ws-txn-badge[data-state="open"] { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); background: color-mix(in srgb, var(--warn) 10%, transparent); font-weight: 600; }
299
+ .ws-txn-badge[data-state="aborted"] { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); font-weight: 600; }
300
+ .ws-txn-alert { margin: 6px 8px; }
@@ -13,6 +13,9 @@
13
13
  // - (sprint 93) worksheets as tabs kept in this browser (unsaved text survives a reload), saved as SQL Scripts
14
14
  // (Ctrl+S), the Files list; bind and substitution values in a dialog; Format (Ctrl+F7), Convert case (Ctrl+B),
15
15
  // find and replace (Ctrl+F, Ctrl+H), go to line (Ctrl+G), comment lines (Ctrl+/), Help (F1)
16
+ // - (sprint 107) transaction mode per worksheet tab: Autocommit / Transaction, Commit and Rollback, a badge with the
17
+ // server's real state (fetched again after a reload and a tab switch, and when the idle timeout is due); leaving the
18
+ // page with an open transaction asks first, closing the tab or switching to Autocommit rolls it back
16
19
  // Strict CSP: no inline styles; sizes are set through the CSSOM.
17
20
  (() => {
18
21
  'use strict';
@@ -266,8 +269,8 @@
266
269
  wrap.className = 'ws-wtab';
267
270
  const b = document.createElement('button');
268
271
  b.type = 'button';
269
- b.setAttribute('role', 'tab');
270
- b.setAttribute('aria-selected', String(t === cur));
272
+ // (sprint 108) buttons in a group, the open one aria-current: a tablist may hold only tabs, not the close buttons
273
+ if (t === cur) b.setAttribute('aria-current', 'true');
271
274
  b.dataset.wsWtab = t.key;
272
275
  b.textContent = `${t.name}${dirty(t) ? '*' : ''}`;
273
276
  b.title = t.scriptId ? `Script “${t.name}”${dirty(t) ? ' (unsaved changes)' : ''}` : 'Not saved as a script';
@@ -288,11 +291,15 @@
288
291
  ta.value = t.text;
289
292
  ta.dispatchEvent(new Event('input', { bubbles: true }));
290
293
  drawTabs();
294
+ txnTabChanged();
291
295
  ta.focus();
292
296
  };
293
297
  const newTab = () => switchTo(add({ key: newKey(), name: untitled(), scriptId: null, text: '', saved: '' }));
294
298
  const closeTab = (t) => {
295
- if (dirty(t) && t.text.trim() && !window.confirm(`Close “${t.name}” without saving?`)) return;
299
+ const txnOpen = t === cur ? txnBox.dataset.state !== 'none' : !!t.txnOpen;
300
+ if (txnOpen && !window.confirm(`“${t.name}” has an open transaction. Close it and roll the transaction back?`)) return;
301
+ if (!txnOpen && dirty(t) && t.text.trim() && !window.confirm(`Close “${t.name}” without saving?`)) return;
302
+ if (t.txn) txnPost(t.key, 'rollback'); // the server knows best whether one is open
296
303
  const i = book.tabs.indexOf(t);
297
304
  book.tabs.splice(i, 1);
298
305
  if (t === cur) {
@@ -340,6 +347,111 @@
340
347
  form.querySelector('.ws-nav-switch').addEventListener('click', (e) => { const b = e.target.closest('[data-ws-navtab]'); if (b) navTab(b.dataset.wsNavtab); });
341
348
  navTab(location.hash === '#ws-files' ? 'files' : 'objects');
342
349
 
350
+ // ------------------------------------------------------------ (sprint 107) transaction mode
351
+ function txnTabChanged() {
352
+ modeSel.value = cur.txn ? 'on' : '';
353
+ txnApply({ state: 'none', statements: 0, startedMs: null, note: null });
354
+ txnFetch();
355
+ }
356
+ const txnBox = form.querySelector('[data-ws-txn]');
357
+ const modeSel = txnBox.querySelector('[data-ws-txn-mode]');
358
+ const badge = txnBox.querySelector('[data-ws-txn-badge]');
359
+ const commitBtn = txnBox.querySelector('[data-ws-txn-commit]');
360
+ const rollbackBtn = txnBox.querySelector('[data-ws-txn-rollback]');
361
+ const commitNote = form.querySelector('[data-ws-commit-note]');
362
+ const idleSeconds = Number(txnBox.dataset.idle) || 300;
363
+ const idleWords = idleSeconds % 60 ? `${idleSeconds} seconds` : `${idleSeconds / 60} minute${idleSeconds === 60 ? '' : 's'}`;
364
+ let txnInfo = { state: txnBox.dataset.state, statements: Number(txnBox.dataset.statements) || 0, startedMs: Number(txnBox.dataset.started) || null,
365
+ lastMs: Number(txnBox.dataset.last) || null, note: txnBox.dataset.note || null };
366
+ let clockSkew = Date.now() - (Number(txnBox.dataset.now) || Date.now());
367
+ let txnTimer = 0;
368
+ const badgeText = (i) => {
369
+ if (i.state === 'none') return i.note || 'No open transaction';
370
+ const min = i.startedMs ? Math.floor((Date.now() - clockSkew - i.startedMs) / 60000) : 0;
371
+ return `${i.state === 'aborted' ? 'Transaction failed' : 'Transaction open'} · ${i.statements} statement${i.statements === 1 ? '' : 's'} · ${min < 1 ? 'under a minute' : `${min} min`}`;
372
+ };
373
+ const txnApply = (i) => {
374
+ txnInfo = i;
375
+ const on = modeSel.value === 'on';
376
+ txnBox.dataset.state = i.state;
377
+ badge.dataset.state = i.state;
378
+ badge.textContent = badgeText(i);
379
+ badge.hidden = !on && i.state === 'none' && !i.note;
380
+ commitBtn.hidden = rollbackBtn.hidden = !on && i.state === 'none';
381
+ commitBtn.disabled = i.state !== 'open';
382
+ rollbackBtn.disabled = i.state === 'none';
383
+ commitNote.textContent = on
384
+ ? `The statements stay in one transaction until Commit or Rollback; it is rolled back after ${idleWords} without a run.`
385
+ : 'Each statement commits on its own.';
386
+ cur.txn = on;
387
+ cur.txnOpen = i.state !== 'none';
388
+ persist();
389
+ clearTimeout(txnTimer);
390
+ if (i.state !== 'none') {
391
+ // the age once a minute; the server again when the idle timeout is due
392
+ const due = i.lastMs ? i.lastMs + idleSeconds * 1000 + 2000 - (Date.now() - clockSkew) : 60000;
393
+ txnTimer = setTimeout(() => (due <= 60000 ? txnFetch() : txnApply(txnInfo)), Math.max(1000, Math.min(due, 60000)));
394
+ }
395
+ };
396
+ const fromServer = (j) => {
397
+ clockSkew = Date.now() - j.now;
398
+ txnApply({ state: j.state, statements: j.statements, startedMs: j.startedMs, lastMs: j.lastMs, note: j.note });
399
+ };
400
+ async function txnFetch() {
401
+ const key = cur.key;
402
+ try {
403
+ const res = await fetch(`/builder/sql/worksheet/txn?tab=${encodeURIComponent(key)}`, { credentials: 'same-origin', headers: { accept: 'application/json' } });
404
+ if (!res.ok) throw new Error(String(res.status));
405
+ const j = await res.json();
406
+ if (key !== cur.key) return; // switched meanwhile
407
+ if (j.state !== 'none') modeSel.value = 'on';
408
+ fromServer(j);
409
+ } catch {
410
+ badge.textContent = 'Transaction state unknown';
411
+ }
412
+ }
413
+ async function txnPost(key, action) {
414
+ try {
415
+ const res = await fetch('/builder/sql/worksheet/txn', { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json' },
416
+ body: new URLSearchParams({ __csrf: form.elements.__csrf.value, tab: key, action }) });
417
+ const j = await res.json();
418
+ if (key === cur.key) {
419
+ fromServer(j);
420
+ if (j.message) announce(j.message);
421
+ }
422
+ return j;
423
+ } catch {
424
+ announce('The transaction could not be ended: reload the page.');
425
+ return null;
426
+ }
427
+ }
428
+ modeSel.addEventListener('change', async () => {
429
+ if (modeSel.value !== 'on' && txnInfo.state !== 'none') {
430
+ if (!window.confirm('Switching to Autocommit rolls back the open transaction. Roll it back?')) { modeSel.value = 'on'; return; }
431
+ await txnPost(cur.key, 'rollback');
432
+ modeSel.value = '';
433
+ }
434
+ txnApply(txnInfo);
435
+ });
436
+ // a page post (a run, Commit, Rollback) is not leaving: the transaction stays
437
+ let submitting = false;
438
+ form.addEventListener('submit', () => {
439
+ submitting = true;
440
+ setTimeout(() => { submitting = false; }, 3000); // a download doesn't leave the page
441
+ });
442
+ window.addEventListener('pageshow', () => { submitting = false; });
443
+ window.addEventListener('beforeunload', (e) => {
444
+ if (submitting || txnInfo.state === 'none') return;
445
+ e.preventDefault();
446
+ e.returnValue = ''; // the browser asks "Leave site?"; an idle transaction is rolled back after the timeout
447
+ });
448
+ if (!form.dataset.tab || form.dataset.tab !== cur.key) txnTabChanged();
449
+ else {
450
+ if (txnInfo.state !== 'none') modeSel.value = 'on';
451
+ else if (!form.querySelector('[data-ws-txn-mode] option[selected]')) modeSel.value = cur.txn ? 'on' : '';
452
+ txnApply(txnInfo);
453
+ }
454
+
343
455
  // ------------------------------------------------------------ (sprint 97) a chart's settings draw it again
344
456
  form.querySelector('.ch-controls')?.addEventListener('change', () => run('chart'));
345
457
 
@@ -513,6 +625,7 @@
513
625
  const size = (key, px) => {
514
626
  const v = key === 'nav' ? Math.max(160, Math.min(px, window.innerWidth * 0.5)) : Math.max(80, Math.min(px, window.innerHeight * 0.75));
515
627
  form.style.setProperty(key === 'nav' ? '--ws-nav' : '--ws-out', `${Math.round(v)}px`);
628
+ form.querySelector(`[data-ws-split="${key}"]`)?.setAttribute('aria-valuenow', String(Math.round(v)));
516
629
  store.set(key, String(Math.round(v)));
517
630
  return v;
518
631
  };
@@ -520,6 +633,9 @@
520
633
  for (const sp of form.querySelectorAll('[data-ws-split]')) {
521
634
  const key = sp.dataset.wsSplit;
522
635
  const current = () => (key === 'nav' ? form.querySelector('.ws-nav').getBoundingClientRect().width : out.getBoundingClientRect().height);
636
+ // (sprint 108) a focusable separator states its position (in pixels)
637
+ sp.setAttribute('aria-valuemin', '0');
638
+ if (!sp.hasAttribute('aria-valuenow')) sp.setAttribute('aria-valuenow', String(Math.round(current())));
523
639
  sp.addEventListener('pointerdown', (e) => {
524
640
  e.preventDefault();
525
641
  sp.setPointerCapture(e.pointerId);
@@ -69,7 +69,7 @@ async function listPage(s: Session, q: Record<string, unknown>) {
69
69
  ${list.map((c) => html`<tr>
70
70
  <td data-label="#">${c.seq}</td>
71
71
  <td data-label="Change set" class="wrap-anywhere"><details><summary><code>${flywayName(c)}</code>${c.object_name ? html` <span class="muted small">${c.object_name}</span>` : ''}</summary>
72
- <pre class="source wrap-pre">${c.sql}</pre>
72
+ <pre class="source wrap-pre" data-code-view="sql" tabindex="0" aria-label="SQL">${c.sql}</pre>
73
73
  <form method="post" action="${CHANGES}/${c.id}" class="search u-mwnone">${csrf(s)}
74
74
  <div class="field"><label class="label" for="f_name_${c.id}">Name</label><input id="f_name_${c.id}" name="name" value="${c.name}" pattern="[a-z0-9][a-z0-9_]{0,59}" required></div>
75
75
  <button class="btn btn-sm u-selfend">Rename</button></form>
@@ -355,7 +355,7 @@ export async function dataModelerRoutes(app: FastifyInstance) {
355
355
  return reply.header('content-disposition', `attachment; filename="${fileName(d.name)}.sql"`).type('application/sql; charset=utf-8').send(script);
356
356
  const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler')}
357
357
  ${region(`DDL of ${d.name}`, html`<p class="muted u-mt0">The CREATE statements of the diagram's ${m.tables.length} objects in schema ${d.schema_name}: tables, their keys, indexes, triggers, row level security, policies, comments and grants, and views. Review before running.</p>
358
- <pre class="source" tabindex="0" aria-label="DDL">${script}</pre>
358
+ <pre class="source" data-code-view="sql" tabindex="0" aria-label="DDL">${script}</pre>
359
359
  <form method="post" action="${BASE}/sql">${csrf(s)}<input type="hidden" name="sql" value="${script}">
360
360
  <div class="buttons"><a class="btn" href="${M}/${d.id}">${bicon('back')} Back to the diagram</a><a class="btn" href="${M}/${d.id}/ddl?download=1">${icon('download')} Download</a>
361
361
  <button class="btn btn-hot">${icon('play')} Open in the SQL Worksheet</button></div></form>`)}`;
@@ -223,7 +223,7 @@ function reviewForm(s: Session, spec: RoleSpec, stmts: string[], o: { isNew: boo
223
223
  return html`${errorList(o.errors)}${o.output ?? ''}
224
224
  ${region('SQL', stmts.length
225
225
  ? html`<p class="muted u-mt0">These statements run in one transaction when you apply them: all of them, or none.${spec.password === 'set' ? ' The password shows as \'********\': pgkiln sends PostgreSQL only its SCRAM-SHA-256 verifier.' : ''}</p>
226
- <pre class="source dbu-sql" aria-label="Generated SQL">${maskSql(stmts)}</pre>`
226
+ <pre class="source dbu-sql" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${maskSql(stmts)}</pre>`
227
227
  : html`<p class="muted u-mt0">Nothing to change: the role is as the form says.</p>`)}
228
228
  <form method="post" action="${URL_BASE}/save" class="dbu-form">${csrf(s)}${hiddenSpec(spec, o.isNew)}
229
229
  <input type="hidden" name="reviewed" value="${reviewDigest(stmts)}">
@@ -409,7 +409,7 @@ async function dropPage(req: Req, reply: FastifyReply, s: Session, name: string,
409
409
  <option value=""${sel(to === '')}>${r.owned ? 'Drop them with the role (DROP OWNED)' : 'It owns nothing here: just revoke its privileges'}</option></select>
410
410
  <small class="help">DROP OWNED also revokes the privileges granted to the role in this database.</small></div>
411
411
  <div class="buttons"><button class="btn">${icon('eye')} Review SQL</button></div></form>`)}
412
- ${region('SQL', html`<pre class="source dbu-sql" aria-label="Generated SQL">${maskSql(stmts)}</pre>
412
+ ${region('SQL', html`<pre class="source dbu-sql" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${maskSql(stmts)}</pre>
413
413
  <form method="post" action="${URL_BASE}/drop" class="dbu-form">${csrf(s)}
414
414
  <input type="hidden" name="name" value="${r.name}"><input type="hidden" name="to" value="${to}"><input type="hidden" name="reviewed" value="${reviewDigest(stmts)}">
415
415
  ${!to && r.owned ? html`<label class="check u-mb1"><input type="checkbox" name="drop_objects" value="1" required> Drop the ${String(r.owned)} object${r.owned === 1 ? '' : 's'} ${r.name} owns, with their data</label>` : ''}
@@ -279,22 +279,22 @@ export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s
279
279
  const ddlOf = async (match: (x: { kind: string; name: string; table?: string }) => boolean) => {
280
280
  const objects = await readSchema(db.pool as pg.Pool, schema).catch(() => []);
281
281
  const text = objects.filter(match).map((x) => x.ddl).join('\n\n');
282
- return text ? html`<pre class="source">${text}</pre>` : html`<p class="muted">No DDL (an extension's object, or not readable with this connection).</p>`;
282
+ return text ? html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="DDL of ${o.name}">${text}</pre>` : html`<p class="muted">No DDL (an extension's object, or not readable with this connection).</p>`;
283
283
  };
284
284
  const relname = o.ref.includes('.') ? o.ref.slice(o.ref.lastIndexOf('.') + 1).replace(/^"|"$/g, '') : o.ref.replace(/^"|"$/g, '');
285
285
 
286
286
  if (o.type === 'function' || o.type === 'procedure') {
287
287
  const src = await db.one<{ def: string }>('select pg_get_functiondef($1::regprocedure) as def', [o.ref]).catch(() => undefined);
288
- return region(o.name, src ? html`<pre class="source">${src.def}</pre>` : html`<p class="muted">An aggregate or window function: no source.</p>`);
288
+ return region(o.name, src ? html`<pre class="source" data-code-view="plpgsql" tabindex="0" aria-label="Source of ${o.name}">${src.def}</pre>` : html`<p class="muted">An aggregate or window function: no source.</p>`);
289
289
  }
290
290
  if (o.type === 'trigger') {
291
291
  const [rel, name] = o.ref.split('|');
292
292
  const t = await db.one<{ def: string }>('select pg_get_triggerdef(oid, true) as def from pg_trigger where tgrelid = $1::regclass and tgname = $2', [rel, name]);
293
- return region(o.name, html`<pre class="source">${t?.def ?? ''}</pre>`);
293
+ return region(o.name, html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="Definition of ${o.name}">${t?.def ?? ''}</pre>`);
294
294
  }
295
295
  if (o.type === 'index') {
296
296
  const i = await db.one<{ def: string; size: string }>('select pg_get_indexdef($1::regclass) as def, pg_size_pretty(pg_relation_size($1::regclass)) as size', [o.ref]);
297
- return region(o.name, html`<pre class="source">${i?.def ?? ''};</pre><p class="muted">Size: ${i?.size ?? ''}</p>`);
297
+ return region(o.name, html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="Definition of ${o.name}">${i?.def ?? ''};</pre><p class="muted">Size: ${i?.size ?? ''}</p>`);
298
298
  }
299
299
  if (o.type === 'sequence') {
300
300
  const q = await db.one<Record<string, unknown>>(
@@ -361,7 +361,7 @@ export async function queryBuilderRoutes(app: FastifyInstance) {
361
361
  </form>`;
362
362
 
363
363
  const output = built
364
- ? html`<div class="u-spacer"></div>${region('SQL', html`<pre class="source" tabindex="0" aria-label="Generated SQL">${built.sql}</pre>
364
+ ? html`<div class="u-spacer"></div>${region('SQL', html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${built.sql}</pre>
365
365
  <form method="post" action="${BASE}/sql">${csrf(s)}<input type="hidden" name="sql" value="${built.sql}">
366
366
  <div class="buttons"><button class="btn btn-hot">${icon('play')} Run in the SQL Worksheet</button></div></form>`)}`
367
367
  : '';
@@ -43,7 +43,7 @@ export async function quickSqlRoutes(app: FastifyInstance) {
43
43
  ${region('PostgreSQL', html`${warnings.length
44
44
  ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${warnings.map((w) => html`<li>Line ${w.line}: ${w.message}</li>`)}</ul></div>`
45
45
  : ''}
46
- <pre class="source ddl" tabindex="0" aria-label="Generated SQL">${ddl}</pre>`)}
46
+ <pre class="source ddl" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${ddl}</pre>`)}
47
47
  </div>
48
48
  <div class="u-spacer"></div>
49
49
  ${region('Save or run', html`<div class="form-grid"><div class="field"><label class="label" for="f_name">Script name</label>
@@ -237,7 +237,7 @@ const hiddenSpec = (spec: JobSpec, jobid: string | null) => html`<input type="hi
237
237
  /** The review step of any change: the SQL, Apply (with the digest), Back. */
238
238
  function reviewRegion(s: Session, stmts: string[], action: string, hidden: Raw, o: { back?: Raw | ''; apply?: string; danger?: boolean; extra?: Raw | '' } = {}) {
239
239
  return html`${region('SQL', stmts.length
240
- ? html`<p class="muted u-mt0">These statements run in one transaction on pgkiln's owner connection when you apply them: all of them, or none.</p><pre class="source cron-sql" aria-label="Generated SQL">${stmts.join('\n\n')}</pre>`
240
+ ? html`<p class="muted u-mt0">These statements run in one transaction on pgkiln's owner connection when you apply them: all of them, or none.</p><pre class="source cron-sql" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${stmts.join('\n\n')}</pre>`
241
241
  : html`<p class="muted u-mt0">Nothing to change.</p>`)}
242
242
  <form method="post" action="${action}" class="cron-form">${csrf(s)}${hidden}<input type="hidden" name="reviewed" value="${reviewDigest(stmts)}">${o.extra ?? ''}
243
243
  <div class="buttons">${stmts.length ? html`<button class="btn ${o.danger ? 'btn-danger' : 'btn-hot'}" name="mode" value="apply">${icon(o.danger ? 'trash' : 'check')} ${o.apply ?? 'Apply'}</button>` : ''}${o.back ?? ''}</div></form>`;
@@ -320,7 +320,7 @@ async function jobPage(reply: FastifyReply, s: Session, st: Extract<CronState, {
320
320
  <dt>State</dt><dd>${job.active ? 'Active' : html`<span class="tag">inactive</span>`}</dd>
321
321
  <dt>Schedule</dt><dd><code>${job.schedule}</code></dd>
322
322
  <dt>Database</dt><dd>${job.database}</dd><dt>Run as</dt><dd>${job.username}</dd><dt>Job id</dt><dd>${job.jobid}</dd>
323
- </dl><pre class="source cron-sql" aria-label="Command">${job.command}</pre>`)}
323
+ </dl><pre class="source cron-sql" data-code-view="sql" tabindex="0" aria-label="Command">${job.command}</pre>`)}
324
324
  ${region('Schedule', schedulePreview(job.schedule, st.timeZone))}
325
325
  ${region('Recent runs', html`${runsTable(runs, false)}<p class="small"><a href="${URL_BASE}/history?job=${job.jobid}">All runs of this job</a> · <a href="${URL_BASE}/history/purge?job=${job.jobid}">Purge its history</a></p>`)}`;
326
326
  return send(reply, s, shell(s, name, crumbs([name]), main, 'sql'));
@@ -92,7 +92,7 @@ export function resultsHtml(results: StatementResult[]) {
92
92
  (r) => html`<li class="script-result">
93
93
  <div class="script-result-head">${statusTag(r)} <strong>#${r.n || '-'}</strong>
94
94
  <span class="muted small">${r.line ? `line ${r.line}` : ''}${r.status === 'ok' ? ` · ${r.command ?? 'done'}${r.rows !== null && r.rows !== undefined ? `, ${r.rows} row(s)` : ''}` : ''}${r.status === 'ok' || r.status === 'error' ? ` · ${r.ms} ms` : ''}</span></div>
95
- <details class="script-sql"><summary><code>${r.sql.length > 120 ? `${r.sql.slice(0, 120).replace(/\s+/g, ' ')}…` : r.sql.replace(/\s+/g, ' ')}</code></summary><pre class="source">${r.sql}</pre></details>
95
+ <details class="script-sql"><summary><code>${r.sql.length > 120 ? `${r.sql.slice(0, 120).replace(/\s+/g, ' ')}…` : r.sql.replace(/\s+/g, ' ')}</code></summary><pre class="source" data-code-view="sql" tabindex="0" aria-label="Statement">${r.sql}</pre></details>
96
96
  ${r.error ? html`<div class="alert ${r.status === 'error' ? 'alert-error' : 'alert-note'}">${r.error}</div>` : ''}
97
97
  ${r.columns?.length
98
98
  ? html`<div class="table-wrap"><table class="report"><thead><tr>${r.columns.map((c) => html`<th>${c}</th>`)}</tr></thead>
@@ -130,7 +130,7 @@ export async function sqlChartRoutes(app: FastifyInstance) {
130
130
  <div class="field"><label class="label" for="ch-name">Name</label><input id="ch-name" name="name" value="${ch.name}" maxlength="120" required></div>
131
131
  ${controls}
132
132
  <label class="check"><input type="checkbox" name="on_dashboard"${ch.on_dashboard ? raw(' checked') : ''}> On the Charts dashboard</label>
133
- <details class="u-mt1"><summary>The query</summary><pre class="source">${ch.sql}</pre></details>
133
+ <details class="u-mt1"><summary>The query</summary><pre class="source" data-code-view="sql" tabindex="0" aria-label="The query">${ch.sql}</pre></details>
134
134
  <div class="buttons"><button class="btn btn-hot" name="action" value="save">${icon('save')} Save</button>
135
135
  <button class="btn" name="action" value="worksheet">${icon('code')} Open in the worksheet</button>
136
136
  <button class="btn btn-danger" name="action" value="delete" data-confirm="Delete the chart ${ch.name}?">${icon('trash')} Delete</button></div>
@@ -369,7 +369,7 @@ export async function sqlMonitorRoutes(app: FastifyInstance) {
369
369
  const main = html`<h1 class="u-mb1">Statement</h1>
370
370
  <div class="stat-grid u-mb1">${tile('Calls', num(calls))}${tile('Total ms', num(total))}${tile('Mean ms', num(calls ? total / calls : 0))}${tile('Rows', num(sum('rows')))}
371
371
  ${tile('Blocks hit', num(sum('hit')))}${tile('Blocks read', num(sum('read')))}${tile('Temp blocks written', num(sum('temp_written')))}</div>
372
- ${region('Text', html`<pre class="source u-break sm-sql">${sql}</pre>
372
+ ${region('Text', html`<pre class="source u-break sm-sql" data-code-view="sql" tabindex="0" aria-label="SQL text">${sql}</pre>
373
373
  <div class="buttons"><form method="post" action="${URL_BASE}/statements/${queryid}/worksheet">${csrf(s)}<button class="btn">${icon('code')} Open in worksheet</button></form>
374
374
  <a class="btn" href="${URL_BASE}/top">Back to Top SQL</a></div>`)}
375
375
  ${region('By role', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Role</th><th class="num">Calls</th><th class="num">Total ms</th><th class="num">Mean ms</th><th class="num">Std dev ms</th><th class="num">Min ms</th><th class="num">Max ms</th><th class="num">Rows</th><th class="num">Hit</th><th class="num">Read</th><th class="num">Temp read</th><th class="num">Temp written</th></tr></thead>
@@ -121,7 +121,7 @@ function form(s: Session, def: TableDef, opts: { editing: TableDef | null; schem
121
121
  <div class="field"><label class="label" for="te-part_by">Partition by</label><select id="te-part_by" name="part_by"><option value="">Not partitioned</option>${(['range', 'list', 'hash'] as const).map((p) => html`<option value="${p}"${sel(p, def.partitionBy)}>${p}</option>`)}</select></div>
122
122
  <div class="field"><label class="label" for="te-part_key">Key</label>${input('part_key', def.partitionKey, 'Key', 'class="code" placeholder="created_at"')}</div></div>
123
123
  <small class="help">The partitions themselves are tables made with CREATE TABLE … PARTITION OF in the worksheet.</small></fieldset>`}`],
124
- ['ddl', 'DDL', opts.ddl.length ? html`<pre class="source te-ddl">${opts.ddl.join('\n\n')}</pre>` : html`<p class="muted">${editing ? 'No changes yet: the table is as the form shows it.' : 'Fill in the table and press Preview DDL.'}</p>`],
124
+ ['ddl', 'DDL', opts.ddl.length ? html`<pre class="source te-ddl" data-code-view="sql" tabindex="0" aria-label="DDL">${opts.ddl.join('\n\n')}</pre>` : html`<p class="muted">${editing ? 'No changes yet: the table is as the form shows it.' : 'Fill in the table and press Preview DDL.'}</p>`],
125
125
  ['output', 'Output', opts.output || html`<p class="muted">Apply shows what happened here.</p>`],
126
126
  ];
127
127
  const tab = sections.some(([k]) => k === opts.tab) ? opts.tab : opts.errors.length ? 'columns' : 'columns';
@@ -203,7 +203,7 @@ async function page(req: Req, reply: FastifyReply) {
203
203
  const now = await readTable(db, `${pgIdent(def.schema)}.${pgIdent(def.name)}`);
204
204
  return back(reply, s, `${BASE}/sql/objects/table?${new URLSearchParams({ o: now ? `${pgIdent(now.schema)}.${pgIdent(now.name)}` : '' })}`);
205
205
  }
206
- output = html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p><pre class="source">${at}</pre></div>`;
206
+ output = html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p><pre class="source" data-code-view="sql" tabindex="0" aria-label="Failed statement">${at}</pre></div>`;
207
207
  tab = 'output';
208
208
  }
209
209
  if (errors.length) tab = 'columns';