detached bc78630

fix(web): count issue form limits like the server and refresh the challenge after back navigation

commit
bc786300b89e85722d296fbef5ff74982fd274aa
author
brice · · 2026-10-19 07:55 UTC
parent
e687d9c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +167 -16
M internal/adapters/web/issues_form.templ +3 -3
@@ -29,19 +29,19 @@ templ IssueForm(l Layout, p IssueFormPage) {
2929 <input type="hidden" name="form_token" value={ p.FormToken }/>
3030 <input type="hidden" name="solution" value=""/>
3131 <label for="issue-title">title</label>
32- <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" value={ p.Title }/>
32+ <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/>
3333 <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
3434 if p.ErrorField == "title" {
3535 <p class="f-error" role="alert">{ p.Error }</p>
3636 }
3737 <label for="issue-body">description</label>
38- <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional">{ p.Body }</textarea>
38+ <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea>
3939 <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
4040 if p.ErrorField == "body" {
4141 <p class="f-error" role="alert">{ p.Error }</p>
4242 }
4343 <label for="issue-author">name (optional, not verified)</label>
44- <input id="issue-author" name="author" type="text" maxlength="40" autocomplete="off" value={ p.Author }/>
44+ <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/>
4545 <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
4646 if p.ErrorField == "author" {
4747 <p class="f-error" role="alert">{ p.Error }</p>
@@ -29,19 +29,19 @@ templ IssueForm(l Layout, p IssueFormPage) {
29 <input type="hidden" name="form_token" value={ p.FormToken }/>29 <input type="hidden" name="form_token" value={ p.FormToken }/>
30 <input type="hidden" name="solution" value=""/>30 <input type="hidden" name="solution" value=""/>
31 <label for="issue-title">title</label>31 <label for="issue-title">title</label>
32- <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" value={ p.Title }/>32+ <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/>
33 <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>33 <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
34 if p.ErrorField == "title" {34 if p.ErrorField == "title" {
35 <p class="f-error" role="alert">{ p.Error }</p>35 <p class="f-error" role="alert">{ p.Error }</p>
36 }36 }
37 <label for="issue-body">description</label>37 <label for="issue-body">description</label>
38- <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional">{ p.Body }</textarea>38+ <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea>
39 <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>39 <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
40 if p.ErrorField == "body" {40 if p.ErrorField == "body" {
41 <p class="f-error" role="alert">{ p.Error }</p>41 <p class="f-error" role="alert">{ p.Error }</p>
42 }42 }
43 <label for="issue-author">name (optional, not verified)</label>43 <label for="issue-author">name (optional, not verified)</label>
44- <input id="issue-author" name="author" type="text" maxlength="40" autocomplete="off" value={ p.Author }/>44+ <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/>
45 <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>45 <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
46 if p.ErrorField == "author" {46 if p.ErrorField == "author" {
47 <p class="f-error" role="alert">{ p.Error }</p>47 <p class="f-error" role="alert">{ p.Error }</p>
M internal/adapters/web/issues_form_templ.go +6 -6
@@ -137,14 +137,14 @@ func IssueForm(l Layout, p IssueFormPage) templ.Component {
137137 if templ_7745c5c3_Err != nil {
138138 return templ_7745c5c3_Err
139139 }
140- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" name=\"title\" type=\"text\" maxlength=\"120\" required autocomplete=\"off\" value=\"")
140+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" name=\"title\" type=\"text\" required autocomplete=\"off\" value=\"")
141141 if templ_7745c5c3_Err != nil {
142142 return templ_7745c5c3_Err
143143 }
144144 var templ_7745c5c3_Var10 string
145145 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)
146146 if templ_7745c5c3_Err != nil {
147- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 32, Col: 111}
147+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 32, Col: 95}
148148 }
149149 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
150150 if templ_7745c5c3_Err != nil {
@@ -212,14 +212,14 @@ func IssueForm(l Layout, p IssueFormPage) templ.Component {
212212 return templ_7745c5c3_Err
213213 }
214214 }
215- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" name=\"body\" rows=\"8\" maxlength=\"10000\" placeholder=\"Markdown, optional\">")
215+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" name=\"body\" rows=\"8\" placeholder=\"Markdown, optional\">")
216216 if templ_7745c5c3_Err != nil {
217217 return templ_7745c5c3_Err
218218 }
219219 var templ_7745c5c3_Var15 string
220220 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)
221221 if templ_7745c5c3_Err != nil {
222- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 38, Col: 109}
222+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 38, Col: 91}
223223 }
224224 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
225225 if templ_7745c5c3_Err != nil {
@@ -287,14 +287,14 @@ func IssueForm(l Layout, p IssueFormPage) templ.Component {
287287 return templ_7745c5c3_Err
288288 }
289289 }
290- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" name=\"author\" type=\"text\" maxlength=\"40\" autocomplete=\"off\" value=\"")
290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" name=\"author\" type=\"text\" autocomplete=\"off\" value=\"")
291291 if templ_7745c5c3_Err != nil {
292292 return templ_7745c5c3_Err
293293 }
294294 var templ_7745c5c3_Var20 string
295295 templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)
296296 if templ_7745c5c3_Err != nil {
297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 104}
297+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 89}
298298 }
299299 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
300300 if templ_7745c5c3_Err != nil {
@@ -137,14 +137,14 @@ func IssueForm(l Layout, p IssueFormPage) templ.Component {
137 if templ_7745c5c3_Err != nil {137 if templ_7745c5c3_Err != nil {
138 return templ_7745c5c3_Err138 return templ_7745c5c3_Err
139 }139 }
140- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" name=\"title\" type=\"text\" maxlength=\"120\" required autocomplete=\"off\" value=\"")140+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" name=\"title\" type=\"text\" required autocomplete=\"off\" value=\"")
141 if templ_7745c5c3_Err != nil {141 if templ_7745c5c3_Err != nil {
142 return templ_7745c5c3_Err142 return templ_7745c5c3_Err
143 }143 }
144 var templ_7745c5c3_Var10 string144 var templ_7745c5c3_Var10 string
145 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)145 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)
146 if templ_7745c5c3_Err != nil {146 if templ_7745c5c3_Err != nil {
147- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 32, Col: 111}147+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 32, Col: 95}
148 }148 }
149 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)149 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
150 if templ_7745c5c3_Err != nil {150 if templ_7745c5c3_Err != nil {
@@ -212,14 +212,14 @@ func IssueForm(l Layout, p IssueFormPage) templ.Component {
212 return templ_7745c5c3_Err212 return templ_7745c5c3_Err
213 }213 }
214 }214 }
215- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" name=\"body\" rows=\"8\" maxlength=\"10000\" placeholder=\"Markdown, optional\">")215+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" name=\"body\" rows=\"8\" placeholder=\"Markdown, optional\">")
216 if templ_7745c5c3_Err != nil {216 if templ_7745c5c3_Err != nil {
217 return templ_7745c5c3_Err217 return templ_7745c5c3_Err
218 }218 }
219 var templ_7745c5c3_Var15 string219 var templ_7745c5c3_Var15 string
220 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)220 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)
221 if templ_7745c5c3_Err != nil {221 if templ_7745c5c3_Err != nil {
222- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 38, Col: 109}222+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 38, Col: 91}
223 }223 }
224 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))224 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
225 if templ_7745c5c3_Err != nil {225 if templ_7745c5c3_Err != nil {
@@ -287,14 +287,14 @@ func IssueForm(l Layout, p IssueFormPage) templ.Component {
287 return templ_7745c5c3_Err287 return templ_7745c5c3_Err
288 }288 }
289 }289 }
290- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" name=\"author\" type=\"text\" maxlength=\"40\" autocomplete=\"off\" value=\"")290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" name=\"author\" type=\"text\" autocomplete=\"off\" value=\"")
291 if templ_7745c5c3_Err != nil {291 if templ_7745c5c3_Err != nil {
292 return templ_7745c5c3_Err292 return templ_7745c5c3_Err
293 }293 }
294 var templ_7745c5c3_Var20 string294 var templ_7745c5c3_Var20 string
295 templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)295 templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)
296 if templ_7745c5c3_Err != nil {296 if templ_7745c5c3_Err != nil {
297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 104}297+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 89}
298 }298 }
299 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)299 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
300 if templ_7745c5c3_Err != nil {300 if templ_7745c5c3_Err != nil {
M internal/adapters/web/issues_form_test.go +2 -1
@@ -79,13 +79,14 @@ func TestIssueFormGet(t *testing.T) {
7979 `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
8080 `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
8181 `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82- `name="title" type="text" maxlength="120" required`, `maxlength="10000"`, `name="author" type="text" maxlength="40"`,
82+ `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`,
8383 `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
8484 `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
8585 `/-/static/js/issue-form.`,
8686 } {
8787 assert.Contains(t, body, want)
8888 }
89+ assert.NotContains(t, body, `maxlength`, "the limits count code points, not UTF-16 units")
8990 assert.NotContains(t, body, `name="website" type="hidden"`)
9091 assert.NotContains(t, body, `type="hidden" name="website"`)
9192 noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")]
@@ -79,13 +79,14 @@ func TestIssueFormGet(t *testing.T) {
79 `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,79 `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
80 `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,80 `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
81 `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,81 `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82- `name="title" type="text" maxlength="120" required`, `maxlength="10000"`, `name="author" type="text" maxlength="40"`,82+ `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`,
83 `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,83 `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
84 `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,84 `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
85 `/-/static/js/issue-form.`,85 `/-/static/js/issue-form.`,
86 } {86 } {
87 assert.Contains(t, body, want)87 assert.Contains(t, body, want)
88 }88 }
89+ assert.NotContains(t, body, `maxlength`, "the limits count code points, not UTF-16 units")
89 assert.NotContains(t, body, `name="website" type="hidden"`)90 assert.NotContains(t, body, `name="website" type="hidden"`)
90 assert.NotContains(t, body, `type="hidden" name="website"`)91 assert.NotContains(t, body, `type="hidden" name="website"`)
91 noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")]92 noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")]
M internal/adapters/web/jstest/issue-form.test.mjs +87 -0
@@ -94,3 +94,90 @@ test('state: the button opens only when solved, waited and not over', () => {
9494 assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true);
9595 assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true);
9696 });
97+
98+test('serverLength counts NFC code points like the server', () => {
99+ assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61);
100+ assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122);
101+ assert.equal(C.counterClass(122, 120), 'over');
102+ assert.equal(C.serverLength('title', '😀'.repeat(120)), 120);
103+ assert.equal(C.counterClass(120, 120), 'warn');
104+ assert.equal(C.serverLength('title', '😀'.repeat(121)), 121);
105+ assert.equal(C.counterClass(121, 120), 'over');
106+});
107+
108+test('serverLength drops what the server strips and trims', () => {
109+ assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2);
110+ assert.equal(C.serverLength('title', ' \u202e '), 0);
111+ assert.equal(C.serverLength('author', ' me '), 2);
112+ assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6);
113+ assert.equal(C.serverLength('body', ' a '), 3);
114+ assert.equal(C.serverLength('title', 'a\tb'), 2);
115+});
116+
117+test('state keeps the button disabled when a title is empty or a field is over', () => {
118+ const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false };
119+ assert.equal(C.state(base).disabled, false);
120+ assert.equal(C.state({ ...base, overs: true }).disabled, true);
121+ assert.equal(C.state({ ...base, empty: true }).disabled, true);
122+});
123+
124+function fakeStorage() {
125+ const m = new Map();
126+ return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m };
127+}
128+
129+test('drafts are saved, restored once, and scoped to the path', () => {
130+ const st = fakeStorage();
131+ const k = C.draftKey('/r/issues/new');
132+ assert.notEqual(k, C.draftKey('/other/issues/new'));
133+ C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' });
134+ assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' });
135+ assert.equal(st.m.size, 0);
136+ assert.equal(C.restoreDraft(st, k), null);
137+ st.setItem(k, 'not json');
138+ assert.equal(C.restoreDraft(st, k), null);
139+});
140+
141+test('onPageShow reloads only for a bfcache restore', () => {
142+ assert.equal(C.onPageShow(true), 'reload');
143+ assert.equal(C.onPageShow(false), 'none');
144+ assert.equal(C.onPageShow(undefined), 'none');
145+});
146+
147+test('init restores a draft and reloads on a bfcache pageshow', () => {
148+ const listeners = {};
149+ const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o });
150+ const title = mk({ id: 'issue-title', name: 'title', value: '' });
151+ const body = mk({ id: 'issue-body', name: 'body', value: '' });
152+ const author = mk({ id: 'issue-author', name: 'author', value: '' });
153+ const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' }));
154+ const button = { disabled: false };
155+ const solution = { value: '' };
156+ const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution,
157+ '[name="title"]': title, '[name="body"]': body, '[name="author"]': author,
158+ '#issue-title': title, '#issue-body': body, '#issue-author': author };
159+ const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null,
160+ querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} };
161+ const st = fakeStorage();
162+ let reloads = 0;
163+ const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker };
164+ Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } },
165+ addEventListener(t, f) { listeners[t] = f; }, Worker: undefined });
166+ try {
167+ C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' });
168+ window.ermiteIssueForm.init(form);
169+ assert.equal(title.value, 'Hello');
170+ assert.equal(outs[0].textContent, '5 / 120');
171+ assert.equal(st.m.size, 0);
172+ assert.equal(button.disabled, true);
173+ listeners.pageshow({ persisted: false });
174+ assert.equal(reloads, 0);
175+ title.value = 'Edited';
176+ listeners.pageshow({ persisted: true });
177+ assert.equal(reloads, 1);
178+ assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' });
179+ assert.equal(button.disabled, true);
180+ } finally {
181+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; }
182+ }
183+});
@@ -94,3 +94,90 @@ test('state: the button opens only when solved, waited and not over', () => {
94 assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true);94 assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true);
95 assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true);95 assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true);
96 });96 });
97+
98+test('serverLength counts NFC code points like the server', () => {
99+ assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61);
100+ assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122);
101+ assert.equal(C.counterClass(122, 120), 'over');
102+ assert.equal(C.serverLength('title', '😀'.repeat(120)), 120);
103+ assert.equal(C.counterClass(120, 120), 'warn');
104+ assert.equal(C.serverLength('title', '😀'.repeat(121)), 121);
105+ assert.equal(C.counterClass(121, 120), 'over');
106+});
107+
108+test('serverLength drops what the server strips and trims', () => {
109+ assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2);
110+ assert.equal(C.serverLength('title', ' \u202e '), 0);
111+ assert.equal(C.serverLength('author', ' me '), 2);
112+ assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6);
113+ assert.equal(C.serverLength('body', ' a '), 3);
114+ assert.equal(C.serverLength('title', 'a\tb'), 2);
115+});
116+
117+test('state keeps the button disabled when a title is empty or a field is over', () => {
118+ const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false };
119+ assert.equal(C.state(base).disabled, false);
120+ assert.equal(C.state({ ...base, overs: true }).disabled, true);
121+ assert.equal(C.state({ ...base, empty: true }).disabled, true);
122+});
123+
124+function fakeStorage() {
125+ const m = new Map();
126+ return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m };
127+}
128+
129+test('drafts are saved, restored once, and scoped to the path', () => {
130+ const st = fakeStorage();
131+ const k = C.draftKey('/r/issues/new');
132+ assert.notEqual(k, C.draftKey('/other/issues/new'));
133+ C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' });
134+ assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' });
135+ assert.equal(st.m.size, 0);
136+ assert.equal(C.restoreDraft(st, k), null);
137+ st.setItem(k, 'not json');
138+ assert.equal(C.restoreDraft(st, k), null);
139+});
140+
141+test('onPageShow reloads only for a bfcache restore', () => {
142+ assert.equal(C.onPageShow(true), 'reload');
143+ assert.equal(C.onPageShow(false), 'none');
144+ assert.equal(C.onPageShow(undefined), 'none');
145+});
146+
147+test('init restores a draft and reloads on a bfcache pageshow', () => {
148+ const listeners = {};
149+ const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o });
150+ const title = mk({ id: 'issue-title', name: 'title', value: '' });
151+ const body = mk({ id: 'issue-body', name: 'body', value: '' });
152+ const author = mk({ id: 'issue-author', name: 'author', value: '' });
153+ const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' }));
154+ const button = { disabled: false };
155+ const solution = { value: '' };
156+ const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution,
157+ '[name="title"]': title, '[name="body"]': body, '[name="author"]': author,
158+ '#issue-title': title, '#issue-body': body, '#issue-author': author };
159+ const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null,
160+ querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} };
161+ const st = fakeStorage();
162+ let reloads = 0;
163+ const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker };
164+ Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } },
165+ addEventListener(t, f) { listeners[t] = f; }, Worker: undefined });
166+ try {
167+ C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' });
168+ window.ermiteIssueForm.init(form);
169+ assert.equal(title.value, 'Hello');
170+ assert.equal(outs[0].textContent, '5 / 120');
171+ assert.equal(st.m.size, 0);
172+ assert.equal(button.disabled, true);
173+ listeners.pageshow({ persisted: false });
174+ assert.equal(reloads, 0);
175+ title.value = 'Edited';
176+ listeners.pageshow({ persisted: true });
177+ assert.equal(reloads, 1);
178+ assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' });
179+ assert.equal(button.disabled, true);
180+ } finally {
181+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; }
182+ }
183+});
M internal/adapters/web/static/js/issue-form.js +69 -6
@@ -17,15 +17,54 @@ window.ermiteIssueForm = (() => {
1717 },
1818 // length counts code points, like the server.
1919 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'; },
2058 // state is what the button and the status line show.
21- // {solved, now, minAt, sending, unsupported, failed, overs}
22- state({ solved, now, minAt, sending, unsupported, failed, overs }) {
59+ // {solved, now, minAt, sending, unsupported, failed, overs, empty}
60+ state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) {
2361 if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };
2462 if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };
2563 if (sending) return { disabled: true, status: 'Sending…' };
2664 if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };
2765 if (now < minAt) return { disabled: true, status: 'Almost ready…' };
2866 if (overs) return { disabled: true, status: 'A field is too long.' };
67+ if (empty) return { disabled: true, status: 'Enter a title.' };
2968 return { disabled: false, status: 'Ready.' };
3069 },
3170 };
@@ -36,7 +75,7 @@ window.ermiteIssueForm = (() => {
3675 const status = $('[data-status]');
3776 const solution = $('input[name="solution"]');
3877 const minAt = Number(form.dataset.minSubmitAt) || 0;
39- const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false };
78+ const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true };
4079 let timer = null;
4180
4281 function render() {
@@ -47,14 +86,38 @@ window.ermiteIssueForm = (() => {
4786 if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);
4887 }
4988
50- for (const out of form.querySelectorAll('output[data-for]')) {
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) {
51112 const field = form.querySelector('#' + out.dataset.for);
113+ const name = field.name;
52114 const max = Number(out.dataset.max);
53115 const update = () => {
54- const n = core.length(field.value);
116+ const n = core.serverLength(name, field.value);
55117 out.textContent = n + ' / ' + max;
56118 out.className = core.counterClass(n, max);
57- ctx.overs = [...form.querySelectorAll('output[data-for]')].some((o) => o.className === 'over');
119+ ctx.overs = outs.some((o) => o.className === 'over');
120+ if (name === 'title') ctx.empty = n === 0;
58121 render();
59122 };
60123 field.addEventListener('input', update);
@@ -17,15 +17,54 @@ window.ermiteIssueForm = (() => {
17 },17 },
18 // length counts code points, like the server.18 // length counts code points, like the server.
19 length(s) { return [...String(s)].length; },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'; },
20 // state is what the button and the status line show.58 // state is what the button and the status line show.
21- // {solved, now, minAt, sending, unsupported, failed, overs}59+ // {solved, now, minAt, sending, unsupported, failed, overs, empty}
22- state({ solved, now, minAt, sending, unsupported, failed, overs }) {60+ state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) {
23 if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };61 if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };
24 if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };62 if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };
25 if (sending) return { disabled: true, status: 'Sending…' };63 if (sending) return { disabled: true, status: 'Sending…' };
26 if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };64 if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };
27 if (now < minAt) return { disabled: true, status: 'Almost ready…' };65 if (now < minAt) return { disabled: true, status: 'Almost ready…' };
28 if (overs) return { disabled: true, status: 'A field is too long.' };66 if (overs) return { disabled: true, status: 'A field is too long.' };
67+ if (empty) return { disabled: true, status: 'Enter a title.' };
29 return { disabled: false, status: 'Ready.' };68 return { disabled: false, status: 'Ready.' };
30 },69 },
31 };70 };
@@ -36,7 +75,7 @@ window.ermiteIssueForm = (() => {
36 const status = $('[data-status]');75 const status = $('[data-status]');
37 const solution = $('input[name="solution"]');76 const solution = $('input[name="solution"]');
38 const minAt = Number(form.dataset.minSubmitAt) || 0;77 const minAt = Number(form.dataset.minSubmitAt) || 0;
39- const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false };78+ const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true };
40 let timer = null;79 let timer = null;
41 80
42 function render() {81 function render() {
@@ -47,14 +86,38 @@ window.ermiteIssueForm = (() => {
47 if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);86 if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);
48 }87 }
49 88
50- for (const out of form.querySelectorAll('output[data-for]')) {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) {
51 const field = form.querySelector('#' + out.dataset.for);112 const field = form.querySelector('#' + out.dataset.for);
113+ const name = field.name;
52 const max = Number(out.dataset.max);114 const max = Number(out.dataset.max);
53 const update = () => {115 const update = () => {
54- const n = core.length(field.value);116+ const n = core.serverLength(name, field.value);
55 out.textContent = n + ' / ' + max;117 out.textContent = n + ' / ' + max;
56 out.className = core.counterClass(n, max);118 out.className = core.counterClass(n, max);
57- ctx.overs = [...form.querySelectorAll('output[data-for]')].some((o) => o.className === 'over');119+ ctx.overs = outs.some((o) => o.className === 'over');
120+ if (name === 'title') ctx.empty = n === 0;
58 render();121 render();
59 };122 };
60 field.addEventListener('input', update);123 field.addEventListener('input', update);

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 2

Tags 43

↑↓ moveenter openesc close