diff --git a/Makefile b/Makefile
index 065377fd68..87bc7b4caf 100644
--- a/Makefile
+++ b/Makefile
@@ -981,7 +981,7 @@ generate-gomock:
 
 .PHONY: generate-images
 generate-images: | node_modules
-	npm install --no-save fabric@6.0.0-beta20 imagemin-zopfli@7
+	npm install --no-save fabric@6 imagemin-zopfli@7
 	node tools/generate-images.js $(TAGS)
 
 .PHONY: generate-manpage
diff --git a/modules/git/repo_index.go b/modules/git/repo_index.go
index 6aaab242c1..8390570098 100644
--- a/modules/git/repo_index.go
+++ b/modules/git/repo_index.go
@@ -104,11 +104,8 @@ func (repo *Repository) RemoveFilesFromIndex(filenames ...string) error {
 	buffer := new(bytes.Buffer)
 	for _, file := range filenames {
 		if file != "" {
-			buffer.WriteString("0 ")
-			buffer.WriteString(objectFormat.EmptyObjectID().String())
-			buffer.WriteByte('\t')
-			buffer.WriteString(file)
-			buffer.WriteByte('\000')
+			// using format: mode SP type SP sha1 TAB path
+			buffer.WriteString("0 blob " + objectFormat.EmptyObjectID().String() + "\t" + file + "\000")
 		}
 	}
 	return cmd.Run(&RunOpts{
@@ -119,11 +116,33 @@ func (repo *Repository) RemoveFilesFromIndex(filenames ...string) error {
 	})
 }
 
+type IndexObjectInfo struct {
+	Mode     string
+	Object   ObjectID
+	Filename string
+}
+
+// AddObjectsToIndex adds the provided object hashes to the index at the provided filenames
+func (repo *Repository) AddObjectsToIndex(objects ...IndexObjectInfo) error {
+	cmd := NewCommand(repo.Ctx, "update-index", "--add", "--replace", "-z", "--index-info")
+	stdout := new(bytes.Buffer)
+	stderr := new(bytes.Buffer)
+	buffer := new(bytes.Buffer)
+	for _, object := range objects {
+		// using format: mode SP type SP sha1 TAB path
+		buffer.WriteString(object.Mode + " blob " + object.Object.String() + "\t" + object.Filename + "\000")
+	}
+	return cmd.Run(&RunOpts{
+		Dir:    repo.Path,
+		Stdin:  bytes.NewReader(buffer.Bytes()),
+		Stdout: stdout,
+		Stderr: stderr,
+	})
+}
+
 // AddObjectToIndex adds the provided object hash to the index at the provided filename
 func (repo *Repository) AddObjectToIndex(mode string, object ObjectID, filename string) error {
-	cmd := NewCommand(repo.Ctx, "update-index", "--add", "--replace", "--cacheinfo").AddDynamicArguments(mode, object.String(), filename)
-	_, _, err := cmd.RunStdString(&RunOpts{Dir: repo.Path})
-	return err
+	return repo.AddObjectsToIndex(IndexObjectInfo{Mode: mode, Object: object, Filename: filename})
 }
 
 // WriteTree writes the current index as a tree to the object db and returns its hash
diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go
index abeeb2e0a9..d1299bb191 100644
--- a/modules/markup/markdown/markdown_test.go
+++ b/modules/markup/markdown/markdown_test.go
@@ -552,6 +552,14 @@ func TestMathBlock(t *testing.T) {
 			"$a$ ($b$) [$c$] {$d$}",
 			`<p><code class="language-math is-loading">a</code> (<code class="language-math is-loading">b</code>) [$c$] {$d$}</p>` + nl,
 		},
+		{
+			"$$a$$ test",
+			`<p><code class="language-math display is-loading">a</code> test</p>` + nl,
+		},
+		{
+			"test $$a$$",
+			`<p>test <code class="language-math display is-loading">a</code></p>` + nl,
+		},
 	}
 
 	for _, test := range testcases {
diff --git a/modules/markup/markdown/math/block_parser.go b/modules/markup/markdown/math/block_parser.go
index f3262c82c0..527df84975 100644
--- a/modules/markup/markdown/math/block_parser.go
+++ b/modules/markup/markdown/math/block_parser.go
@@ -47,6 +47,12 @@ func (b *blockParser) Open(parent ast.Node, reader text.Reader, pc parser.Contex
 	}
 	idx := bytes.Index(line[pos+2:], endBytes)
 	if idx >= 0 {
+		// for case $$ ... $$ any other text
+		for i := pos + idx + 4; i < len(line); i++ {
+			if line[i] != ' ' && line[i] != '\n' {
+				return nil, parser.NoChildren
+			}
+		}
 		segment.Stop = segment.Start + idx + 2
 		reader.Advance(segment.Len() - 1)
 		segment.Start += 2
diff --git a/modules/markup/markdown/math/inline_block_node.go b/modules/markup/markdown/math/inline_block_node.go
new file mode 100644
index 0000000000..c92d0c8d84
--- /dev/null
+++ b/modules/markup/markdown/math/inline_block_node.go
@@ -0,0 +1,31 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package math
+
+import (
+	"github.com/yuin/goldmark/ast"
+)
+
+// InlineBlock represents inline math e.g. $$...$$
+type InlineBlock struct {
+	Inline
+}
+
+// InlineBlock implements InlineBlock.
+func (n *InlineBlock) InlineBlock() {}
+
+// KindInlineBlock is the kind for math inline block
+var KindInlineBlock = ast.NewNodeKind("MathInlineBlock")
+
+// Kind returns KindInlineBlock
+func (n *InlineBlock) Kind() ast.NodeKind {
+	return KindInlineBlock
+}
+
+// NewInlineBlock creates a new ast math inline block node
+func NewInlineBlock() *InlineBlock {
+	return &InlineBlock{
+		Inline{},
+	}
+}
diff --git a/modules/markup/markdown/math/inline_parser.go b/modules/markup/markdown/math/inline_parser.go
index 614cf329af..b11195d551 100644
--- a/modules/markup/markdown/math/inline_parser.go
+++ b/modules/markup/markdown/math/inline_parser.go
@@ -21,11 +21,20 @@ var defaultInlineDollarParser = &inlineParser{
 	end:   []byte{'$'},
 }
 
+var defaultDualDollarParser = &inlineParser{
+	start: []byte{'$', '$'},
+	end:   []byte{'$', '$'},
+}
+
 // NewInlineDollarParser returns a new inline parser
 func NewInlineDollarParser() parser.InlineParser {
 	return defaultInlineDollarParser
 }
 
+func NewInlineDualDollarParser() parser.InlineParser {
+	return defaultDualDollarParser
+}
+
 var defaultInlineBracketParser = &inlineParser{
 	start: []byte{'\\', '('},
 	end:   []byte{'\\', ')'},
@@ -38,7 +47,7 @@ func NewInlineBracketParser() parser.InlineParser {
 
 // Trigger triggers this parser on $ or \
 func (parser *inlineParser) Trigger() []byte {
-	return parser.start[0:1]
+	return parser.start
 }
 
 func isPunctuation(b byte) bool {
@@ -88,7 +97,11 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.
 			break
 		}
 		suceedingCharacter := line[pos]
-		if !isPunctuation(suceedingCharacter) && !(suceedingCharacter == ' ') && !isBracket(suceedingCharacter) {
+		// check valid ending character
+		if !isPunctuation(suceedingCharacter) &&
+			!(suceedingCharacter == ' ') &&
+			!(suceedingCharacter == '\n') &&
+			!isBracket(suceedingCharacter) {
 			return nil
 		}
 		if line[ender-1] != '\\' {
@@ -101,12 +114,21 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.
 
 	block.Advance(opener)
 	_, pos := block.Position()
-	node := NewInline()
+	var node ast.Node
+	if parser == defaultDualDollarParser {
+		node = NewInlineBlock()
+	} else {
+		node = NewInline()
+	}
 	segment := pos.WithStop(pos.Start + ender - opener)
 	node.AppendChild(node, ast.NewRawTextSegment(segment))
 	block.Advance(ender - opener + len(parser.end))
 
-	trimBlock(node, block)
+	if parser == defaultDualDollarParser {
+		trimBlock(&(node.(*InlineBlock)).Inline, block)
+	} else {
+		trimBlock(node.(*Inline), block)
+	}
 	return node
 }
 
diff --git a/modules/markup/markdown/math/inline_renderer.go b/modules/markup/markdown/math/inline_renderer.go
index b4e9ade0ae..96848099cc 100644
--- a/modules/markup/markdown/math/inline_renderer.go
+++ b/modules/markup/markdown/math/inline_renderer.go
@@ -21,7 +21,11 @@ func NewInlineRenderer() renderer.NodeRenderer {
 
 func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
 	if entering {
-		_, _ = w.WriteString(`<code class="language-math is-loading">`)
+		extraClass := ""
+		if _, ok := n.(*InlineBlock); ok {
+			extraClass = "display "
+		}
+		_, _ = w.WriteString(`<code class="language-math ` + extraClass + `is-loading">`)
 		for c := n.FirstChild(); c != nil; c = c.NextSibling() {
 			segment := c.(*ast.Text).Segment
 			value := util.EscapeHTML(segment.Value(source))
@@ -43,4 +47,5 @@ func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Nod
 // RegisterFuncs registers the renderer for inline math nodes
 func (r *InlineRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
 	reg.Register(KindInline, r.renderInline)
+	reg.Register(KindInlineBlock, r.renderInline)
 }
diff --git a/modules/markup/markdown/math/math.go b/modules/markup/markdown/math/math.go
index 8a50753574..3d9f376bc6 100644
--- a/modules/markup/markdown/math/math.go
+++ b/modules/markup/markdown/math/math.go
@@ -96,7 +96,8 @@ func (e *Extension) Extend(m goldmark.Markdown) {
 		util.Prioritized(NewInlineBracketParser(), 501),
 	}
 	if e.parseDollarInline {
-		inlines = append(inlines, util.Prioritized(NewInlineDollarParser(), 501))
+		inlines = append(inlines, util.Prioritized(NewInlineDollarParser(), 503),
+			util.Prioritized(NewInlineDualDollarParser(), 502))
 	}
 	m.Parser().AddOptions(parser.WithInlineParsers(inlines...))
 
diff --git a/services/pull/patch.go b/services/pull/patch.go
index 12b79a0625..e90b4bdbbe 100644
--- a/services/pull/patch.go
+++ b/services/pull/patch.go
@@ -128,7 +128,7 @@ func (e *errMergeConflict) Error() string {
 	return fmt.Sprintf("conflict detected at: %s", e.filename)
 }
 
-func attemptMerge(ctx context.Context, file *unmergedFile, tmpBasePath string, gitRepo *git.Repository) error {
+func attemptMerge(ctx context.Context, file *unmergedFile, tmpBasePath string, filesToRemove *[]string, filesToAdd *[]git.IndexObjectInfo) error {
 	log.Trace("Attempt to merge:\n%v", file)
 
 	switch {
@@ -142,14 +142,13 @@ func attemptMerge(ctx context.Context, file *unmergedFile, tmpBasePath string, g
 		}
 
 		// Not a genuine conflict and we can simply remove the file from the index
-		return gitRepo.RemoveFilesFromIndex(file.stage1.path)
+		*filesToRemove = append(*filesToRemove, file.stage1.path)
+		return nil
 	case file.stage1 == nil && file.stage2 != nil && (file.stage3 == nil || file.stage2.SameAs(file.stage3)):
 		// 2. Added in ours but not in theirs or identical in both
 		//
 		// Not a genuine conflict just add to the index
-		if err := gitRepo.AddObjectToIndex(file.stage2.mode, git.MustIDFromString(file.stage2.sha), file.stage2.path); err != nil {
-			return err
-		}
+		*filesToAdd = append(*filesToAdd, git.IndexObjectInfo{Mode: file.stage2.mode, Object: git.MustIDFromString(file.stage2.sha), Filename: file.stage2.path})
 		return nil
 	case file.stage1 == nil && file.stage2 != nil && file.stage3 != nil && file.stage2.sha == file.stage3.sha && file.stage2.mode != file.stage3.mode:
 		// 3. Added in both with the same sha but the modes are different
@@ -160,7 +159,8 @@ func attemptMerge(ctx context.Context, file *unmergedFile, tmpBasePath string, g
 		// 4. Added in theirs but not ours:
 		//
 		// Not a genuine conflict just add to the index
-		return gitRepo.AddObjectToIndex(file.stage3.mode, git.MustIDFromString(file.stage3.sha), file.stage3.path)
+		*filesToAdd = append(*filesToAdd, git.IndexObjectInfo{Mode: file.stage3.mode, Object: git.MustIDFromString(file.stage3.sha), Filename: file.stage3.path})
+		return nil
 	case file.stage1 == nil:
 		// 5. Created by new in both
 		//
@@ -221,7 +221,8 @@ func attemptMerge(ctx context.Context, file *unmergedFile, tmpBasePath string, g
 			return err
 		}
 		hash = strings.TrimSpace(hash)
-		return gitRepo.AddObjectToIndex(file.stage2.mode, git.MustIDFromString(hash), file.stage2.path)
+		*filesToAdd = append(*filesToAdd, git.IndexObjectInfo{Mode: file.stage2.mode, Object: git.MustIDFromString(hash), Filename: file.stage2.path})
+		return nil
 	default:
 		if file.stage1 != nil {
 			return &errMergeConflict{file.stage1.path}
@@ -245,6 +246,9 @@ func AttemptThreeWayMerge(ctx context.Context, gitPath string, gitRepo *git.Repo
 		return false, nil, fmt.Errorf("unable to run read-tree -m! Error: %w", err)
 	}
 
+	var filesToRemove []string
+	var filesToAdd []git.IndexObjectInfo
+
 	// Then we use git ls-files -u to list the unmerged files and collate the triples in unmergedfiles
 	unmerged := make(chan *unmergedFile)
 	go unmergedFiles(ctx, gitPath, unmerged)
@@ -270,7 +274,7 @@ func AttemptThreeWayMerge(ctx context.Context, gitPath string, gitRepo *git.Repo
 		}
 
 		// OK now we have the unmerged file triplet attempt to merge it
-		if err := attemptMerge(ctx, file, gitPath, gitRepo); err != nil {
+		if err := attemptMerge(ctx, file, gitPath, &filesToRemove, &filesToAdd); err != nil {
 			if conflictErr, ok := err.(*errMergeConflict); ok {
 				log.Trace("Conflict: %s in %s", conflictErr.filename, description)
 				conflict = true
@@ -283,6 +287,15 @@ func AttemptThreeWayMerge(ctx context.Context, gitPath string, gitRepo *git.Repo
 			return false, nil, err
 		}
 	}
+
+	// Add and remove files in one command, as this is slow with many files otherwise
+	if err := gitRepo.RemoveFilesFromIndex(filesToRemove...); err != nil {
+		return false, nil, err
+	}
+	if err := gitRepo.AddObjectsToIndex(filesToAdd...); err != nil {
+		return false, nil, err
+	}
+
 	return conflict, conflictedFiles, nil
 }