tenseleyflow/shithub / 991453a

Browse files

Render markdown preview diffs

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
991453a447fdd731df9b37f4a4e29bacb4a88aea
Parents
0d02b3b
Tree
04ffbfc

5 changed files

StatusFile+-
M internal/web/handlers/repo/editor.go 200 14
A internal/web/handlers/repo/editor_diff_test.go 79 0
M internal/web/static/css/shithub.css 27 6
M internal/web/templates/repo/editor.html 25 3
M internal/web/templates/repo/markdown_preview.html 11 1
internal/web/handlers/repo/editor.gomodified
@@ -3,6 +3,7 @@
33
 package repo
44
 
55
 import (
6
+	"context"
67
 	"errors"
78
 	"fmt"
89
 	"html/template"
@@ -275,33 +276,218 @@ func (h *Handlers) codeMarkdownPreview(w http.ResponseWriter, r *http.Request) {
275276
 		h.d.Render.HTTPError(w, r, http.StatusRequestEntityTooLarge, "")
276277
 		return
277278
 	}
279
+	ref := r.PostFormValue("ref")
280
+	if ref == "" {
281
+		ref = row.DefaultBranch
282
+	}
283
+	filePath := cleanEditorPath(r.PostFormValue("path"))
284
+	if !validateSubpath(filePath) {
285
+		filePath = ""
286
+	}
287
+	if r.PostFormValue("show_diff") == "1" {
288
+		sourcePath := cleanEditorPath(r.PostFormValue("original_path"))
289
+		if sourcePath == "" {
290
+			sourcePath = filePath
291
+		}
292
+		blocks, err := h.markdownPreviewDiffBlocks(r.Context(), owner.Username, row.Name, ref, filePath, sourcePath, body)
293
+		if err != nil {
294
+			h.d.Logger.WarnContext(r.Context(), "code: markdown diff preview", "error", err)
295
+		} else {
296
+			w.Header().Set("Content-Type", "text/html; charset=utf-8")
297
+			if err := h.d.Render.RenderFragment(w, "repo/markdown_preview", map[string]any{
298
+				"DiffMode":   true,
299
+				"DiffBlocks": blocks,
300
+			}); err != nil {
301
+				h.d.Logger.WarnContext(r.Context(), "code: markdown preview", "error", err)
302
+			}
303
+			return
304
+		}
305
+	}
278306
 	rendered, err := mdrender.RenderDocumentHTML(body)
279307
 	if err != nil {
280308
 		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
281309
 		return
282310
 	}
283
-	ref := r.PostFormValue("ref")
284
-	if ref == "" {
285
-		ref = row.DefaultBranch
311
+	rendered = rewriteEditorMarkdownURLs(rendered, owner.Username, row.Name, ref, filePath)
312
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
313
+	if err := h.d.Render.RenderFragment(w, "repo/markdown_preview", map[string]any{
314
+		"MarkdownHTML": template.HTML(rendered), //nolint:gosec // sanitized by markdown renderer
315
+	}); err != nil {
316
+		h.d.Logger.WarnContext(r.Context(), "code: markdown preview", "error", err)
286317
 	}
287
-	filePath := cleanEditorPath(r.PostFormValue("path"))
318
+}
319
+
320
+type markdownPreviewDiffBlock struct {
321
+	Kind string
322
+	HTML template.HTML
323
+}
324
+
325
+type markdownLineRun struct {
326
+	Kind  string
327
+	Lines []string
328
+}
329
+
330
+func (h *Handlers) markdownPreviewDiffBlocks(ctx context.Context, owner, repoName, ref, previewPath, sourcePath string, body []byte) ([]markdownPreviewDiffBlock, error) {
331
+	var original []byte
332
+	if validateSubpath(sourcePath) && sourcePath != "" {
333
+		gitDir, err := h.d.RepoFS.RepoPath(owner, repoName)
334
+		if err != nil {
335
+			return nil, err
336
+		}
337
+		kind, _, size, err := repogit.StatPath(ctx, gitDir, ref, sourcePath)
338
+		switch {
339
+		case err == nil && kind == repogit.EntryBlob && size <= webedit.MaxTextBytes:
340
+			original, err = repogit.ReadBlobBytes(ctx, gitDir, ref, sourcePath, webedit.MaxTextBytes)
341
+			if err != nil {
342
+				return nil, err
343
+			}
344
+		case err == nil:
345
+			original = nil
346
+		case errors.Is(err, repogit.ErrPathNotFound):
347
+			original = nil
348
+		default:
349
+			return nil, err
350
+		}
351
+	}
352
+	return renderMarkdownPreviewDiffBlocks(original, body, func(rendered string) string {
353
+		return rewriteEditorMarkdownURLs(rendered, owner, repoName, ref, previewPath)
354
+	})
355
+}
356
+
357
+func renderMarkdownPreviewDiffBlocks(original, current []byte, rewrite func(string) string) ([]markdownPreviewDiffBlock, error) {
358
+	runs := markdownLineDiff(splitMarkdownLines(string(original)), splitMarkdownLines(string(current)))
359
+	blocks := make([]markdownPreviewDiffBlock, 0, len(runs))
360
+	for _, run := range runs {
361
+		body := strings.Join(run.Lines, "")
362
+		if strings.TrimSpace(body) == "" {
363
+			continue
364
+		}
365
+		rendered, err := mdrender.RenderDocumentHTML([]byte(body))
366
+		if err != nil {
367
+			return nil, err
368
+		}
369
+		if rewrite != nil {
370
+			rendered = rewrite(rendered)
371
+		}
372
+		blocks = append(blocks, markdownPreviewDiffBlock{
373
+			Kind: run.Kind,
374
+			HTML: template.HTML(rendered), //nolint:gosec // sanitized by markdown renderer
375
+		})
376
+	}
377
+	return blocks, nil
378
+}
379
+
380
+func rewriteEditorMarkdownURLs(rendered, owner, repoName, ref, filePath string) string {
288381
 	dir := parentPath(filePath)
289382
 	if !validateSubpath(dir) {
290383
 		dir = ""
291384
 	}
292
-	rendered = rewriteMarkdownRelativeURLs(
385
+	return rewriteMarkdownRelativeURLs(
293386
 		rendered,
294
-		codeRouteBase(owner.Username, row.Name, "blob", ref, dir),
295
-		codeRouteBase(owner.Username, row.Name, "blob", ref, ""),
296
-		codeRouteBase(owner.Username, row.Name, "raw", ref, dir),
297
-		codeRouteBase(owner.Username, row.Name, "raw", ref, ""),
387
+		codeRouteBase(owner, repoName, "blob", ref, dir),
388
+		codeRouteBase(owner, repoName, "blob", ref, ""),
389
+		codeRouteBase(owner, repoName, "raw", ref, dir),
390
+		codeRouteBase(owner, repoName, "raw", ref, ""),
298391
 	)
299
-	w.Header().Set("Content-Type", "text/html; charset=utf-8")
300
-	if err := h.d.Render.RenderFragment(w, "repo/markdown_preview", map[string]any{
301
-		"MarkdownHTML": template.HTML(rendered), //nolint:gosec // sanitized by markdown renderer
302
-	}); err != nil {
303
-		h.d.Logger.WarnContext(r.Context(), "code: markdown preview", "error", err)
304392
 }
393
+
394
+func splitMarkdownLines(s string) []string {
395
+	s = strings.ReplaceAll(s, "\r\n", "\n")
396
+	s = strings.ReplaceAll(s, "\r", "\n")
397
+	if s == "" {
398
+		return nil
399
+	}
400
+	lines := strings.SplitAfter(s, "\n")
401
+	if len(lines) > 0 && lines[len(lines)-1] == "" {
402
+		lines = lines[:len(lines)-1]
403
+	}
404
+	return lines
405
+}
406
+
407
+const maxMarkdownPreviewDiffCells = 40000
408
+
409
+func markdownLineDiff(original, current []string) []markdownLineRun {
410
+	if len(original) == 0 && len(current) == 0 {
411
+		return nil
412
+	}
413
+	if len(original) > 0 && len(current) > maxMarkdownPreviewDiffCells/len(original) {
414
+		return boundedMarkdownLineDiff(original, current)
415
+	}
416
+	width := len(current) + 1
417
+	table := make([]int, (len(original)+1)*width)
418
+	for i := len(original) - 1; i >= 0; i-- {
419
+		for j := len(current) - 1; j >= 0; j-- {
420
+			if original[i] == current[j] {
421
+				table[i*width+j] = table[(i+1)*width+j+1] + 1
422
+				continue
423
+			}
424
+			deleted := table[(i+1)*width+j]
425
+			added := table[i*width+j+1]
426
+			if deleted >= added {
427
+				table[i*width+j] = deleted
428
+			} else {
429
+				table[i*width+j] = added
430
+			}
431
+		}
432
+	}
433
+	var runs []markdownLineRun
434
+	i, j := 0, 0
435
+	for i < len(original) && j < len(current) {
436
+		switch {
437
+		case original[i] == current[j]:
438
+			runs = appendMarkdownLineRun(runs, "context", current[j])
439
+			i++
440
+			j++
441
+		case table[(i+1)*width+j] >= table[i*width+j+1]:
442
+			runs = appendMarkdownLineRun(runs, "deleted", original[i])
443
+			i++
444
+		default:
445
+			runs = appendMarkdownLineRun(runs, "added", current[j])
446
+			j++
447
+		}
448
+	}
449
+	for ; i < len(original); i++ {
450
+		runs = appendMarkdownLineRun(runs, "deleted", original[i])
451
+	}
452
+	for ; j < len(current); j++ {
453
+		runs = appendMarkdownLineRun(runs, "added", current[j])
454
+	}
455
+	return runs
456
+}
457
+
458
+func boundedMarkdownLineDiff(original, current []string) []markdownLineRun {
459
+	prefix := 0
460
+	for prefix < len(original) && prefix < len(current) && original[prefix] == current[prefix] {
461
+		prefix++
462
+	}
463
+	origEnd := len(original)
464
+	currEnd := len(current)
465
+	for origEnd > prefix && currEnd > prefix && original[origEnd-1] == current[currEnd-1] {
466
+		origEnd--
467
+		currEnd--
468
+	}
469
+	var runs []markdownLineRun
470
+	for _, line := range current[:prefix] {
471
+		runs = appendMarkdownLineRun(runs, "context", line)
472
+	}
473
+	for _, line := range original[prefix:origEnd] {
474
+		runs = appendMarkdownLineRun(runs, "deleted", line)
475
+	}
476
+	for _, line := range current[prefix:currEnd] {
477
+		runs = appendMarkdownLineRun(runs, "added", line)
478
+	}
479
+	for _, line := range current[currEnd:] {
480
+		runs = appendMarkdownLineRun(runs, "context", line)
481
+	}
482
+	return runs
483
+}
484
+
485
+func appendMarkdownLineRun(runs []markdownLineRun, kind, line string) []markdownLineRun {
486
+	if len(runs) > 0 && runs[len(runs)-1].Kind == kind {
487
+		runs[len(runs)-1].Lines = append(runs[len(runs)-1].Lines, line)
488
+		return runs
489
+	}
490
+	return append(runs, markdownLineRun{Kind: kind, Lines: []string{line}})
305491
 }
306492
 
307493
 func (h *Handlers) editorData(r *http.Request, cc *codeContext, mode, pathValue, content string) codeEditorData {
internal/web/handlers/repo/editor_diff_test.goadded
@@ -0,0 +1,79 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"strings"
7
+	"testing"
8
+)
9
+
10
+func TestMarkdownLineDiffSplitsAddedDeletedAndContextRuns(t *testing.T) {
11
+	t.Parallel()
12
+
13
+	runs := markdownLineDiff(
14
+		splitMarkdownLines("alpha\nold\nomega\n"),
15
+		splitMarkdownLines("alpha\nnew\nomega\nextra\n"),
16
+	)
17
+
18
+	kinds := make([]string, 0, len(runs))
19
+	for _, run := range runs {
20
+		kinds = append(kinds, run.Kind)
21
+	}
22
+	got := strings.Join(kinds, ",")
23
+	want := "context,deleted,added,context,added"
24
+	if got != want {
25
+		t.Fatalf("run kinds = %q, want %q", got, want)
26
+	}
27
+}
28
+
29
+func TestRenderMarkdownPreviewDiffBlocksRendersChangedMarkdown(t *testing.T) {
30
+	t.Parallel()
31
+
32
+	blocks, err := renderMarkdownPreviewDiffBlocks(
33
+		[]byte("# Demo\n\nold line\n\nkept line\n"),
34
+		[]byte("# Demo\n\nnew line\n\nkept line\n"),
35
+		func(rendered string) string { return rendered },
36
+	)
37
+	if err != nil {
38
+		t.Fatalf("renderMarkdownPreviewDiffBlocks: %v", err)
39
+	}
40
+
41
+	var added, deleted bool
42
+	var joined strings.Builder
43
+	for _, block := range blocks {
44
+		if block.Kind == "added" {
45
+			added = true
46
+		}
47
+		if block.Kind == "deleted" {
48
+			deleted = true
49
+		}
50
+		joined.WriteString(string(block.HTML))
51
+	}
52
+	if !added || !deleted {
53
+		t.Fatalf("expected added and deleted blocks, got %#v", blocks)
54
+	}
55
+	html := joined.String()
56
+	for _, want := range []string{"new line", "old line", "<h1"} {
57
+		if !strings.Contains(html, want) {
58
+			t.Fatalf("rendered diff HTML missing %q: %s", want, html)
59
+		}
60
+	}
61
+}
62
+
63
+func TestRenderMarkdownPreviewDiffBlocksSanitizesHTML(t *testing.T) {
64
+	t.Parallel()
65
+
66
+	blocks, err := renderMarkdownPreviewDiffBlocks(
67
+		nil,
68
+		[]byte("<script>alert(1)</script>\n\nok\n"),
69
+		func(rendered string) string { return rendered },
70
+	)
71
+	if err != nil {
72
+		t.Fatalf("renderMarkdownPreviewDiffBlocks: %v", err)
73
+	}
74
+	for _, block := range blocks {
75
+		if strings.Contains(string(block.HTML), "<script") {
76
+			t.Fatalf("rendered diff block was not sanitized: %s", block.HTML)
77
+		}
78
+	}
79
+}
internal/web/static/css/shithub.cssmodified
@@ -5442,11 +5442,11 @@ button.shithub-repo-action {
54425442
 .shithub-editor-textbox textarea:focus {
54435443
   box-shadow: inset 0 0 0 2px var(--accent-emphasis);
54445444
 }
5445
-.shithub-editor-helpbar,
5446
-.shithub-editor-attach {
5445
+.shithub-editor-helpbar {
54475446
   min-height: 36px;
54485447
   padding: 0.45rem 0.75rem;
54495448
   border-top: 1px solid var(--border-default);
5449
+  border-radius: 0 0 6px 6px;
54505450
   color: var(--fg-muted);
54515451
   background: var(--canvas-subtle);
54525452
   font-size: 0.875rem;
@@ -5462,10 +5462,6 @@ button.shithub-repo-action {
54625462
   background: var(--canvas-default);
54635463
   font: 0.8rem ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
54645464
 }
5465
-.shithub-editor-attach {
5466
-  border-radius: 0 0 6px 6px;
5467
-  font-weight: 600;
5468
-}
54695465
 .shithub-editor-preview {
54705466
   min-height: min(72vh, 900px);
54715467
   padding: 3rem 2.75rem;
@@ -5475,6 +5471,31 @@ button.shithub-repo-action {
54755471
   max-width: none;
54765472
   color: var(--fg-default);
54775473
 }
5474
+.shithub-editor-preview-body.is-diff {
5475
+  display: flex;
5476
+  flex-direction: column;
5477
+  gap: 0.35rem;
5478
+}
5479
+.shithub-editor-preview-diff-block {
5480
+  margin: 0;
5481
+}
5482
+.shithub-editor-preview-diff-block.is-added,
5483
+.shithub-editor-preview-diff-block.is-deleted {
5484
+  padding-left: 0.95rem;
5485
+  border-left: 4px solid;
5486
+}
5487
+.shithub-editor-preview-diff-block.is-added {
5488
+  border-left-color: var(--success-emphasis);
5489
+}
5490
+.shithub-editor-preview-diff-block.is-deleted {
5491
+  border-left-color: var(--danger-fg);
5492
+}
5493
+.shithub-editor-preview-diff-block > :first-child {
5494
+  margin-top: 0;
5495
+}
5496
+.shithub-editor-preview-diff-block > :last-child {
5497
+  margin-bottom: 0;
5498
+}
54785499
 .shithub-editor-preview-empty {
54795500
   margin: 0;
54805501
   color: var(--fg-muted);
internal/web/templates/repo/editor.htmlmodified
@@ -99,7 +99,6 @@
9999
           <div class="shithub-editor-helpbar">
100100
             Use <kbd>Control + Shift + m</kbd> to toggle the <kbd>tab</kbd> key moving focus. Alternatively, use <kbd>esc</kbd> then <kbd>tab</kbd> to move to the next interactive element on the page.
101101
           </div>
102
-          <div class="shithub-editor-attach">Attach files by dragging & dropping, selecting or pasting them.</div>
103102
         </div>
104103
         <div class="shithub-editor-pane shithub-editor-preview" data-editor-pane="preview" hidden>
105104
           <div class="markdown-body shithub-editor-preview-body" data-editor-preview data-preview-url="{{ .PreviewURL }}" data-preview-ref="{{ .Ref }}">
@@ -154,6 +153,8 @@
154153
   let indentSize = 2;
155154
   let softWrap = true;
156155
   let previewDirty = true;
156
+  let tabMovesFocus = false;
157
+  let nextTabMovesFocus = false;
157158
   let submitting = false;
158159
 
159160
   function updateGutter() {
@@ -210,6 +211,8 @@
210211
     body.set("content", textarea.value);
211212
     body.set("ref", preview.dataset.previewRef || "");
212213
     body.set("path", pathInput ? pathInput.value : "");
214
+    if (pathInput) body.set("original_path", pathInput.dataset.original || pathInput.value || "");
215
+    if (showDiff && showDiff.checked) body.set("show_diff", "1");
213216
     preview.innerHTML = "<p class=\"shithub-editor-preview-empty\">Rendering preview...</p>";
214217
     const res = await fetch(preview.dataset.previewUrl, {
215218
       method: "POST",
@@ -254,6 +257,7 @@
254257
     updateCommitState();
255258
     textarea.addEventListener("input", function () {
256259
       updateGutter();
260
+      nextTabMovesFocus = false;
257261
       previewDirty = true;
258262
       updateCommitState();
259263
     });
@@ -261,7 +265,24 @@
261265
       if (gutter) gutter.scrollTop = textarea.scrollTop;
262266
     });
263267
     textarea.addEventListener("keydown", function (event) {
264
-      if (event.key !== "Tab") return;
268
+      if ((event.key === "m" || event.key === "M") && event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey) {
269
+        event.preventDefault();
270
+        tabMovesFocus = !tabMovesFocus;
271
+        nextTabMovesFocus = false;
272
+        return;
273
+      }
274
+      if (event.key === "Escape") {
275
+        nextTabMovesFocus = true;
276
+        return;
277
+      }
278
+      if (event.key !== "Tab") {
279
+        nextTabMovesFocus = false;
280
+        return;
281
+      }
282
+      if (tabMovesFocus || nextTabMovesFocus) {
283
+        nextTabMovesFocus = false;
284
+        return;
285
+      }
265286
       event.preventDefault();
266287
       const start = textarea.selectionStart;
267288
       const end = textarea.selectionEnd;
@@ -328,7 +349,8 @@
328349
 
329350
   if (showDiff) {
330351
     showDiff.addEventListener("change", function () {
331
-      if (preview) preview.classList.toggle("is-showing-diff", showDiff.checked);
352
+      previewDirty = true;
353
+      if (root.classList.contains("is-previewing")) renderPreview();
332354
     });
333355
   }
334356
 
internal/web/templates/repo/markdown_preview.htmlmodified
@@ -1,3 +1,13 @@
11
 {{ define "page" -}}
2
-<div class="markdown-body shithub-editor-preview-body">{{ .MarkdownHTML }}</div>
2
+<div class="markdown-body shithub-editor-preview-body{{ if .DiffMode }} is-diff{{ end }}">
3
+  {{ if .DiffMode }}
4
+    {{ range .DiffBlocks }}
5
+      <div class="shithub-editor-preview-diff-block is-{{ .Kind }}">{{ .HTML }}</div>
6
+    {{ else }}
7
+      <p class="shithub-editor-preview-empty">No preview changes.</p>
8
+    {{ end }}
9
+  {{ else }}
10
+    {{ .MarkdownHTML }}
11
+  {{ end }}
12
+</div>
313
 {{- end }}