v0.28.1 cc7ba64

issue-form.js RawHistoryFolder

1// issue-form.js: the public issue form (/<repo>/issues/new for a visitor).
2// The server sends a proof of work with the form; this script starts the
3// worker (pow-worker.js) when the page loads, so it solves while the visitor
4// writes, shows the length counters, and enables the button once the solution
5// is found and the minimum fill time has passed. Without scripts the form
6// stays hidden (the page says why).
7//
8// window.ermiteIssueForm.core is pure (jstest/issue-form.test.mjs).
9window.ermiteIssueForm = (() => {
10  const core = {
11    // counterClass is the class of a length counter: "over" above max, "warn"
12    // from 90 % of it, else none.
13    counterClass(n, max) {
14      if (n > max) return 'over';
15      if (n * 10 >= max * 9) return 'warn';
16      return '';
17    },
18    // length counts code points, like the server.
19    length(s) { return [...String(s)].length; },
20    // serverLength is the length the server will see for field ('title',
21    // 'body' or 'author'): NFC, control and bidi characters removed (body
22    // keeps \n and \t), title and author trimmed (domain.cleanText).
23    serverLength(field, value) {
24      const multiline = field === 'body';
25      let s = String(value).normalize('NFC');
26      if (multiline) s = s.replace(/\r\n/g, '\n');
27      let out = '';
28      for (const ch of s) {
29        const c = ch.codePointAt(0);
30        if (multiline && (c === 0x0a || c === 0x09)) { out += ch; continue; }
31        if (c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) continue;
32        if (c === 0x200e || c === 0x200f || c === 0x061c ||
33            (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) continue;
34        out += ch;
35      }
36      if (!multiline) out = out.trim();
37      return [...out].length;
38    },
39    draftKey(path) { return 'ermite:issue-draft:' + path; },
40    // saveDraft keeps the text fields in storage (never the solution).
41    saveDraft(storage, key, draft) {
42      try { storage.setItem(key, JSON.stringify({ title: draft.title, body: draft.body, author: draft.author })); } catch (_) { /* storage unavailable */ }
43    },
44    // restoreDraft returns the saved draft, or null, and clears it.
45    restoreDraft(storage, key) {
46      try {
47        const raw = storage.getItem(key);
48        if (raw === null || raw === undefined) return null;
49        storage.removeItem(key);
50        const d = JSON.parse(raw);
51        if (!d || typeof d !== 'object') return null;
52        return { title: String(d.title || ''), body: String(d.body || ''), author: String(d.author || '') };
53      } catch (_) { return null; }
54    },
55    // onPageShow decides what to do on pageshow: a page restored from the
56    // back/forward cache has a challenge that may be spent or expired.
57    onPageShow(persisted) { return persisted === true ? 'reload' : 'none'; },
58    // state is what the button and the status line show.
59    //   {solved, now, minAt, sending, unsupported, failed, overs, empty}
60    state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) {
61      if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };
62      if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };
63      if (sending) return { disabled: true, status: 'Sending…' };
64      if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };
65      if (now < minAt) return { disabled: true, status: 'Almost ready…' };
66      if (overs) return { disabled: true, status: 'A field is too long.' };
67      if (empty) return { disabled: true, status: 'Enter a title.' };
68      return { disabled: false, status: 'Ready.' };
69    },
70  };
71
72  function init(form) {
73    const $ = (sel) => form.querySelector(sel);
74    const button = $('button[type="submit"]');
75    const status = $('[data-status]');
76    const solution = $('input[name="solution"]');
77    const minAt = Number(form.dataset.minSubmitAt) || 0;
78    const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true };
79    let timer = null;
80
81    function render() {
82      const s = core.state({ ...ctx, now: Date.now(), minAt });
83      button.disabled = s.disabled;
84      if (status) status.textContent = s.status;
85      clearTimeout(timer);
86      if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);
87    }
88
89    const storage = (() => { try { return window.sessionStorage; } catch (_) { return null; } })();
90    const draftKey = core.draftKey(window.location ? window.location.pathname : '');
91    const fields = { title: $('[name="title"]'), body: $('[name="body"]'), author: $('[name="author"]') };
92    if (storage) {
93      const d = core.restoreDraft(storage, draftKey);
94      if (d) for (const k of Object.keys(fields)) if (fields[k]) fields[k].value = d[k];
95    }
96    window.addEventListener('pageshow', (e) => {
97      if (core.onPageShow(e.persisted) !== 'reload') return;
98      // The old challenge may be spent or expired: never reuse its solution.
99      if (button) button.disabled = true;
100      if (storage) {
101        core.saveDraft(storage, draftKey, {
102          title: fields.title ? fields.title.value : '',
103          body: fields.body ? fields.body.value : '',
104          author: fields.author ? fields.author.value : '',
105        });
106      }
107      window.location.reload();
108    });
109
110    const outs = [...form.querySelectorAll('output[data-for]')];
111    for (const out of outs) {
112      const field = form.querySelector('#' + out.dataset.for);
113      const name = field.name;
114      const max = Number(out.dataset.max);
115      const update = () => {
116        const n = core.serverLength(name, field.value);
117        out.textContent = n + ' / ' + max;
118        out.className = core.counterClass(n, max);
119        ctx.overs = outs.some((o) => o.className === 'over');
120        if (name === 'title') ctx.empty = n === 0;
121        render();
122      };
123      field.addEventListener('input', update);
124      update();
125    }
126
127    form.addEventListener('submit', (e) => {
128      if (ctx.sending || !ctx.solved || Date.now() < minAt) { e.preventDefault(); return; }
129      ctx.sending = true;
130      render();
131    });
132
133    form.hidden = false;
134    form.removeAttribute('hidden');
135
136    if (!window.Worker || !window.crypto || !window.crypto.subtle || !form.dataset.worker) {
137      ctx.unsupported = true;
138      render();
139      return;
140    }
141    render();
142    let worker;
143    try {
144      worker = new window.Worker(form.dataset.worker);
145    } catch (_) {
146      ctx.unsupported = true;
147      render();
148      return;
149    }
150    worker.onmessage = (e) => {
151      const m = e.data || {};
152      if (m.solution !== undefined) {
153        solution.value = m.solution;
154        ctx.solved = true;
155        worker.terminate();
156      } else if (m.error === 'nocrypto') {
157        ctx.unsupported = true;
158      } else if (m.error) {
159        ctx.failed = true;
160      } else {
161        return;
162      }
163      render();
164    };
165    worker.onerror = () => { ctx.failed = true; render(); };
166    worker.postMessage({ nonce: form.dataset.nonce, difficulty: Number(form.dataset.difficulty) });
167  }
168
169  return { core, init };
170})();
171
172document.addEventListener('DOMContentLoaded', () => {
173  const form = document.querySelector('form[data-issue-form]');
174  if (form) window.ermiteIssueForm.init(form);
175});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close