fix(web): normalize content for edit history diff and fix comment history dropdown (#39616) (#39627)

Backport #39616 by @SergioBenitez

Editing a comment as follows:

```diff
# Deployment checklist

- Timeout: 30 seconds.
+ Timeout: 60 seconds.
Restart the worker before deploying.
Check the logs after deployment.
```

Gitea would render the history this way, before, inserting spurious new
lines:

<img width="1280" height="720" alt="before"
src="https://github.com/user-attachments/assets/5391167b-ac04-4746-b544-add926230ad8"
/>

With this change, it now renders it like this:

<img width="1280" height="720" alt="after"
src="https://github.com/user-attachments/assets/369f1f19-0eaf-4da5-99e5-aeedbac6050c"
/>

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Sergio Benitez <SergioBenitez@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Giteabot
2026-10-06 01:57:59 -07:00
committed by GitHub
parent 7f672160de
commit 30cc41ff97
3 changed files with 52 additions and 29 deletions
+30 -28
View File
@@ -4,8 +4,6 @@
package repo
import (
"bytes"
"html"
"html/template"
"net/http"
"strings"
@@ -15,6 +13,7 @@ import (
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/log"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"github.com/sergi/go-diff/diffmatchpatch"
@@ -110,6 +109,34 @@ func canSoftDeleteContentHistory(ctx *context.Context, issue *issues_model.Issue
return canSoftDelete
}
func diffContentHistory(oldContent, newContent string) template.HTML {
// compare the current history revision with the previous one
dmp := diffmatchpatch.New()
// `checklines=false` makes better diff result
diff := dmp.DiffMain(util.NormalizeStringEOL(oldContent), util.NormalizeStringEOL(newContent), false)
diff = dmp.DiffCleanupEfficiency(diff)
// use chroma to render the diff html
buf := &htmlutil.HTMLBuilder{}
buf.WriteHTML(`<pre class="chroma">`)
for _, it := range diff {
switch it.Type {
case diffmatchpatch.DiffInsert:
buf.WriteHTML(`<span class="gi">`)
buf.WriteString(it.Text)
buf.WriteHTML("</span>")
case diffmatchpatch.DiffDelete:
buf.WriteHTML(`<span class="gd">`)
buf.WriteString(it.Text)
buf.WriteHTML("</span>")
default:
buf.WriteString(it.Text)
}
}
buf.WriteHTML("</pre>")
return buf.HTMLString()
}
// GetContentHistoryDetail get detail
func GetContentHistoryDetail(ctx *context.Context) {
issue := GetActionIssue(ctx)
@@ -144,36 +171,11 @@ func GetContentHistoryDetail(ctx *context.Context) {
prevHistoryContentText = prevHistory.ContentText
}
// compare the current history revision with the previous one
dmp := diffmatchpatch.New()
// `checklines=false` makes better diff result
diff := dmp.DiffMain(prevHistoryContentText, history.ContentText, false)
diff = dmp.DiffCleanupEfficiency(diff)
// use chroma to render the diff html
diffHTMLBuf := bytes.Buffer{}
diffHTMLBuf.WriteString("<pre class='chroma'>")
for _, it := range diff {
switch it.Type {
case diffmatchpatch.DiffInsert:
diffHTMLBuf.WriteString("<span class='gi'>")
diffHTMLBuf.WriteString(html.EscapeString(it.Text))
diffHTMLBuf.WriteString("</span>")
case diffmatchpatch.DiffDelete:
diffHTMLBuf.WriteString("<span class='gd'>")
diffHTMLBuf.WriteString(html.EscapeString(it.Text))
diffHTMLBuf.WriteString("</span>")
default:
diffHTMLBuf.WriteString(html.EscapeString(it.Text))
}
}
diffHTMLBuf.WriteString("</pre>")
ctx.JSON(http.StatusOK, map[string]any{
"canSoftDelete": canSoftDeleteContentHistory(ctx, issue, comment, history),
"historyId": historyID,
"prevHistoryId": prevHistoryID,
"diffHtml": diffHTMLBuf.String(),
"diffHtml": diffContentHistory(prevHistoryContentText, history.ContentText),
})
}
@@ -0,0 +1,17 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDiffContentHistory(t *testing.T) {
out := diffContentHistory("<\r\n&\r\n>", "<\nXXX\n>")
assert.Equal(t, `<pre class="chroma">&lt;
<span class="gd">&amp;</span><span class="gi">XXX</span>
&gt;</pre>`, string(out))
}
@@ -521,10 +521,14 @@ $.fn.dropdown = function(parameters) {
: function(){}
;
const dataKeyRemoteQueried = 'remote-queried';
if(module.is.remote() && !$module.data(dataKeyRemoteQueried)) {
// if the dropdown uses remote API, then it needs to query if:
// * its content is empty (can not show: no menu item)
// * it has not queried before (it has content, but we still need to query the remote API, e.g.: issue author list)
if(module.is.remote() && (!module.can.show() || !$module.data(dataKeyRemoteQueried))) {
module.debug('No API results retrieved, searching before show');
$module.data(dataKeyRemoteQueried, true)
module.queryRemote(module.get.query(), module.show);
// can continue to "show" if there are menu items to avoid the lag of waiting for the remote API response
}
if( module.can.show() && !module.is.active() ) {
module.debug('Showing dropdown');