diff --git a/main.go b/main.go index ea300fd..f30b5b8 100644 --- a/main.go +++ b/main.go @@ -1220,7 +1220,7 @@ func (a *app) handleComment(w http.ResponseWriter, r *http.Request) { _ = sse.MarshalAndPatchSignals(map[string]any{"_busy": false}) return // nothing to do } - body = truncate(body, 2000) + body, trimNote := truncateNoted(body, 2000, "Comment") author, ok := a.writerName(r, sse, "comment") if !ok { return @@ -1261,7 +1261,7 @@ func (a *app) handleComment(w http.ResponseWriter, r *http.Request) { // sessions viewing it (not the whole fleet). The new comment arrives on their // stream; this command's response only clears the draft. a.hub.notifyDetailWatchers(issueShort) - _ = sse.MarshalAndPatchSignals(map[string]any{"draft": "", "_notice": "", "_busy": false}) + _ = sse.MarshalAndPatchSignals(map[string]any{"draft": "", "_notice": trimNote, "_busy": false}) } // addAuthor inserts name into the author-facet datalist if it's new. The list @@ -1306,8 +1306,9 @@ func (a *app) handleCreate(w http.ResponseWriter, r *http.Request) { _ = sse.MarshalAndPatchSignals(map[string]any{"_busy": false, "_notice": "Title is required."}) return } - title = truncate(title, 200) - desc = truncate(desc, 4000) + title, titleNote := truncateNoted(title, 200, "Title") + desc, descNote := truncateNoted(desc, 4000, "Description") + trimNote := strings.TrimSpace(titleNote + " " + descNote) creator, ok := a.writerName(r, sse, "create an issue") if !ok { return @@ -1375,7 +1376,7 @@ func (a *app) handleCreate(w http.ResponseWriter, r *http.Request) { a.hub.notifyAll() _ = sse.MarshalAndPatchSignals(map[string]any{ "compose": map[string]any{"title": "", "desc": "", "labels": []string{}}, - "_busy": false, "_notice": "", + "_busy": false, "_notice": trimNote, }) } @@ -1428,8 +1429,9 @@ func (a *app) handleEditSave(w http.ResponseWriter, r *http.Request) { _ = sse.MarshalAndPatchSignals(map[string]any{"_busy": false, "_notice": "Title is required."}) return } - title = truncate(title, 200) - desc = truncate(desc, 4000) + title, titleNote := truncateNoted(title, 200, "Title") + desc, descNote := truncateNoted(desc, 4000, "Description") + trimNote := strings.TrimSpace(titleNote + " " + descNote) me, ok := a.writerName(r, sse, "edit") if !ok { return @@ -1505,7 +1507,7 @@ func (a *app) handleEditSave(w http.ResponseWriter, r *http.Request) { // the unaffected views (and the edited-out case is caught because the list // bytes actually change for affected slices). a.hub.notifyAll() - _ = sse.MarshalAndPatchSignals(map[string]any{"_busy": false, "_notice": ""}) + _ = sse.MarshalAndPatchSignals(map[string]any{"_busy": false, "_notice": trimNote}) } func boolToInt(b bool) int { @@ -1515,6 +1517,19 @@ func boolToInt(b bool) int { return 0 } +// truncateNoted caps s like truncate and, when the input was actually trimmed, +// also returns a user-facing note for the command's _notice signal — silent +// truncation alters input without telling anyone, which is the one thing the +// validate-in-handler pattern exists to surface. The inputs carry maxlength so +// this rarely fires from the UI; multi-byte text can still exceed the server's +// byte cap (maxlength counts UTF-16 units, the cap counts bytes). +func truncateNoted(s string, n int, what string) (string, string) { + if len(s) <= n { + return s, "" + } + return truncate(s, n), what + " was too long and was trimmed." +} + // truncate caps s at n bytes without splitting a UTF-8 rune mid-sequence. func truncate(s string, n int) string { if len(s) <= n { diff --git a/templates/fragments.html b/templates/fragments.html index ec945cc..3a4d309 100644 --- a/templates/fragments.html +++ b/templates/fragments.html @@ -133,8 +133,8 @@