diff --git a/templates/devtest/toast-and-message.tmpl b/templates/devtest/toast-and-message.tmpl index c4056b6fc6b..484110ef1bf 100644 --- a/templates/devtest/toast-and-message.tmpl +++ b/templates/devtest/toast-and-message.tmpl @@ -1,11 +1,14 @@ {{template "devtest/devtest-header"}} -
-

Toast

-
- - - - +
+
+
+

Toast

+
+ + + + +
{{template "devtest/devtest-footer"}} diff --git a/web_src/css/modules/message.css b/web_src/css/modules/message.css index d7a6cb8aee1..26f7af792f0 100644 --- a/web_src/css/modules/message.css +++ b/web_src/css/modules/message.css @@ -12,6 +12,25 @@ border-radius: var(--border-radius); } +details.ui.message { + padding: 0; +} + +details.ui.message summary { + padding: 1em 1.5em; +} + +details.ui.message pre { + margin: -1.25em 0 0; + padding: 0.5em 1.5em; + white-space: pre-wrap; +} + +details.ui.message:not(:has(pre)) summary { + list-style: none; + cursor: text; +} + .ui.message:first-child { margin-top: 0; } diff --git a/web_src/js/modules/devtest.ts b/web_src/js/modules/devtest.ts index 7886ff97480..d96df89bae0 100644 --- a/web_src/js/modules/devtest.ts +++ b/web_src/js/modules/devtest.ts @@ -4,6 +4,7 @@ import {registerGlobalInitFunc} from './observer.ts'; import {fomanticQuery} from './fomantic/base.ts'; import {createElementFromHTML} from '../utils/dom.ts'; import {html} from '../utils/html.ts'; +import {showGlobalErrorMessage} from './errors.ts'; type LevelMap = Record Toast | null>; @@ -54,4 +55,10 @@ function initDevtestPage() { export function initDevtest() { registerGlobalInitFunc('initDevtestPage', initDevtestPage); + registerGlobalInitFunc('initDevtestDetailsErrorMessage', () => { + for (let i = 0; i < 2; i++) { + showGlobalErrorMessage('showGlobalErrorMessage single message', 'warning'); + showGlobalErrorMessage('showGlobalErrorMessage message with details', 'error', `detail message 1\nvery lo${'o'.repeat(200)}ng line 2\nline 3`); + } + }); } diff --git a/web_src/js/modules/errors.test.ts b/web_src/js/modules/errors.test.ts index efa114a88d0..df2431f9680 100644 --- a/web_src/js/modules/errors.test.ts +++ b/web_src/js/modules/errors.test.ts @@ -1,4 +1,8 @@ -import {isGiteaError, showGlobalErrorMessage} from './errors.ts'; +import {isGiteaError, processWindowErrorEvent, showGlobalErrorMessage} from './errors.ts'; + +beforeEach(() => { + document.body.innerHTML = '
'; +}); test('isGiteaError', () => { expect(isGiteaError('', '')).toBe(true); @@ -16,7 +20,6 @@ test('isGiteaError', () => { }); test('showGlobalErrorMessage', () => { - document.body.innerHTML = '
'; showGlobalErrorMessage('test msg 1'); showGlobalErrorMessage('test msg 2'); showGlobalErrorMessage('test msg 1'); // duplicated @@ -25,3 +28,21 @@ test('showGlobalErrorMessage', () => { expect(document.body.innerHTML).toContain('>test msg 2<'); expect(document.querySelectorAll('.js-global-error').length).toEqual(2); }); + +test('processWindowErrorEvent renders stack trace in details', () => { + const error = new Error('boom'); + error.stack = `Error: boom\n at fn (${window.location.origin}/assets/js/index.js:1:1)`; + processWindowErrorEvent({error, type: 'error'} as ErrorEvent & PromiseRejectionEvent); + expect(document.querySelector('.js-global-error summary')!.textContent).toContain('JavaScript error: boom'); + expect(document.querySelector('.js-global-error pre')!.textContent).toContain('/assets/js/index.js:1:1'); +}); + +test('processWindowErrorEvent falls back to message without stack', () => { + processWindowErrorEvent({ + error: {message: 'script error'}, type: 'error', + filename: `${window.location.origin}/assets/js/x.js`, lineno: 5, colno: 10, + } as ErrorEvent & PromiseRejectionEvent); + const msgText = document.querySelector('.js-global-error .ui.message')!.textContent; + expect(msgText).toContain('JavaScript error: script error'); + expect(msgText).toContain('@ 5:10'); +}); diff --git a/web_src/js/modules/errors.ts b/web_src/js/modules/errors.ts index 276b498ead7..d4fb549cfed 100644 --- a/web_src/js/modules/errors.ts +++ b/web_src/js/modules/errors.ts @@ -6,25 +6,36 @@ export function errorMessage(err: unknown): string { return (err as Error)?.message || String(err); } -export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') { - const msgContainer = document.querySelector('.page-content') ?? document.body; - if (!msgContainer) { +export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error', details?: string) { + const parentContainer = document.querySelector('.page-content') ?? document.body; + if (!parentContainer) { alert(`${msgType}: ${msg}`); return; } - const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages - let msgDiv = msgContainer.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`); - if (!msgDiv) { + // compact the message to a data attribute to avoid too many duplicated messages + const msgCompact = `${msgType}-${msg.trim()}`.replace(/[^-\w\u{80}-\u{10FFFF}]+/gu, ''); + let msgContainer = parentContainer.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`); + if (!msgContainer) { const el = document.createElement('div'); - el.innerHTML = html`
`; - msgDiv = el.childNodes[0] as HTMLDivElement; + el.innerHTML = html`
`; + msgContainer = el.childNodes[0] as HTMLDivElement; } + // merge duplicated messages into "the message (count)" format - const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1; - msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact); - msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString()); - msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : ''); - msgContainer.prepend(msgDiv); + const msgCount = Number(msgContainer.getAttribute(`data-global-error-msg-count`)) + 1; + msgContainer.setAttribute(`data-global-error-msg-compact`, msgCompact); + msgContainer.setAttribute(`data-global-error-msg-count`, msgCount.toString()); + + const msgElem = msgContainer.querySelector('details')!; + const msgSummary = msgElem.querySelector('summary')!; + msgSummary.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : ''); + if (details) { + let msgDetailsPre = msgElem.querySelector('pre'); + if (!msgDetailsPre) msgDetailsPre = document.createElement('pre'); + msgDetailsPre.textContent = details; + msgElem.append(msgDetailsPre); + } + parentContainer.prepend(msgContainer); } // Detect whether an error originated from Gitea's own scripts, not from @@ -53,9 +64,9 @@ export function processWindowErrorEvent({error, reason, message, type, filename, // Filter out errors from browser extensions or other non-Gitea scripts. if (!isGiteaError(filename ?? '', err?.stack ?? '')) return; - let msg = err?.message ?? message; - if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`; - const dot = msg.endsWith('.') ? '' : '.'; const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type; - showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`); + let msg = err?.message ?? message; + if (!err?.stack && lineno) msg += ` (${filename} @ ${lineno}:${colno})`; + const dot = msg.endsWith('.') ? '' : '.'; + showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`, 'error', err?.stack); } diff --git a/web_src/js/webcomponents/relative-time.test.ts b/web_src/js/webcomponents/relative-time.test.ts index 009006c71ba..e20f262ca4b 100644 --- a/web_src/js/webcomponents/relative-time.test.ts +++ b/web_src/js/webcomponents/relative-time.test.ts @@ -109,6 +109,18 @@ test('respects lang from parent element', async () => { expect(getText(el)).toBe('vor 3 Tagen'); }); +test('falls back when navigator.language is invalid', async () => { + vi.spyOn(navigator, 'language', 'get').mockReturnValue('undefined'); + try { + const el = document.createElement('relative-time'); + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString()); + await Promise.resolve(); + expect(getText(el)).toBe('3 minutes ago'); + } finally { + vi.restoreAllMocks(); + } +}); + test('switches to datetime with P1D threshold', async () => { const el = createRelativeTime(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), { lang: 'en-US', diff --git a/web_src/js/webcomponents/relative-time.ts b/web_src/js/webcomponents/relative-time.ts index e9e668bfc45..e37638b7c4a 100644 --- a/web_src/js/webcomponents/relative-time.ts +++ b/web_src/js/webcomponents/relative-time.ts @@ -258,13 +258,13 @@ class RelativeTime extends HTMLElement { } get #lang(): string { - const lang = this.closest('[lang]')?.getAttribute('lang'); - if (lang) { + for (const candidate of [this.closest('[lang]')?.getAttribute('lang'), navigator.language]) { + if (!candidate) continue; try { - return new Intl.Locale(lang).toString(); - } catch { /* invalid locale, fall through */ } + return String(new Intl.Locale(candidate)); + } catch {} } - return navigator.language ?? 'en'; + return 'en'; } get second(): 'numeric' | '2-digit' | undefined {