loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Replace ajax with fetch, improve image diff (#27267)

1. Dropzone attachment removal, pretty simple replacement
2. Image diff: The previous code fetched every image twice, once via
`img[src]` and once via `$.ajax`. Now it's only fetched once and a
second time only when necessary. The image diff code was partially
rewritten.

---------

Co-authored-by: Giteabot <teabot@gitea.io>

authored by

silverwind
Giteabot
and committed by
GitHub
73b63d93 dc040447

+95 -81
+17 -10
routers/web/repo/compare.go
··· 32 32 "code.gitea.io/gitea/modules/markup" 33 33 "code.gitea.io/gitea/modules/setting" 34 34 api "code.gitea.io/gitea/modules/structs" 35 + "code.gitea.io/gitea/modules/typesniffer" 35 36 "code.gitea.io/gitea/modules/upload" 36 37 "code.gitea.io/gitea/modules/util" 37 38 "code.gitea.io/gitea/services/gitdiff" ··· 60 61 return blob 61 62 } 62 63 64 + ctx.Data["GetSniffedTypeForBlob"] = func(blob *git.Blob) typesniffer.SniffedType { 65 + st := typesniffer.SniffedType{} 66 + 67 + if blob == nil { 68 + return st 69 + } 70 + 71 + st, err := blob.GuessContentType() 72 + if err != nil { 73 + log.Error("GuessContentType failed: %v", err) 74 + return st 75 + } 76 + return st 77 + } 78 + 63 79 setPathsCompareContext(ctx, before, head, headOwner, headName) 64 80 setImageCompareContext(ctx) 65 81 setCsvCompareContext(ctx) ··· 87 103 88 104 // setImageCompareContext sets context data that is required by image compare template 89 105 func setImageCompareContext(ctx *context.Context) { 90 - ctx.Data["IsBlobAnImage"] = func(blob *git.Blob) bool { 91 - if blob == nil { 92 - return false 93 - } 94 - 95 - st, err := blob.GuessContentType() 96 - if err != nil { 97 - log.Error("GuessContentType failed: %v", err) 98 - return false 99 - } 106 + ctx.Data["IsSniffedTypeAnImage"] = func(st typesniffer.SniffedType) bool { 100 107 return st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()) 101 108 } 102 109 }
+5 -3
templates/repo/diff/box.tmpl
··· 97 97 {{/*notice: the index of Diff.Files should not be used for element ID, because the index will be restarted from 0 when doing load-more for PRs with a lot of files*/}} 98 98 {{$blobBase := call $.GetBlobByPathForCommit $.BeforeCommit $file.OldName}} 99 99 {{$blobHead := call $.GetBlobByPathForCommit $.HeadCommit $file.Name}} 100 - {{$isImage := or (call $.IsBlobAnImage $blobBase) (call $.IsBlobAnImage $blobHead)}} 100 + {{$sniffedTypeBase := call $.GetSniffedTypeForBlob $blobBase}} 101 + {{$sniffedTypeHead := call $.GetSniffedTypeForBlob $blobHead}} 102 + {{$isImage:= or (call $.IsSniffedTypeAnImage $sniffedTypeBase) (call $.IsSniffedTypeAnImage $sniffedTypeHead)}} 101 103 {{$isCsv := (call $.IsCsvFile $file)}} 102 104 {{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}} 103 105 {{$isExpandable := or (gt $file.Addition 0) (gt $file.Deletion 0) $file.IsBin}} ··· 198 200 <div id="diff-rendered-{{$file.NameHash}}" class="file-body file-code {{if $.IsSplitStyle}}code-diff-split{{else}}code-diff-unified{{end}} gt-overflow-x-scroll"> 199 201 <table class="chroma gt-w-100"> 200 202 {{if $isImage}} 201 - {{template "repo/diff/image_diff" dict "file" . "root" $ "blobBase" $blobBase "blobHead" $blobHead}} 203 + {{template "repo/diff/image_diff" dict "file" . "root" $ "blobBase" $blobBase "blobHead" $blobHead "sniffedTypeBase" $sniffedTypeBase "sniffedTypeHead" $sniffedTypeHead}} 202 204 {{else}} 203 - {{template "repo/diff/csv_diff" dict "file" . "root" $ "blobBase" $blobBase "blobHead" $blobHead}} 205 + {{template "repo/diff/csv_diff" dict "file" . "root" $ "blobBase" $blobBase "blobHead" $blobHead "sniffedTypeBase" $sniffedTypeBase "sniffedTypeHead" $sniffedTypeHead}} 204 206 {{end}} 205 207 </table> 206 208 </div>
+6 -1
templates/repo/diff/image_diff.tmpl
··· 1 1 {{if or .blobBase .blobHead}} 2 2 <tr> 3 3 <td colspan="2"> 4 - <div class="image-diff" data-path-before="{{.root.BeforeRawPath}}/{{PathEscapeSegments .file.OldName}}" data-path-after="{{.root.RawPath}}/{{PathEscapeSegments .file.Name}}"> 4 + <div class="image-diff" 5 + data-path-before="{{.root.BeforeRawPath}}/{{PathEscapeSegments .file.OldName}}" 6 + data-path-after="{{.root.RawPath}}/{{PathEscapeSegments .file.Name}}" 7 + data-mime-before="{{.sniffedTypeBase.GetMimeType}}" 8 + data-mime-after="{{.sniffedTypeHead.GetMimeType}}" 9 + > 5 10 <div class="ui secondary pointing tabular top attached borderless menu new-menu"> 6 11 <div class="new-menu-inner"> 7 12 <a class="item active" data-tab="diff-side-by-side-{{.file.Index}}">{{ctx.Locale.Tr "repo.diff.image.side_by_side"}}</a>
+3 -4
web_src/js/features/common-global.js
··· 11 11 import {showTemporaryTooltip} from '../modules/tippy.js'; 12 12 import {confirmModal} from './comp/ConfirmModal.js'; 13 13 import {showErrorToast} from '../modules/toast.js'; 14 - import {request} from '../modules/fetch.js'; 14 + import {request, POST} from '../modules/fetch.js'; 15 15 16 16 const {appUrl, appSubUrl, csrfToken, i18n} = window.config; 17 17 ··· 243 243 this.on('removedfile', (file) => { 244 244 $(`#${file.uuid}`).remove(); 245 245 if ($dropzone.data('remove-url')) { 246 - $.post($dropzone.data('remove-url'), { 247 - file: file.uuid, 248 - _csrf: csrfToken, 246 + POST($dropzone.data('remove-url'), { 247 + data: new URLSearchParams({file: file.uuid}), 249 248 }); 250 249 } 251 250 });
+37 -54
web_src/js/features/imagediff.js
··· 1 1 import $ from 'jquery'; 2 - import {hideElem} from '../utils/dom.js'; 2 + import {GET} from '../modules/fetch.js'; 3 + import {hideElem, loadElem} from '../utils/dom.js'; 4 + import {parseDom} from '../utils.js'; 3 5 4 - function getDefaultSvgBoundsIfUndefined(svgXml, src) { 6 + function getDefaultSvgBoundsIfUndefined(text, src) { 5 7 const DefaultSize = 300; 6 8 const MaxSize = 99999; 7 9 8 - const svg = svgXml.documentElement; 10 + const svgDoc = parseDom(text, 'image/svg+xml'); 11 + const svg = svgDoc.documentElement; 9 12 const width = svg?.width?.baseVal; 10 13 const height = svg?.height?.baseVal; 11 14 if (width === undefined || height === undefined) { ··· 65 68 }; 66 69 } 67 70 68 - $('.image-diff:not([data-image-diff-loaded])').each(function() { 71 + $('.image-diff:not([data-image-diff-loaded])').each(async function() { 69 72 const $container = $(this); 70 73 $container.attr('data-image-diff-loaded', 'true'); 71 74 72 75 // the container may be hidden by "viewed" checkbox, so use the parent's width for reference 73 76 const diffContainerWidth = Math.max($container.closest('.diff-file-box').width() - 300, 100); 74 - const pathAfter = $container.data('path-after'); 75 - const pathBefore = $container.data('path-before'); 76 77 77 78 const imageInfos = [{ 78 - loaded: false, 79 - path: pathAfter, 80 - $image: $container.find('img.image-after'), 79 + path: this.getAttribute('data-path-after'), 80 + mime: this.getAttribute('data-mime-after'), 81 + $images: $container.find('img.image-after'), // matches 3 <img> 81 82 $boundsInfo: $container.find('.bounds-info-after') 82 83 }, { 83 - loaded: false, 84 - path: pathBefore, 85 - $image: $container.find('img.image-before'), 84 + path: this.getAttribute('data-path-before'), 85 + mime: this.getAttribute('data-mime-before'), 86 + $images: $container.find('img.image-before'), // matches 3 <img> 86 87 $boundsInfo: $container.find('.bounds-info-before') 87 88 }]; 88 89 89 - for (const info of imageInfos) { 90 - if (info.$image.length > 0) { 91 - $.ajax({ 92 - url: info.path, 93 - success: (data, _, jqXHR) => { 94 - info.$image.on('load', () => { 95 - info.loaded = true; 96 - setReadyIfLoaded(); 97 - }).on('error', () => { 98 - info.loaded = true; 99 - setReadyIfLoaded(); 100 - info.$boundsInfo.text('(image error)'); 101 - }); 102 - info.$image.attr('src', info.path); 90 + await Promise.all(imageInfos.map(async (info) => { 91 + const [success] = await Promise.all(Array.from(info.$images, (img) => { 92 + return loadElem(img, info.path); 93 + })); 94 + // only the first images is associated with $boundsInfo 95 + if (!success) info.$boundsInfo.text('(image error)'); 96 + if (info.mime === 'image/svg+xml') { 97 + const resp = await GET(info.path); 98 + const text = await resp.text(); 99 + const bounds = getDefaultSvgBoundsIfUndefined(text, info.path); 100 + if (bounds) { 101 + info.$images.attr('width', bounds.width); 102 + info.$images.attr('height', bounds.height); 103 + hideElem(info.$boundsInfo); 104 + } 105 + } 106 + })); 103 107 104 - if (jqXHR.getResponseHeader('Content-Type') === 'image/svg+xml') { 105 - const bounds = getDefaultSvgBoundsIfUndefined(data, info.path); 106 - if (bounds) { 107 - info.$image.attr('width', bounds.width); 108 - info.$image.attr('height', bounds.height); 109 - hideElem(info.$boundsInfo); 110 - } 111 - } 112 - } 113 - }); 114 - } else { 115 - info.loaded = true; 116 - setReadyIfLoaded(); 117 - } 118 - } 108 + const $imagesAfter = imageInfos[0].$images; 109 + const $imagesBefore = imageInfos[1].$images; 119 110 120 - function setReadyIfLoaded() { 121 - if (imageInfos[0].loaded && imageInfos[1].loaded) { 122 - initViews(imageInfos[0].$image, imageInfos[1].$image); 123 - } 111 + initSideBySide(createContext($imagesAfter[0], $imagesBefore[0])); 112 + if ($imagesAfter.length > 0 && $imagesBefore.length > 0) { 113 + initSwipe(createContext($imagesAfter[1], $imagesBefore[1])); 114 + initOverlay(createContext($imagesAfter[2], $imagesBefore[2])); 124 115 } 125 116 126 - function initViews($imageAfter, $imageBefore) { 127 - initSideBySide(createContext($imageAfter[0], $imageBefore[0])); 128 - if ($imageAfter.length > 0 && $imageBefore.length > 0) { 129 - initSwipe(createContext($imageAfter[1], $imageBefore[1])); 130 - initOverlay(createContext($imageAfter[2], $imageBefore[2])); 131 - } 132 - 133 - $container.find('> .image-diff-tabs').removeClass('is-loading'); 134 - } 117 + $container.find('> .image-diff-tabs').removeClass('is-loading'); 135 118 136 119 function initSideBySide(sizes) { 137 120 let factor = 1;
+1 -3
web_src/js/modules/fetch.js
··· 11 11 export function request(url, {method = 'GET', headers = {}, data, body, ...other} = {}) { 12 12 let contentType; 13 13 if (!body) { 14 - if (data instanceof FormData) { 15 - body = data; 16 - } else if (data instanceof URLSearchParams) { 14 + if (data instanceof FormData || data instanceof URLSearchParams) { 17 15 body = data; 18 16 } else if (isObject(data) || Array.isArray(data)) { 19 17 contentType = 'application/json';
+4 -6
web_src/js/svg.js
··· 1 1 import {h} from 'vue'; 2 + import {parseDom, serializeXml} from './utils.js'; 2 3 import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg'; 3 4 import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg'; 4 5 import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg'; ··· 145 146 // At the moment, developers must check, pick and fill the names manually, 146 147 // most of the SVG icons in assets couldn't be used directly. 147 148 148 - const parser = new DOMParser(); 149 - const serializer = new XMLSerializer(); 150 - 151 149 // retrieve an HTML string for given SVG icon name, size and additional classes 152 150 export function svg(name, size = 16, className = '') { 153 151 if (!(name in svgs)) throw new Error(`Unknown SVG icon: ${name}`); 154 152 if (size === 16 && !className) return svgs[name]; 155 153 156 - const document = parser.parseFromString(svgs[name], 'image/svg+xml'); 154 + const document = parseDom(svgs[name], 'image/svg+xml'); 157 155 const svgNode = document.firstChild; 158 156 if (size !== 16) { 159 157 svgNode.setAttribute('width', String(size)); 160 158 svgNode.setAttribute('height', String(size)); 161 159 } 162 160 if (className) svgNode.classList.add(...className.split(/\s+/).filter(Boolean)); 163 - return serializer.serializeToString(svgNode); 161 + return serializeXml(svgNode); 164 162 } 165 163 166 164 export function svgParseOuterInner(name) { ··· 176 174 if (p1 === -1 || p2 === -1) throw new Error(`Invalid SVG icon: ${name}`); 177 175 const svgInnerHtml = svgStr.slice(p1 + 1, p2); 178 176 const svgOuterHtml = svgStr.slice(0, p1 + 1) + svgStr.slice(p2); 179 - const svgDoc = parser.parseFromString(svgOuterHtml, 'image/svg+xml'); 177 + const svgDoc = parseDom(svgOuterHtml, 'image/svg+xml'); 180 178 const svgOuter = svgDoc.firstChild; 181 179 return {svgOuter, svgInnerHtml}; 182 180 }
+11
web_src/js/utils.js
··· 128 128 .replace(/_/g, '/') 129 129 .replace(/-/g, '+')); 130 130 } 131 + 132 + const domParser = new DOMParser(); 133 + const xmlSerializer = new XMLSerializer(); 134 + 135 + export function parseDom(text, contentType) { 136 + return domParser.parseFromString(text, contentType); 137 + } 138 + 139 + export function serializeXml(node) { 140 + return xmlSerializer.serializeToString(node); 141 + }
+11
web_src/js/utils/dom.js
··· 183 183 export function onInputDebounce(fn) { 184 184 return debounce(300, fn); 185 185 } 186 + 187 + // Set the `src` attribute on an element and returns a promise that resolves once the element 188 + // has loaded or errored. Suitable for all elements mention in: 189 + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/load_event 190 + export function loadElem(el, src) { 191 + return new Promise((resolve) => { 192 + el.addEventListener('load', () => resolve(true), {once: true}); 193 + el.addEventListener('error', () => resolve(false), {once: true}); 194 + el.src = src; 195 + }); 196 + }