Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
145d6b3
Fix `relative-time` error with invalid `navigator.language`
silverwind Apr 16, 2026
6bba33a
Add margin and green tint to copy-success feedback
silverwind Apr 16, 2026
986524b
Refactor showGlobalError to accept Error + options
silverwind Apr 16, 2026
991e3d9
Revert showGlobalErrorMessage API to string + optional stack
silverwind Apr 16, 2026
7a3668c
Address review feedback
silverwind Apr 16, 2026
fed4ff3
Drop redundant stack-for-copy test
silverwind Apr 16, 2026
7d99c9d
Shrink diff against main
silverwind Apr 16, 2026
60a6352
Rework error UI to use <details>/<summary>/<pre><code>
silverwind Apr 17, 2026
708ec77
Merge branch 'main' into relative-time-invalid-lang
silverwind Apr 17, 2026
131b7cd
fix
wxiaoguang Apr 18, 2026
c3a286e
Merge branch 'main' into relative-time-invalid-lang
silverwind Apr 19, 2026
e90c234
Address review: restore console hint, drop homepage e2e, simplify sta…
silverwind Apr 19, 2026
aaca6e7
Address review: drop text-center/whitespace, revert to simple append
silverwind Apr 20, 2026
03ebb69
Merge remote-tracking branch 'origin/main' into relative-time-invalid…
silverwind Apr 20, 2026
7cd7a9e
fix lint
silverwind Apr 20, 2026
8ec30f7
fix lint
wxiaoguang Apr 21, 2026
26bf1e4
Merge branch 'main' into relative-time-invalid-lang
silverwind Apr 21, 2026
ca6f7df
Merge branch 'main' into relative-time-invalid-lang
lunny Apr 21, 2026
ab30e9f
Merge branch 'main' into relative-time-invalid-lang
GiteaBot Apr 21, 2026
4a25a35
Merge branch 'main' into relative-time-invalid-lang
GiteaBot Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions templates/devtest/toast-and-message.tmpl
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
{{template "devtest/devtest-header"}}
<div class="ui container">
<h1>Toast</h1>
<div>
<button class="ui button toast-test-button" data-toast-level="info" data-toast-message="test info">Show Info Toast</button>
<button class="ui button toast-test-button" data-toast-level="warning" data-toast-message="test warning">Show Warning Toast</button>
<button class="ui button toast-test-button" data-toast-level="error" data-toast-message="test error">Show Error Toast</button>
<button class="ui button toast-test-button" data-toast-level="error" data-toast-message="very looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong message">Show Error Toast (long)</button>
<div class="page-content">
<div data-global-init="initDevtestDetailsErrorMessage"></div>
<div class="ui container">
<h1>Toast</h1>
<div>
<button class="ui button toast-test-button" data-toast-level="info" data-toast-message="test info">Show Info Toast</button>
<button class="ui button toast-test-button" data-toast-level="warning" data-toast-message="test warning">Show Warning Toast</button>
<button class="ui button toast-test-button" data-toast-level="error" data-toast-message="test error">Show Error Toast</button>
<button class="ui button toast-test-button" data-toast-level="error" data-toast-message="very looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong message">Show Error Toast (long)</button>
</div>
</div>
</div>
{{template "devtest/devtest-footer"}}
19 changes: 19 additions & 0 deletions web_src/css/modules/message.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions web_src/js/modules/devtest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (message: string) => Toast | null>;

Expand Down Expand Up @@ -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`);
}
});
}
25 changes: 23 additions & 2 deletions web_src/js/modules/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import {isGiteaError, showGlobalErrorMessage} from './errors.ts';
import {isGiteaError, processWindowErrorEvent, showGlobalErrorMessage} from './errors.ts';

beforeEach(() => {
document.body.innerHTML = '<div class="page-content"></div>';
});

test('isGiteaError', () => {
expect(isGiteaError('', '')).toBe(true);
Expand All @@ -16,7 +20,6 @@ test('isGiteaError', () => {
});

test('showGlobalErrorMessage', () => {
document.body.innerHTML = '<div class="page-content"></div>';
showGlobalErrorMessage('test msg 1');
showGlobalErrorMessage('test msg 2');
showGlobalErrorMessage('test msg 1'); // duplicated
Expand All @@ -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');
});
43 changes: 27 additions & 16 deletions web_src/js/modules/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(`.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<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgContainer) {
const el = document.createElement('div');
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
msgDiv = el.childNodes[0] as HTMLDivElement;
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><details class="ui ${msgType} message"><summary></summary></details></div>`;
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
Expand Down Expand Up @@ -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;

const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
let msg = err?.message ?? message;
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
if (!err?.stack && 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.`);
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`, 'error', err?.stack);
}
12 changes: 12 additions & 0 deletions web_src/js/webcomponents/relative-time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 5 additions & 5 deletions web_src/js/webcomponents/relative-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down