This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import {getActionStatusIcon} from './action-status-icon.ts';
|
||||
|
||||
test('getActionStatusIcon', () => {
|
||||
expect(getActionStatusIcon('success')).toEqual({name: 'octicon-check', colorClass: 'tw-text-green'});
|
||||
expect(getActionStatusIcon('success', 'circle-fill')).toEqual({name: 'octicon-check-circle-fill', colorClass: 'tw-text-green'});
|
||||
expect(getActionStatusIcon('running')).toEqual({name: 'gitea-running', colorClass: 'tw-text-yellow'});
|
||||
expect(getActionStatusIcon('failure', 'circle-fill')).toEqual({name: 'octicon-x-circle-fill', colorClass: 'tw-text-red'});
|
||||
expect(getActionStatusIcon('cancelled')).toEqual({name: 'octicon-stop', colorClass: 'tw-text-text-light'});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type {SvgName} from '../svg.ts';
|
||||
import type {ActionsStatus} from './gitea-actions.ts';
|
||||
|
||||
export type ActionStatusIconVariant = 'circle-fill' | '';
|
||||
|
||||
export type ActionStatusIconSpec = {
|
||||
name: SvgName,
|
||||
colorClass: string,
|
||||
};
|
||||
|
||||
// Keep in sync with templates/repo/icons/action_status.tmpl and ActionStatusIcon.vue.
|
||||
export function getActionStatusIcon(status: ActionsStatus, iconVariant: ActionStatusIconVariant = ''): ActionStatusIconSpec {
|
||||
const circleFill = iconVariant === 'circle-fill';
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return {name: circleFill ? 'octicon-check-circle-fill' : 'octicon-check', colorClass: 'tw-text-green'};
|
||||
case 'skipped':
|
||||
return {name: 'octicon-skip', colorClass: 'tw-text-text-light'};
|
||||
case 'cancelled':
|
||||
return {name: 'octicon-stop', colorClass: 'tw-text-text-light'};
|
||||
case 'waiting':
|
||||
return {name: 'octicon-circle', colorClass: 'tw-text-text-light'};
|
||||
case 'blocked':
|
||||
return {name: 'octicon-blocked', colorClass: 'tw-text-yellow'};
|
||||
case 'running':
|
||||
return {name: 'gitea-running', colorClass: 'tw-text-yellow'};
|
||||
case 'cancelling':
|
||||
return {name: 'octicon-stop', colorClass: 'tw-text-yellow'};
|
||||
case 'failure':
|
||||
case 'unknown':
|
||||
return {name: circleFill ? 'octicon-x-circle-fill' : 'octicon-x', colorClass: 'tw-text-red'};
|
||||
default: {
|
||||
const _exhaustive: never = status;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {clippie, type ClippieContent} from 'clippie';
|
||||
import {showTemporaryTooltip} from './tippy.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
|
||||
const {copy_success, copy_error} = window.config.i18n;
|
||||
const pendingFeedback = new WeakSet<HTMLElement>();
|
||||
|
||||
/** copy the copiable content to clipboard, return "true" on success, otherwise "false" */
|
||||
export async function copyToClipboard(content: ClippieContent): Promise<boolean> {
|
||||
return await clippie(content);
|
||||
}
|
||||
|
||||
/** Copy `content` to the clipboard. `target` is used to:
|
||||
* - avoid duplicate copy actions (especially when the content will be fetched from an async function)
|
||||
* - provide feedback to end users (its `.octicon-copy` is swapped to show success/fail feedback, or a tooltip if it has none)
|
||||
* When `content` is a function, `target` also shows a spinner while it resolves. */
|
||||
export async function copyToClipboardWithFeedback(target: HTMLElement, content: ClippieContent | (() => Promise<ClippieContent>)) {
|
||||
if (pendingFeedback.has(target)) return;
|
||||
pendingFeedback.add(target);
|
||||
|
||||
let success = false;
|
||||
const feedbackSvg = target.querySelector<SVGElement>('.octicon-copy');
|
||||
|
||||
// prepare copiable "content"
|
||||
try {
|
||||
if (typeof content === 'function') {
|
||||
if (feedbackSvg) target.style.setProperty('--loading-size', `${feedbackSvg.getAttribute('width')!}px`);
|
||||
target.classList.add('is-loading', 'loading-icon-2px');
|
||||
try {
|
||||
content = await content();
|
||||
} finally {
|
||||
target.classList.remove('is-loading', 'loading-icon-2px');
|
||||
target.style.removeProperty('--loading-size');
|
||||
}
|
||||
}
|
||||
success = await copyToClipboard(content);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// show feedback
|
||||
if (feedbackSvg) {
|
||||
const restore = replaceWithFeedbackSvg(feedbackSvg, success);
|
||||
await sleep(1000);
|
||||
restore();
|
||||
} else {
|
||||
showTemporaryTooltip(target, success ? copy_success : copy_error);
|
||||
}
|
||||
|
||||
pendingFeedback.delete(target);
|
||||
}
|
||||
|
||||
function replaceWithFeedbackSvg(origSvg: SVGElement, success: boolean): () => void {
|
||||
const size = Number(origSvg.getAttribute('width')!);
|
||||
const {icon, color} = success ?
|
||||
{icon: 'octicon-check', color: 'tw-text-green'} as const :
|
||||
{icon: 'octicon-x', color: 'tw-text-red'} as const;
|
||||
const newSvg = createElementFromHTML<SVGElement>(svg(icon, size, color));
|
||||
origSvg.replaceWith(newSvg);
|
||||
return () => newSvg.replaceWith(origSvg);
|
||||
}
|
||||
|
||||
// Enable clipboard copy from HTML attributes. These properties are supported:
|
||||
// - data-clipboard-text: Direct text to copy
|
||||
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied
|
||||
export function initGlobalCopyToClipboardListener() {
|
||||
document.addEventListener('click', async (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-clipboard-text], [data-clipboard-target]');
|
||||
if (!target) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let text = target.getAttribute('data-clipboard-text');
|
||||
if (text === null) {
|
||||
const textSelector = target.getAttribute('data-clipboard-target')!;
|
||||
const textTarget = document.querySelector(textSelector)!;
|
||||
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
|
||||
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
|
||||
} else if (textTarget.nodeName === 'DIV') {
|
||||
text = textTarget.textContent;
|
||||
} else {
|
||||
throw new Error(`Unsupported element for clipboard target: ${textSelector}`);
|
||||
}
|
||||
}
|
||||
// now, text can not be null
|
||||
await copyToClipboardWithFeedback(target, text);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {clippie} from 'clippie';
|
||||
import {createTippy} from '../tippy.ts';
|
||||
import {copyToClipboard} from '../clipboard.ts';
|
||||
import {keySymbols} from '../../utils.ts';
|
||||
import {goToDefinitionAt} from './utils.ts';
|
||||
import type {Instance} from 'tippy.js';
|
||||
@@ -95,13 +95,13 @@ function buildMenuItems(cm: CodemirrorModules, view: EditorView, togglePalette:
|
||||
'separator',
|
||||
{label: 'Cut', keys: 'Mod+X', disabled: !hasSelection, run: async (v) => {
|
||||
const {from, to} = v.state.selection.main;
|
||||
if (await clippie(v.state.doc.sliceString(from, to))) {
|
||||
if (await copyToClipboard(v.state.doc.sliceString(from, to))) {
|
||||
v.dispatch({changes: {from, to}});
|
||||
}
|
||||
}},
|
||||
{label: 'Copy', keys: 'Mod+C', disabled: !hasSelection, run: async (v) => {
|
||||
const {from, to} = v.state.selection.main;
|
||||
await clippie(v.state.doc.sliceString(from, to));
|
||||
await copyToClipboard(v.state.doc.sliceString(from, to));
|
||||
}},
|
||||
{label: 'Paste', keys: 'Mod+V', run: async (view) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {buildLanguageDescriptions, importCodemirror} from './main.ts';
|
||||
|
||||
test('matchFilename — language detection covers extended rules', async () => {
|
||||
const cm = await importCodemirror();
|
||||
const list = buildLanguageDescriptions(cm);
|
||||
const match = (filename: string) =>
|
||||
cm.language.LanguageDescription.matchFilename(list, filename)?.name;
|
||||
|
||||
// Linguist-supplied filenames + extensions
|
||||
expect(match('.bashrc')).toBe('Shell');
|
||||
expect(match('PKGBUILD')).toBe('Shell');
|
||||
expect(match('foo.zsh')).toBe('Shell');
|
||||
expect(match('Cargo.lock')).toBe('TOML');
|
||||
expect(match('Gemfile')).toBe('Ruby');
|
||||
expect(match('foo.gemspec')).toBe('Ruby');
|
||||
expect(match('foo.psgi')).toBe('Perl');
|
||||
expect(match('foo.pyi')).toBe('Python');
|
||||
expect(match('foo.webmanifest')).toBe('JSON');
|
||||
expect(match('foo.tcc')).toBe('C++');
|
||||
|
||||
// Script-side extras (extraFilenames / extraExtensions)
|
||||
expect(match('.editorconfig')).toBe('Properties files');
|
||||
expect(match('foo.conf')).toBe('Properties files');
|
||||
expect(match('Snakefile')).toBe('Python');
|
||||
|
||||
// Custom Gitea entries override language-data
|
||||
expect(match('Containerfile.test')).toBe('Dockerfile');
|
||||
expect(match('Dockerfile.dev')).toBe('Dockerfile');
|
||||
expect(match('Makefile.am')).toBe('Makefile');
|
||||
expect(match('foo.mk')).toBe('Makefile');
|
||||
expect(match('.env.local')).toBe('Dotenv');
|
||||
expect(match('foo.json5')).toBe('JSON5');
|
||||
expect(match('foo.mdown')).toBe('Markdown');
|
||||
|
||||
// Filename regex wins over extension match
|
||||
expect(match('nginx.conf')).toBe('Nginx');
|
||||
|
||||
// .spec routes to RPM Spec via excludeExt redirect
|
||||
expect(match('foo.spec')).toBe('RPM Spec');
|
||||
|
||||
// CM original ownership preserved against Linguist's broader claims (.sql is SQL,
|
||||
// not PLSQL, even though Linguist's PLSQL extension list includes it).
|
||||
expect(match('foo.sql')).toBe('SQL');
|
||||
expect(match('foo.h')).toBe('C');
|
||||
expect(match('foo.mm')).toBe('Objective-C++');
|
||||
|
||||
// Globally ambiguous extensions fall through to plain text
|
||||
expect(match('foo.cgi')).toBeUndefined();
|
||||
expect(match('foo.inc')).toBeUndefined();
|
||||
|
||||
// Smoke: existing language-data entries still resolve
|
||||
expect(match('foo.go')).toBe('Go');
|
||||
expect(match('foo.tsx')).toBe('TSX');
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import type {PaletteCommand} from './command-palette.ts';
|
||||
import {contextMenu, collectSymbols, selectAllOccurrences} from './context-menu.ts';
|
||||
import {createJsonLinter, createSyntaxErrorLinter} from './linter.ts';
|
||||
import {clickableUrls, goToDefinitionAt, trimTrailingWhitespaceFromView} from './utils.ts';
|
||||
import type {LanguageDescription} from '@codemirror/language';
|
||||
import type {LanguageDescription, LanguageSupport} from '@codemirror/language';
|
||||
import type {Compartment, Extension} from '@codemirror/state';
|
||||
import type {EditorView, ViewUpdate} from '@codemirror/view';
|
||||
|
||||
@@ -41,10 +41,12 @@ export type CodemirrorEditor = {
|
||||
};
|
||||
};
|
||||
|
||||
type LinguistLanguage = {name: string; extensions: string[]; filenames: string[]};
|
||||
|
||||
export type CodemirrorModules = Awaited<ReturnType<typeof importCodemirror>>;
|
||||
|
||||
async function importCodemirror() {
|
||||
const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap] = await Promise.all([
|
||||
export async function importCodemirror() {
|
||||
const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap, linguist] = await Promise.all([
|
||||
import('@codemirror/autocomplete'),
|
||||
import('@codemirror/commands'),
|
||||
import('@codemirror/language'),
|
||||
@@ -56,8 +58,77 @@ async function importCodemirror() {
|
||||
import('@lezer/highlight'),
|
||||
import('@replit/codemirror-indentation-markers'),
|
||||
import('@replit/codemirror-vscode-keymap'),
|
||||
import('../../../../assets/codemirror-languages.json'),
|
||||
]);
|
||||
return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap};
|
||||
return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap, linguistLanguages: linguist.default as LinguistLanguage[]};
|
||||
}
|
||||
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const filenameUnion = (filenames: string[]) =>
|
||||
filenames.length ? new RegExp(`^(${filenames.map(escapeRegex).join('|')})$`) : undefined;
|
||||
|
||||
export function buildLanguageDescriptions(cm: CodemirrorModules): LanguageDescription[] {
|
||||
const list: LanguageDescription[] = [
|
||||
...buildBaseLanguages(cm),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Markdown', extensions: ['md', 'markdown', 'mkd', 'mdown', 'mdwn', 'mkdn', 'mkdown'],
|
||||
load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: list}),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Dockerfile', extensions: ['dockerfile', 'containerfile'],
|
||||
filename: /^(Containerfile|Dockerfile)(\..+)?$/i,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/dockerfile')).dockerFile)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Elixir', extensions: ['ex', 'exs'],
|
||||
load: async () => (await import('codemirror-lang-elixir')).elixir(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Nix', extensions: ['nix'],
|
||||
load: async () => (await import('@replit/codemirror-lang-nix')).nix(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Svelte', extensions: ['svelte'],
|
||||
load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Makefile', extensions: ['mk', 'mak', 'make'], filename: /^(GNU|BSD)?[Mm]akefile(\..+)?$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'JSON5', extensions: ['json5', 'jsonc'],
|
||||
load: async () => (await import('@codemirror/lang-json')).json(),
|
||||
}),
|
||||
];
|
||||
return list;
|
||||
}
|
||||
|
||||
// Languages that the JSON omits because they're constructed manually above.
|
||||
const customNames = new Set(['Dockerfile', 'Markdown']);
|
||||
|
||||
let baseLanguagesCache: LanguageDescription[] | null = null;
|
||||
function buildBaseLanguages(cm: CodemirrorModules): LanguageDescription[] {
|
||||
if (baseLanguagesCache) return baseLanguagesCache;
|
||||
const loadByName = new Map<string, LanguageDescription['load']>(
|
||||
cm.languageData.languages.map((l: LanguageDescription) => [l.name, l.load.bind(l)]),
|
||||
);
|
||||
const overrides = cm.linguistLanguages
|
||||
.filter((l) => loadByName.has(l.name))
|
||||
.map((l) => cm.language.LanguageDescription.of({
|
||||
name: l.name,
|
||||
extensions: l.extensions,
|
||||
filename: filenameUnion(l.filenames),
|
||||
load: loadByName.get(l.name)!,
|
||||
}));
|
||||
const overrideNames = new Set(overrides.map((o) => o.name));
|
||||
const fallback = cm.languageData.languages.filter(
|
||||
(l: LanguageDescription) => !overrideNames.has(l.name) && !customNames.has(l.name),
|
||||
);
|
||||
return baseLanguagesCache = [...overrides, ...fallback];
|
||||
}
|
||||
|
||||
function togglePreviewDisplay(previewable: boolean): void {
|
||||
@@ -85,38 +156,7 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn
|
||||
const previewableExts = new Set(config.previewableExtensions || []);
|
||||
const lineWrapExts = config.lineWrapExtensions || [];
|
||||
const cm = await importCodemirror();
|
||||
|
||||
const languageDescriptions: LanguageDescription[] = [
|
||||
...cm.languageData.languages.filter((l: LanguageDescription) => l.name !== 'Markdown'),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Markdown', extensions: ['md', 'markdown', 'mkd'],
|
||||
load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: languageDescriptions}),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Elixir', extensions: ['ex', 'exs'],
|
||||
load: async () => (await import('codemirror-lang-elixir')).elixir(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Nix', extensions: ['nix'],
|
||||
load: async () => (await import('@replit/codemirror-lang-nix')).nix(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Svelte', extensions: ['svelte'],
|
||||
load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Makefile', filename: /^(GNUm|M|m)akefile$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'JSON5', extensions: ['json5', 'jsonc'],
|
||||
load: async () => (await import('@codemirror/lang-json')).json(),
|
||||
}),
|
||||
];
|
||||
const languageDescriptions = buildLanguageDescriptions(cm);
|
||||
const matchedLang = cm.language.LanguageDescription.matchFilename(languageDescriptions, config.filename);
|
||||
|
||||
const container = document.createElement('div');
|
||||
@@ -163,9 +203,7 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn
|
||||
},
|
||||
}),
|
||||
cm.language.foldGutter({
|
||||
markerDOM(open: boolean) {
|
||||
return createElementFromHTML(svg(open ? 'octicon-chevron-down' : 'octicon-chevron-right', 13));
|
||||
},
|
||||
markerDOM: (open: boolean) => createElementFromHTML(svg(open ? 'octicon-chevron-down' : 'octicon-chevron-right', 13)),
|
||||
}),
|
||||
cm.view.highlightActiveLineGutter(),
|
||||
cm.view.highlightSpecialChars(),
|
||||
@@ -295,16 +333,19 @@ export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameIn
|
||||
return editor;
|
||||
}
|
||||
|
||||
// files that are JSONC despite having a .json extension
|
||||
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$/;
|
||||
// files that the JSON parser is too strict for (comments, trailing commas)
|
||||
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$|\.(jsonc|json5)$/i;
|
||||
|
||||
async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: {language: unknown} | null): Promise<Extension> {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
if (ext === '.json' || ext === '.map') {
|
||||
async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: LanguageSupport | null): Promise<Extension> {
|
||||
if (!loadedLang) return [];
|
||||
const lang = loadedLang.language;
|
||||
// StreamLanguage (legacy modes) don't produce Lezer error nodes
|
||||
if (lang instanceof cm.language.StreamLanguage) return [];
|
||||
if (lang.name === 'json') {
|
||||
return jsoncFilesRegex.test(filename) ? [] : [cm.lint.lintGutter(), await createJsonLinter(cm)];
|
||||
}
|
||||
// StreamLanguage (legacy modes) don't produce Lezer error nodes
|
||||
if (!loadedLang || loadedLang.language instanceof cm.language.StreamLanguage) return [];
|
||||
// markdown's parser emits no error nodes, and nested code-fence overlays aren't traversed
|
||||
if (lang.name === 'markdown') return [];
|
||||
return [cm.lint.lintGutter(), createSyntaxErrorLinter(cm)];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,4 @@
|
||||
import {findUrlAtPosition, trimUrlPunctuation, urlRawRegex} from './utils.ts';
|
||||
|
||||
function matchUrls(text: string): string[] {
|
||||
return Array.from(text.matchAll(urlRawRegex), (m) => trimUrlPunctuation(m[0]));
|
||||
}
|
||||
|
||||
test('matchUrls', () => {
|
||||
expect(matchUrls('visit https://example.com for info')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com.')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com, and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com; and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('(https://example.com)')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('"https://example.com"')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash.')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://x.co')).toEqual(['https://x.co']);
|
||||
expect(matchUrls('https://example.com/path_(wiki)')).toEqual(['https://example.com/path_(wiki)']);
|
||||
expect(matchUrls('https://en.wikipedia.org/wiki/Rust_(programming_language)')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('(https://en.wikipedia.org/wiki/Rust_(programming_language))')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('http://example.com')).toEqual(['http://example.com']);
|
||||
expect(matchUrls('no url here')).toEqual([]);
|
||||
expect(matchUrls('https://a.com and https://b.com')).toEqual(['https://a.com', 'https://b.com']);
|
||||
expect(matchUrls('[](https://www.npmjs.org/package/pkg)')).toEqual(['https://img.shields.io/npm/v/pkg.svg?style=flat', 'https://www.npmjs.org/package/pkg']);
|
||||
});
|
||||
|
||||
test('trimUrlPunctuation', () => {
|
||||
expect(trimUrlPunctuation('https://example.com.')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com,')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com:')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation("https://example.com'")).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com"')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com.,;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com/path')).toEqual('https://example.com/path');
|
||||
expect(trimUrlPunctuation('https://example.com/path_(wiki)')).toEqual('https://example.com/path_(wiki)');
|
||||
expect(trimUrlPunctuation('https://example.com)')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://en.wikipedia.org/wiki/Rust_(lang))')).toEqual('https://en.wikipedia.org/wiki/Rust_(lang)');
|
||||
});
|
||||
import {findUrlAtPosition} from './utils.ts';
|
||||
|
||||
test('findUrlAtPosition', () => {
|
||||
const doc = 'visit https://example.com for info';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {EditorView, ViewUpdate} from '@codemirror/view';
|
||||
import type {CodemirrorModules} from './main.ts';
|
||||
import {trimUrlPunctuation, urlRawRegex} from '../../utils/url.ts';
|
||||
|
||||
/** Remove trailing whitespace from all lines in the editor. */
|
||||
export function trimTrailingWhitespaceFromView(view: EditorView): void {
|
||||
@@ -15,22 +16,9 @@ export function trimTrailingWhitespaceFromView(view: EditorView): void {
|
||||
if (changes.length) view.dispatch({changes});
|
||||
}
|
||||
|
||||
/** Matches URLs, excluding characters that are never valid unencoded in URLs per RFC 3986. */
|
||||
export const urlRawRegex = /\bhttps?:\/\/[^\s<>[\]]+/gi;
|
||||
|
||||
/** Strip trailing punctuation that is likely not part of the URL. */
|
||||
export function trimUrlPunctuation(url: string): string {
|
||||
url = url.replace(/[.,;:'"]+$/, '');
|
||||
// Strip trailing closing parens only if unbalanced (not part of the URL like Wikipedia links)
|
||||
while (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Find the URL at the given character position in a document string, or null if none. */
|
||||
export function findUrlAtPosition(doc: string, pos: number): string | null {
|
||||
for (const match of doc.matchAll(urlRawRegex)) {
|
||||
for (const match of doc.matchAll(urlRawRegex())) {
|
||||
const url = trimUrlPunctuation(match[0]);
|
||||
if (match.index !== undefined && pos >= match.index && pos < match.index + url.length) {
|
||||
return url;
|
||||
@@ -67,7 +55,7 @@ export function goToDefinitionAt(cm: CodemirrorModules, view: EditorView, pos: n
|
||||
export function clickableUrls(cm: CodemirrorModules) {
|
||||
const urlMark = cm.view.Decoration.mark({class: 'cm-url'});
|
||||
const urlDecorator = new cm.view.MatchDecorator({
|
||||
regexp: urlRawRegex,
|
||||
regexp: urlRawRegex(),
|
||||
decorate: (add, from, _to, match) => {
|
||||
const trimmed = trimUrlPunctuation(match[0]);
|
||||
add(from, from + trimmed.length, urlMark);
|
||||
|
||||
@@ -1,20 +1,67 @@
|
||||
import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
|
||||
import type {Toast} from './toast.ts';
|
||||
import {registerGlobalInitFunc} from './observer.ts';
|
||||
import {showFomanticModal} from './fomantic/modal.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>;
|
||||
|
||||
export function initDevtest() {
|
||||
registerGlobalInitFunc('initDevtestPage', () => {
|
||||
const els = document.querySelectorAll('.toast-test-button');
|
||||
if (!els.length) return;
|
||||
function initDevtestPage() {
|
||||
const toastButtons = document.querySelectorAll('.toast-test-button');
|
||||
if (toastButtons.length) {
|
||||
const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
|
||||
for (const el of els) {
|
||||
for (const el of toastButtons) {
|
||||
el.addEventListener('click', () => {
|
||||
const level = el.getAttribute('data-toast-level')!;
|
||||
const message = el.getAttribute('data-toast-message')!;
|
||||
levelMap[level](message);
|
||||
});
|
||||
}
|
||||
document.querySelector('.toast-test-button-pre')!.addEventListener('click', () => {
|
||||
showErrorToast(html`<div>message <pre>pre ${'a'.repeat(200)}</pre><details><summary>summary</summary>details</details></div>`, {useHtmlBody: true});
|
||||
});
|
||||
}
|
||||
|
||||
const modalButtons = document.querySelector('.modal-buttons');
|
||||
if (modalButtons) {
|
||||
for (const el of document.querySelectorAll('.ui.modal:not([data-skip-button])')) {
|
||||
const btn = createElementFromHTML(html`<button class="ui button">${el.id}</button`);
|
||||
btn.addEventListener('click', () => showFomanticModal(el));
|
||||
modalButtons.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
const sampleButtons = document.querySelectorAll('#devtest-button-samples button.ui.button');
|
||||
if (sampleButtons.length) {
|
||||
const buttonStyles = document.querySelectorAll<HTMLInputElement>('input[name*="button-style"]');
|
||||
for (const elStyle of buttonStyles) {
|
||||
elStyle.addEventListener('click', () => {
|
||||
for (const btn of sampleButtons) {
|
||||
for (const el of buttonStyles) {
|
||||
if (el.value) btn.classList.toggle(el.value, el.checked);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const buttonStates = document.querySelectorAll<HTMLInputElement>('input[name*="button-state"]');
|
||||
for (const elState of buttonStates) {
|
||||
elState.addEventListener('click', () => {
|
||||
for (const btn of sampleButtons) {
|
||||
(btn as any)[elState.value] = elState.checked;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -2,25 +2,46 @@
|
||||
import {html} from '../utils/html.ts';
|
||||
import type {Intent} from '../types.ts';
|
||||
|
||||
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
|
||||
const msgContainer = document.querySelector('.page-content') ?? document.body;
|
||||
if (!msgContainer) {
|
||||
/** Extract a message string from an unknown caught value. */
|
||||
export function errorMessage(err: unknown): string {
|
||||
return (err as Error)?.message || String(err);
|
||||
}
|
||||
|
||||
/** Extract a name string from an unknown caught value. */
|
||||
export function errorName(err: unknown): string {
|
||||
return (err as Error)?.name ?? '';
|
||||
}
|
||||
|
||||
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="${CSS.escape(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.firstElementChild 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
|
||||
@@ -30,8 +51,7 @@ export function isGiteaError(filename: string, stack: string): boolean {
|
||||
if (extensionRe.test(filename) || extensionRe.test(stack)) return false;
|
||||
const assetBaseUrl = new URL(`${window.config.assetUrlPrefix}/`, window.location.origin).href;
|
||||
if (filename && !filename.startsWith(assetBaseUrl) && !filename.startsWith(window.location.origin)) return false;
|
||||
if (stack && !stack.includes(assetBaseUrl)) return false;
|
||||
return true;
|
||||
return !stack || stack.includes(assetBaseUrl);
|
||||
}
|
||||
|
||||
export function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) {
|
||||
@@ -49,9 +69,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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {buildStatusFaviconSvg, resetActionFavicon, syncActionRunFavicon} from './favicon-status.ts';
|
||||
|
||||
test('buildStatusFaviconSvg uses action status icons', () => {
|
||||
const success = buildStatusFaviconSvg('success');
|
||||
expect(success).toContain('viewBox="0 0 640 640"');
|
||||
expect(success).toContain('fill:#609926');
|
||||
expect(success).toContain('data-actions-status-name="success"');
|
||||
|
||||
const running = buildStatusFaviconSvg('running');
|
||||
expect(running).toContain('data-actions-status-name="running"');
|
||||
|
||||
const failure = buildStatusFaviconSvg('failure');
|
||||
expect(failure).toContain('data-actions-status-name="failure"');
|
||||
});
|
||||
|
||||
test('syncActionRunFavicon updates favicon links', () => {
|
||||
document.head.innerHTML = `
|
||||
<link rel="icon" href="/assets/img/favicon.svg" type="image/svg+xml">
|
||||
<link rel="alternate icon" href="/assets/img/favicon.png" type="image/png">
|
||||
`;
|
||||
const links = Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]'));
|
||||
syncActionRunFavicon('running');
|
||||
for (const link of links) {
|
||||
expect(link.href).toMatch(/^data:image\/svg\+xml,/);
|
||||
expect(decodeURIComponent(link.href)).toContain('data-actions-status-name="running"');
|
||||
}
|
||||
resetActionFavicon();
|
||||
expect(links[0].href).toContain('favicon.svg');
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import {getActionStatusIcon} from './action-status-icon.ts';
|
||||
import type {ActionsStatus} from './gitea-actions.ts';
|
||||
import {svgParseOuterInner} from '../svg.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
|
||||
const {svgOuter, svgInnerHtml: giteaFaviconInner} = svgParseOuterInner('gitea-favicon');
|
||||
const faviconViewBox = svgOuter.getAttribute('viewBox')!;
|
||||
const [, , faviconViewBoxWidth, faviconViewBoxHeight] = faviconViewBox.split(/\s+/).map(Number);
|
||||
|
||||
// the status badge is rendered in the bottom-right corner, following GitHub Actions favicon proportions
|
||||
const badgeIconSize = 16;
|
||||
const badgeSizeRatio = 340 / 640;
|
||||
const badgeMargin = 6;
|
||||
const badgeDrawSize = faviconViewBoxWidth * badgeSizeRatio;
|
||||
const badgeX = faviconViewBoxWidth - badgeDrawSize - badgeMargin;
|
||||
const badgeY = faviconViewBoxHeight - badgeDrawSize - badgeMargin;
|
||||
const badgeScale = badgeDrawSize / badgeIconSize;
|
||||
// white ring behind the badge so it stands out from the logo, like GitHub's favicon
|
||||
const badgeCenter = badgeDrawSize / 2;
|
||||
const badgeRingRadius = badgeCenter + badgeDrawSize * 0.08;
|
||||
|
||||
let currentStatus: ActionsStatus | null = null;
|
||||
const defaultFaviconHrefs = new Map<HTMLLinkElement, string>();
|
||||
const faviconDataUrlCache = new Map<ActionsStatus, string>();
|
||||
let colorProbe: HTMLElement | null = null;
|
||||
|
||||
function rememberDefaultFaviconHrefs() {
|
||||
if (defaultFaviconHrefs.size > 0) return;
|
||||
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')) {
|
||||
defaultFaviconHrefs.set(link, link.href);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTailwindTextColor(colorClass: string): string {
|
||||
if (!colorProbe) {
|
||||
colorProbe = document.createElement('span');
|
||||
colorProbe.style.display = 'none';
|
||||
document.body.append(colorProbe);
|
||||
}
|
||||
colorProbe.className = colorClass;
|
||||
return getComputedStyle(colorProbe).color || '#000000';
|
||||
}
|
||||
|
||||
function buildStatusIconMarkup(status: ActionsStatus): string {
|
||||
const {name, colorClass} = getActionStatusIcon(status, 'circle-fill');
|
||||
const color = resolveTailwindTextColor(colorClass);
|
||||
const {svgInnerHtml} = svgParseOuterInner(name);
|
||||
const coloredInner = svgInnerHtml.replaceAll('currentColor', color);
|
||||
const ring = html`<circle cx="${badgeX + badgeCenter}" cy="${badgeY + badgeCenter}" r="${badgeRingRadius}" fill="#ffffff"/>`;
|
||||
const badge = html`<g data-actions-status-name="${status}" transform="translate(${badgeX}, ${badgeY}) scale(${badgeScale})" fill="${color}" color="${color}">${htmlRaw(coloredInner)}</g>`;
|
||||
return html`${htmlRaw(ring)}${htmlRaw(badge)}`;
|
||||
}
|
||||
|
||||
export function buildStatusFaviconSvg(status: ActionsStatus): string {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${faviconViewBox}">${htmlRaw(giteaFaviconInner)}${htmlRaw(buildStatusIconMarkup(status))}</svg>`;
|
||||
}
|
||||
|
||||
function buildStatusFaviconDataUrl(status: ActionsStatus): string {
|
||||
const cached = faviconDataUrlCache.get(status);
|
||||
if (cached) return cached;
|
||||
const dataUrl = `data:image/svg+xml,${encodeURIComponent(buildStatusFaviconSvg(status))}`;
|
||||
faviconDataUrlCache.set(status, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
function setFaviconHref(href: string) {
|
||||
rememberDefaultFaviconHrefs();
|
||||
for (const link of defaultFaviconHrefs.keys()) {
|
||||
if (link.isConnected) link.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
export function syncActionRunFavicon(status: ActionsStatus | ''): void {
|
||||
if (status === '') {
|
||||
resetActionFavicon();
|
||||
return;
|
||||
}
|
||||
if (status === currentStatus) return;
|
||||
setFaviconHref(buildStatusFaviconDataUrl(status));
|
||||
currentStatus = status;
|
||||
}
|
||||
|
||||
export function resetActionFavicon(): void {
|
||||
if (currentStatus === null) return;
|
||||
rememberDefaultFaviconHrefs();
|
||||
for (const [link, href] of defaultFaviconHrefs) {
|
||||
if (link.isConnected) link.href = href;
|
||||
}
|
||||
currentStatus = null;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import {isObject} from '../utils.ts';
|
||||
import type {RequestOpts} from '../types.ts';
|
||||
|
||||
// fetch wrapper, use below method name functions and the `data` option to pass in data
|
||||
// which will automatically set an appropriate headers. For json content, only object
|
||||
// which will automatically set an appropriate headers. For JSON content, only object
|
||||
// and array types are currently supported.
|
||||
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
|
||||
let body: string | FormData | URLSearchParams | undefined;
|
||||
@@ -14,17 +14,13 @@ export function request(url: string, {method = 'GET', data, headers = {}, ...oth
|
||||
body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
const headersMerged = new Headers({
|
||||
...(contentType && {'content-type': contentType}),
|
||||
});
|
||||
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
headersMerged.set(name, value);
|
||||
headers = new Headers(headers);
|
||||
if (!headers.has('content-type') && contentType) {
|
||||
headers.set('content-type', contentType);
|
||||
}
|
||||
|
||||
return fetch(url, { // eslint-disable-line no-restricted-globals
|
||||
method,
|
||||
headers: headersMerged,
|
||||
headers,
|
||||
...other,
|
||||
...(body && {body}),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import {initAriaCheckboxPatch} from './fomantic/checkbox.ts';
|
||||
import {initAriaFormFieldPatch} from './fomantic/form.ts';
|
||||
import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
|
||||
import {initAriaModalPatch} from './fomantic/modal.ts';
|
||||
import {initFomanticTransition} from './fomantic/transition.ts';
|
||||
import {initFomanticDimmer} from './fomantic/dimmer.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {initFomanticTab} from './fomantic/tab.ts';
|
||||
|
||||
export const fomanticMobileScreen = window.matchMedia('only screen and (max-width: 767.98px)');
|
||||
|
||||
@@ -24,11 +21,8 @@ export function initGiteaFomantic() {
|
||||
|
||||
initFomanticTransition();
|
||||
initFomanticDimmer();
|
||||
initFomanticTab();
|
||||
|
||||
// Use the patches to improve accessibility, these patches are designed to be as independent as possible, make it easy to modify or remove in the future.
|
||||
initAriaCheckboxPatch();
|
||||
initAriaFormFieldPatch();
|
||||
initAriaDropdownPatch();
|
||||
initAriaModalPatch();
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ However, the templates still have the Fomantic-style HTML layout:
|
||||
</div>
|
||||
```
|
||||
|
||||
We call `initAriaCheckboxPatch` to link the `input` and `label` which makes clicking the
|
||||
We call `initAriaLabels` to link the `input` and `label` which makes clicking the
|
||||
label etc. work. There is still a problem: These checkboxes are not friendly to screen readers,
|
||||
so we add IDs to all the Fomantic UI checkboxes automatically by JS. If the `label` part is empty,
|
||||
then the checkbox needs to get the `aria-label` attribute manually.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {generateElemId} from '../../utils/dom.ts';
|
||||
import {generateElemId, queryElems} from '../../utils/dom.ts';
|
||||
|
||||
export function linkLabelAndInput(label: Element, input: Element) {
|
||||
function linkLabelAndInput(label: Element, input: Element) {
|
||||
const labelFor = label.getAttribute('for');
|
||||
const inputId = input.getAttribute('id');
|
||||
|
||||
@@ -13,6 +13,34 @@ export function linkLabelAndInput(label: Element, input: Element) {
|
||||
}
|
||||
}
|
||||
|
||||
function patchLabels(parent: ParentNode, containerSelector: string, labelSelector: string, inputSelector: string, marker: string) {
|
||||
// Sample layout for this function:
|
||||
// <div parent>
|
||||
// <div container><label/><input/></div>
|
||||
// <div container><label/><input/></div>
|
||||
// </div>
|
||||
//
|
||||
// OR the parent is also the container:
|
||||
// <div parent container><label/><input/></div>
|
||||
|
||||
const patchLabelContainer = (container: Element) => {
|
||||
if (container.hasAttribute(marker)) return;
|
||||
const label = container.querySelector(labelSelector);
|
||||
const input = container.querySelector(inputSelector);
|
||||
if (!label || !input) return;
|
||||
linkLabelAndInput(label, input);
|
||||
container.setAttribute(marker, 'true');
|
||||
};
|
||||
queryElems(parent, containerSelector, patchLabelContainer);
|
||||
if (parent instanceof Element && parent.matches(containerSelector)) patchLabelContainer(parent);
|
||||
}
|
||||
|
||||
// link labels and inputs in `.ui.checkbox` and `.ui.form .field` so labels are clickable and accessible
|
||||
export function initAriaLabels(container: ParentNode) {
|
||||
patchLabels(container, '.ui.checkbox', 'label', 'input', 'data-checkbox-patched');
|
||||
patchLabels(container, '.ui.form .field', ':scope > label', ':scope > input, :scope > select', 'data-field-patched');
|
||||
}
|
||||
|
||||
export function fomanticQuery(s: string | Element | NodeListOf<Element>): ReturnType<typeof $> {
|
||||
// intentionally make it only work for query selector, it isn't used for creating HTML elements (for safety)
|
||||
return typeof s === 'string' ? $(document).find(s) : $(s);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import {linkLabelAndInput} from './base.ts';
|
||||
|
||||
export function initAriaCheckboxPatch() {
|
||||
// link the label and the input element so it's clickable and accessible
|
||||
for (const el of document.querySelectorAll('.ui.checkbox')) {
|
||||
if (el.hasAttribute('data-checkbox-patched')) continue;
|
||||
const label = el.querySelector('label');
|
||||
const input = el.querySelector('input');
|
||||
if (!label || !input) continue;
|
||||
linkLabelAndInput(label, input);
|
||||
el.setAttribute('data-checkbox-patched', 'true');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,23 @@
|
||||
import '../../../fomantic/build/fomantic.js';
|
||||
import {createElementFromHTML} from '../../utils/dom.ts';
|
||||
import {hideScopedEmptyDividers} from './dropdown.ts';
|
||||
|
||||
test('dropdown-item-literal-text', () => {
|
||||
// a "choice" workflow_dispatch input can offer the string "false" as an option.
|
||||
// jQuery `.data()` would coerce `data-text="false"` to the boolean `false`, which then renders as empty text.
|
||||
const $dropdown = $(`<select class="ui dropdown">
|
||||
<option value="1">1</option>
|
||||
<option value="0">0</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>`).dropdown();
|
||||
for (const value of ['1', '0', 'true', 'false']) {
|
||||
$dropdown.dropdown('set selected', value);
|
||||
expect($dropdown.dropdown('get text')).toEqual(value);
|
||||
expect($dropdown.dropdown('get value')).toEqual(value);
|
||||
}
|
||||
});
|
||||
|
||||
test('hideScopedEmptyDividers-simple', () => {
|
||||
const container = createElementFromHTML(`<div>
|
||||
<div class="divider"></div>
|
||||
@@ -13,13 +30,13 @@ test('hideScopedEmptyDividers-simple', () => {
|
||||
</div>`);
|
||||
hideScopedEmptyDividers(container);
|
||||
expect(container.innerHTML).toEqual(`
|
||||
<div class="divider hidden transition"></div>
|
||||
<div class="divider hidden"></div>
|
||||
<div class="item">a</div>
|
||||
<div class="divider hidden transition"></div>
|
||||
<div class="divider hidden transition"></div>
|
||||
<div class="divider hidden"></div>
|
||||
<div class="divider hidden"></div>
|
||||
<div class="divider"></div>
|
||||
<div class="item">b</div>
|
||||
<div class="divider hidden transition"></div>
|
||||
<div class="divider hidden"></div>
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -35,7 +52,7 @@ test('hideScopedEmptyDividers-items-all-filtered', () => {
|
||||
hideScopedEmptyDividers(container);
|
||||
expect(container.innerHTML).toEqual(`
|
||||
<div class="any"></div>
|
||||
<div class="divider hidden transition"></div>
|
||||
<div class="divider hidden"></div>
|
||||
<div class="item filtered">a</div>
|
||||
<div class="item filtered">b</div>
|
||||
<div class="divider"></div>
|
||||
@@ -52,7 +69,7 @@ test('hideScopedEmptyDividers-hide-last', () => {
|
||||
hideScopedEmptyDividers(container);
|
||||
expect(container.innerHTML).toEqual(`
|
||||
<div class="item">a</div>
|
||||
<div class="divider hidden transition" data-scope="b"></div>
|
||||
<div class="divider hidden" data-scope="b"></div>
|
||||
<div class="item tw-hidden" data-scope="b">b</div>
|
||||
`);
|
||||
});
|
||||
@@ -68,9 +85,9 @@ test('hideScopedEmptyDividers-scoped-items', () => {
|
||||
hideScopedEmptyDividers(container);
|
||||
expect(container.innerHTML).toEqual(`
|
||||
<div class="item" data-scope="">a</div>
|
||||
<div class="divider hidden transition" data-scope="b"></div>
|
||||
<div class="divider hidden" data-scope="b"></div>
|
||||
<div class="item tw-hidden" data-scope="b">b</div>
|
||||
<div class="divider hidden transition" data-scope=""></div>
|
||||
<div class="divider hidden" data-scope=""></div>
|
||||
<div class="item" data-scope="">c</div>
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ const fomanticDropdownFn = $.fn.dropdown;
|
||||
export function initAriaDropdownPatch() {
|
||||
if ($.fn.dropdown === ariaDropdownFn) throw new Error('initAriaDropdownPatch could only be called once');
|
||||
$.fn.dropdown = ariaDropdownFn;
|
||||
$.fn.fomanticExt.onResponseKeepSelectedItem = onResponseKeepSelectedItem;
|
||||
$.fn.fomanticExt.onDropdownAfterFiltered = onDropdownAfterFiltered;
|
||||
(ariaDropdownFn as FomanticInitFunction).settings = fomanticDropdownFn.settings;
|
||||
}
|
||||
@@ -254,22 +253,22 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
|
||||
dropdown.addEventListener('mousedown', () => {
|
||||
ignoreClickPreVisible += isMenuVisible() ? 1 : 0;
|
||||
ignoreClickPreEvents++;
|
||||
}, true);
|
||||
}, {capture: true});
|
||||
dropdown.addEventListener('focus', () => {
|
||||
ignoreClickPreVisible += isMenuVisible() ? 1 : 0;
|
||||
ignoreClickPreEvents++;
|
||||
deferredRefreshAriaActiveItem();
|
||||
}, true);
|
||||
}, {capture: true});
|
||||
dropdown.addEventListener('blur', () => {
|
||||
ignoreClickPreVisible = ignoreClickPreEvents = 0;
|
||||
deferredRefreshAriaActiveItem(100);
|
||||
}, true);
|
||||
}, {capture: true});
|
||||
dropdown.addEventListener('mouseup', () => {
|
||||
setTimeout(() => {
|
||||
ignoreClickPreVisible = ignoreClickPreEvents = 0;
|
||||
deferredRefreshAriaActiveItem(100);
|
||||
}, 0);
|
||||
}, true);
|
||||
}, {capture: true});
|
||||
dropdown.addEventListener('click', (e: MouseEvent) => {
|
||||
if (isMenuVisible() &&
|
||||
ignoreClickPreVisible !== 2 && // dropdown is switch from invisible to visible
|
||||
@@ -278,7 +277,7 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
|
||||
e.stopPropagation(); // if the dropdown menu has been opened by focus, do not trigger the next click event again
|
||||
}
|
||||
ignoreClickPreEvents = ignoreClickPreVisible = 0;
|
||||
}, true);
|
||||
}, {capture: true});
|
||||
}
|
||||
|
||||
// Although Fomantic Dropdown supports "hideDividers", it doesn't really work with our "scoped dividers"
|
||||
@@ -296,8 +295,8 @@ export function hideScopedEmptyDividers(container: Element) {
|
||||
let curScope: string = '', lastVisibleScope: string = '';
|
||||
const isDivider = (item: Element) => item.classList.contains('divider');
|
||||
const isScopedDivider = (item: Element) => isDivider(item) && item.hasAttribute('data-scope');
|
||||
const hideDivider = (item: Element) => item.classList.add('hidden', 'transition'); // dropdown has its own classes to hide items
|
||||
const showDivider = (item: Element) => item.classList.remove('hidden', 'transition');
|
||||
const hideDivider = (item: Element) => item.classList.add('hidden'); // dropdown has its own classes to hide items
|
||||
const showDivider = (item: Element) => item.classList.remove('hidden');
|
||||
const isHidden = (item: Element) => item.classList.contains('hidden') || item.classList.contains('filtered') || item.classList.contains('tw-hidden');
|
||||
const handleScopeSwitch = (itemScope: string) => {
|
||||
if (curScopeVisibleItems.length === 1 && isScopedDivider(curScopeVisibleItems[0])) {
|
||||
@@ -324,7 +323,7 @@ export function hideScopedEmptyDividers(container: Element) {
|
||||
handleScopeSwitch(itemScope);
|
||||
}
|
||||
if (!isHidden(item)) {
|
||||
curScopeVisibleItems.push(item as HTMLElement);
|
||||
curScopeVisibleItems.push(item);
|
||||
}
|
||||
}
|
||||
handleScopeSwitch('');
|
||||
@@ -347,19 +346,3 @@ export function hideScopedEmptyDividers(container: Element) {
|
||||
if (visibleItems[i + 1].matches('.divider')) hideDivider(visibleItems[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function onResponseKeepSelectedItem(dropdown: typeof $ | HTMLElement, selectedValue: string) {
|
||||
// There is a bug in fomantic dropdown when using "apiSettings" to fetch data
|
||||
// * when there is a selected item, the dropdown insists on hiding the selected one from the list:
|
||||
// * in the "filter" function: ('[data-value="'+value+'"]').addClass(className.filtered)
|
||||
//
|
||||
// When user selects one item, and click the dropdown again,
|
||||
// then the dropdown only shows other items and will select another (wrong) one.
|
||||
// It can't be easily fix by using setTimeout(patch, 0) in `onResponse` because the `onResponse` is called before another `setTimeout(..., timeLeft)`
|
||||
// Fortunately, the "timeLeft" is controlled by "loadingDuration" which is always zero at the moment, so we can use `setTimeout(..., 10)`
|
||||
const elDropdown = (dropdown instanceof HTMLElement) ? dropdown : (dropdown as any)[0];
|
||||
setTimeout(() => {
|
||||
queryElems(elDropdown, `.menu .item[data-value="${CSS.escape(selectedValue)}"].filtered`, (el) => el.classList.remove('filtered'));
|
||||
$(elDropdown).dropdown('set selected', selectedValue ?? '');
|
||||
}, 10);
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import {linkLabelAndInput} from './base.ts';
|
||||
|
||||
export function initAriaFormFieldPatch() {
|
||||
// link the label and the input element so it's clickable and accessible
|
||||
for (const el of document.querySelectorAll('.ui.form .field')) {
|
||||
if (el.hasAttribute('data-field-patched')) continue;
|
||||
const label = el.querySelector(':scope > label');
|
||||
const input = el.querySelector(':scope > input, :scope > select');
|
||||
if (!label || !input) continue;
|
||||
linkLabelAndInput(label, input);
|
||||
el.setAttribute('data-field-patched', 'true');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,27 @@ import type {FomanticInitFunction} from '../../types.ts';
|
||||
import {queryElems} from '../../utils/dom.ts';
|
||||
import {hideToastsFrom} from '../toast.ts';
|
||||
|
||||
type ModalOpts = {
|
||||
closable?: boolean;
|
||||
onApprove?: (this: HTMLElement) => boolean | void;
|
||||
onShow?: (this: HTMLElement) => void | Promise<void>;
|
||||
onHide?: (this: HTMLElement) => void;
|
||||
onHidden?: (this: HTMLElement) => void;
|
||||
};
|
||||
|
||||
// thin wrapper around Fomantic's jQuery modal plugin so callers don't have to touch jQuery or fomanticQuery
|
||||
export function showFomanticModal(el: Element | null, opts: ModalOpts = {}) {
|
||||
if (!el) return;
|
||||
const $el = $(el);
|
||||
if (Object.keys(opts).length) $el.modal(opts);
|
||||
$el.modal('show');
|
||||
}
|
||||
|
||||
export function hideFomanticModal(el: Element | null) {
|
||||
if (!el) return;
|
||||
$(el).modal('hide');
|
||||
}
|
||||
|
||||
const fomanticModalFn = $.fn.modal;
|
||||
|
||||
// use our own `$.fn.modal` to patch Fomantic's modal module
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import {queryElemSiblings} from '../../utils/dom.ts';
|
||||
|
||||
export function initFomanticTab() {
|
||||
$.fn.tab = function (this: any) {
|
||||
for (const elBtn of this) {
|
||||
const tabName = elBtn.getAttribute('data-tab');
|
||||
if (!tabName) continue;
|
||||
elBtn.addEventListener('click', () => {
|
||||
const elTab = document.querySelector(`.ui.tab[data-tab="${tabName}"]`)!;
|
||||
queryElemSiblings(elTab, `.ui.tab`, (el) => el.classList.remove('active'));
|
||||
queryElemSiblings(elBtn, `[data-tab]`, (el) => el.classList.remove('active'));
|
||||
elBtn.classList.add('active');
|
||||
elTab.classList.add('active');
|
||||
});
|
||||
}
|
||||
return this;
|
||||
};
|
||||
export function initTabSwitcher(tabItemContainer: Element) {
|
||||
// Clicking a `.item[data-tab]` menu item activates the matching `.ui.tab[data-tab=...]` panel
|
||||
// This design is from Fomantic UI, and it has problems like :
|
||||
// * The panel selector is global, callers should make sure the "data-tab" values don't conflict on the same page
|
||||
const tabItems = tabItemContainer.querySelectorAll('.item[data-tab]');
|
||||
for (const elItem of tabItems) {
|
||||
const tabName = elItem.getAttribute('data-tab')!;
|
||||
elItem.addEventListener('click', () => {
|
||||
const elPanel = document.querySelector(`.ui.tab[data-tab="${CSS.escape(tabName)}"]`)!;
|
||||
queryElemSiblings(elPanel, '.ui.tab', (el) => el.classList.remove('active'));
|
||||
queryElemSiblings(elItem, '.item[data-tab]', (el) => el.classList.remove('active'));
|
||||
elItem.classList.add('active');
|
||||
elPanel.classList.add('active');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
|
||||
export type ActionsRunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
|
||||
export type ActionsStatus = 'unknown' | 'waiting' | 'running' | 'cancelling' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
|
||||
export type ActionsArtifactStatus = 'expired' | 'completed';
|
||||
|
||||
export type ActionsRun = {
|
||||
repoId: number,
|
||||
index: number,
|
||||
link: string,
|
||||
viewLink: string,
|
||||
title: string,
|
||||
titleHTML: string,
|
||||
status: ActionsRunStatus,
|
||||
status: ActionsStatus,
|
||||
canCancel: boolean,
|
||||
canApprove: boolean,
|
||||
canRerun: boolean,
|
||||
@@ -15,11 +18,19 @@ export type ActionsRun = {
|
||||
done: boolean,
|
||||
workflowID: string,
|
||||
workflowLink: string,
|
||||
canViewWorkflowFile: boolean,
|
||||
isSchedule: boolean,
|
||||
runAttempt: number,
|
||||
attempts: Array<ActionsRunAttempt>,
|
||||
duration: string,
|
||||
triggeredAt: number,
|
||||
triggerEvent: string,
|
||||
pullRequest?: {
|
||||
index: string,
|
||||
link: string,
|
||||
} | null,
|
||||
jobs: Array<ActionsJob>,
|
||||
jobSummaries?: Array<ActionsJobSummary>,
|
||||
commit: {
|
||||
localeCommit: string,
|
||||
localePushedBy: string,
|
||||
@@ -28,6 +39,7 @@ export type ActionsRun = {
|
||||
pusher: {
|
||||
displayName: string,
|
||||
link: string,
|
||||
avatarLink: string,
|
||||
},
|
||||
branch: {
|
||||
name: string,
|
||||
@@ -37,17 +49,43 @@ export type ActionsRun = {
|
||||
},
|
||||
};
|
||||
|
||||
export type ActionsJobSummary = {
|
||||
jobId: number,
|
||||
jobName: string,
|
||||
summaryHTML: string,
|
||||
};
|
||||
|
||||
export type ActionsRunAttempt = {
|
||||
attempt: number;
|
||||
status: ActionsStatus;
|
||||
done: boolean;
|
||||
link: string;
|
||||
current: boolean;
|
||||
latest: boolean;
|
||||
triggeredAt: number;
|
||||
triggerUserName: string;
|
||||
triggerUserLink: string;
|
||||
triggerUserAvatar: string;
|
||||
};
|
||||
|
||||
export type ActionsJob = {
|
||||
id: number;
|
||||
link: string;
|
||||
jobId: string;
|
||||
name: string;
|
||||
status: ActionsRunStatus;
|
||||
status: ActionsStatus;
|
||||
canRerun: boolean;
|
||||
needs?: string[];
|
||||
duration: string;
|
||||
|
||||
isReusableCaller: boolean;
|
||||
parentJobID: number; // 0 for top-level jobs.
|
||||
callUses?: string;
|
||||
};
|
||||
|
||||
export type ActionsArtifact = {
|
||||
name: string;
|
||||
status: string;
|
||||
size: number;
|
||||
status: ActionsArtifactStatus;
|
||||
expiresUnix: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import {trN} from './i18n.ts';
|
||||
|
||||
test('trN', () => {
|
||||
expect(trN(0, '%d job', '%d jobs', {lang: 'en-US'})).toEqual('0 jobs');
|
||||
expect(trN(1, '%d job', '%d jobs', {lang: 'en-US'})).toEqual('1 job');
|
||||
expect(trN(2, '%d job', '%d jobs', {lang: 'en-US'})).toEqual('2 jobs');
|
||||
expect(trN(1000, '%d job', '%d jobs', {lang: 'en-US'})).toEqual('1000 jobs');
|
||||
// languages without a distinct singular always use the plural form
|
||||
expect(trN(1, '%d job', '%d jobs', {lang: 'zh-CN'})).toEqual('1 jobs');
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
|
||||
/** frontend `Locale.TrN`: pick the `_1` or `_n` form for `count` and interpolate `%d` */
|
||||
export function trN(count: number, form1: string, formN: string, {lang = getCurrentLocale()}: {lang?: string} = {}): string {
|
||||
const form = new Intl.PluralRules(lang).select(count) === 'one' ? form1 : formN;
|
||||
return form.replace('%d', String(count));
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||
import type {Promisable} from '../types.ts';
|
||||
import type {InitPerformanceTracer} from './init.ts';
|
||||
import {initAriaLabels} from './fomantic/base.ts';
|
||||
|
||||
let globalSelectorObserverInited = false;
|
||||
|
||||
type SelectorHandler = {selector: string, handler: (el: HTMLElement) => void};
|
||||
const selectorHandlers: SelectorHandler[] = [];
|
||||
type SelectorHandler<T extends Element> = {selector: string, handler: (el: T) => void};
|
||||
const selectorHandlers: SelectorHandler<Element>[] = [];
|
||||
|
||||
type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => Promisable<void>;
|
||||
const globalEventFuncs: Record<string, GlobalEventFunc<HTMLElement, Event>> = {};
|
||||
|
||||
type GlobalInitFunc<T extends HTMLElement> = (el: T) => Promisable<void>;
|
||||
const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {};
|
||||
type GlobalInitFunc<T extends Element> = (el: T) => Promisable<void>;
|
||||
const globalInitFuncs: Record<string, GlobalInitFunc<Element>> = {};
|
||||
|
||||
// It handles the global events for all `<div data-global-click="onSomeElemClick"></div>` elements.
|
||||
export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) {
|
||||
@@ -23,32 +24,32 @@ export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(
|
||||
// ATTENTION: For most cases, it's recommended to use registerGlobalInitFunc instead,
|
||||
// Because this selector-based approach is less efficient and less maintainable.
|
||||
// But if there are already a lot of elements on many pages, this selector-based approach is more convenient for exiting code.
|
||||
export function registerGlobalSelectorFunc(selector: string, handler: (el: HTMLElement) => void) {
|
||||
selectorHandlers.push({selector, handler});
|
||||
export function registerGlobalSelectorFunc<T extends Element>(selector: string, handler: (el: T) => void) {
|
||||
selectorHandlers.push({selector, handler: handler as (el: Element) => void});
|
||||
// Then initAddedElementObserver will call this handler for all existing elements after all handlers are added.
|
||||
// This approach makes the init stage only need to do one "querySelectorAll".
|
||||
if (!globalSelectorObserverInited) return;
|
||||
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
|
||||
for (const el of document.querySelectorAll<T>(selector)) {
|
||||
handler(el);
|
||||
}
|
||||
}
|
||||
|
||||
// It handles the global init functions for all `<div data-global-int="initSomeElem"></div>` elements.
|
||||
export function registerGlobalInitFunc<T extends HTMLElement>(name: string, handler: GlobalInitFunc<T>) {
|
||||
globalInitFuncs[name] = handler as GlobalInitFunc<HTMLElement>;
|
||||
globalInitFuncs[name] = handler as GlobalInitFunc<Element>;
|
||||
// The "global init" functions are managed internally and called by callGlobalInitFunc
|
||||
// They must be ready before initGlobalSelectorObserver is called.
|
||||
if (globalSelectorObserverInited) throw new Error('registerGlobalInitFunc() must be called before initGlobalSelectorObserver()');
|
||||
}
|
||||
|
||||
function callGlobalInitFunc(el: HTMLElement) {
|
||||
function callGlobalInitFunc(el: Element) {
|
||||
// TODO: GLOBAL-INIT-MULTIPLE-FUNCTIONS: maybe in the future we need to extend it to support multiple functions, for example: `data-global-init="func1 func2 func3"`
|
||||
const initFunc = el.getAttribute('data-global-init')!;
|
||||
const func = globalInitFuncs[initFunc];
|
||||
if (!func) throw new Error(`Global init function "${initFunc}" not found`);
|
||||
|
||||
// when an element node is removed and added again, it should not be re-initialized again.
|
||||
type GiteaGlobalInitElement = Partial<HTMLElement> & {_giteaGlobalInited: boolean};
|
||||
type GiteaGlobalInitElement = Partial<Element> & {_giteaGlobalInited: boolean};
|
||||
if ((el as GiteaGlobalInitElement)._giteaGlobalInited) return;
|
||||
(el as GiteaGlobalInitElement)._giteaGlobalInited = true;
|
||||
|
||||
@@ -80,20 +81,23 @@ export function initGlobalSelectorObserver(perfTracer: InitPerformanceTracer | n
|
||||
const mutation = mutationList[i];
|
||||
const len = mutation.addedNodes.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const addedNode = mutation.addedNodes[i] as HTMLElement;
|
||||
const addedNode = mutation.addedNodes[i] as ParentNode;
|
||||
if (!isDocumentFragmentOrElementNode(addedNode)) continue;
|
||||
|
||||
initAriaLabels(addedNode);
|
||||
for (const {selector, handler} of selectorHandlers) {
|
||||
if (addedNode.matches(selector)) {
|
||||
if ((addedNode instanceof Element) && addedNode.matches(selector)) {
|
||||
handler(addedNode);
|
||||
}
|
||||
for (const el of addedNode.querySelectorAll<HTMLElement>(selector)) {
|
||||
for (const el of addedNode.querySelectorAll?.<HTMLElement>(selector) ?? []) {
|
||||
handler(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
initAriaLabels(document);
|
||||
if (perfTracer) {
|
||||
for (const {selector, handler} of selectorHandlers) {
|
||||
perfTracer.recordCall(`initGlobalSelectorObserver ${selector}`, () => {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {debounce} from 'throttle-debounce';
|
||||
import {GET} from './fetch.ts';
|
||||
import {errorName} from './errors.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {urlQueryEscape} from '../utils/url.ts';
|
||||
|
||||
export type SearchResult = {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
function buildResultHTML(result: SearchResult): string {
|
||||
const img = result.image ? html`<div class="image"><img src="${result.image}" alt=""></div>` : '';
|
||||
const desc = result.description ? html`<div class="description">${result.description}</div>` : '';
|
||||
return html`${htmlRaw(img)}<div class="content"><div class="title">${result.title}</div>${htmlRaw(desc)}</div>`;
|
||||
}
|
||||
|
||||
function buildResultElement(result: SearchResult): HTMLElement {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'result';
|
||||
item.innerHTML = buildResultHTML(result);
|
||||
return item;
|
||||
}
|
||||
|
||||
// single delegated outside-click handler; each attachSearchBox registers a {container, hide} entry
|
||||
const outsideClickBoxes = new Set<{container: HTMLElement; hide: () => void}>();
|
||||
document.addEventListener('click', (event) => {
|
||||
for (const box of outsideClickBoxes) {
|
||||
if (!box.container.contains(event.target as Node)) box.hide();
|
||||
}
|
||||
});
|
||||
|
||||
/** Attach an API-driven autocomplete to `container`. `parse` maps the raw JSON response into the rendered result list. The selected result's title is written to the input on selection. */
|
||||
export function attachSearchBox<T = unknown>(container: HTMLElement, url: string, parse: (raw: T, query: string) => SearchResult[], {minCharacters = 2}: {minCharacters?: number} = {}): void {
|
||||
const input = container.querySelector<HTMLInputElement>('input.prompt') ?? container.querySelector<HTMLInputElement>('input');
|
||||
if (!input) return;
|
||||
|
||||
let resultsEl = container.querySelector<HTMLElement>(':scope > .results');
|
||||
if (!resultsEl) {
|
||||
resultsEl = document.createElement('div');
|
||||
resultsEl.className = 'results';
|
||||
container.append(resultsEl);
|
||||
}
|
||||
const itemResults = new Map<HTMLElement, SearchResult>();
|
||||
let fetchController: AbortController | null = null;
|
||||
|
||||
const hide = () => {
|
||||
fetchController?.abort();
|
||||
resultsEl.style.display = 'none';
|
||||
resultsEl.replaceChildren();
|
||||
itemResults.clear();
|
||||
};
|
||||
|
||||
const render = (results: SearchResult[]) => {
|
||||
if (!results.length) return hide();
|
||||
itemResults.clear();
|
||||
resultsEl.replaceChildren(...results.map((result) => {
|
||||
const item = buildResultElement(result);
|
||||
itemResults.set(item, result);
|
||||
return item;
|
||||
}));
|
||||
resultsEl.style.display = 'block';
|
||||
};
|
||||
|
||||
const select = (item: HTMLElement) => {
|
||||
input.value = itemResults.get(item)!.title;
|
||||
input.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
hide();
|
||||
};
|
||||
|
||||
const search = debounce(200, async (query: string) => {
|
||||
fetchController?.abort();
|
||||
if (query.length < minCharacters) return hide();
|
||||
const ctrl = (fetchController = new AbortController());
|
||||
try {
|
||||
const response = await GET(url.replaceAll('{query}', urlQueryEscape(query)), {signal: ctrl.signal});
|
||||
if (!response.ok) return hide();
|
||||
const results = parse(await response.json(), query);
|
||||
// only render if the fetch wasn't aborted (e.g. by hide()) and the input still matches
|
||||
if (!ctrl.signal.aborted && input.value === query) render(results);
|
||||
} catch (err) {
|
||||
if (errorName(err) !== 'AbortError') hide();
|
||||
}
|
||||
});
|
||||
// cancel + hide ensures a debounced fetch scheduled before any of these can't fire afterwards
|
||||
const dismiss = () => { search.cancel(); hide() };
|
||||
|
||||
input.addEventListener('input', () => search(input.value));
|
||||
input.addEventListener('focus', () => { if (itemResults.size) resultsEl.style.display = 'block'; });
|
||||
input.addEventListener('blur', () => { search.cancel(); setTimeout(hide, 150) }); // hide deferred so a result mousedown can land first
|
||||
input.addEventListener('keydown', (event) => {
|
||||
const resultEls = Array.from(resultsEl.querySelectorAll<HTMLElement>('.result'));
|
||||
if (!resultEls.length) return;
|
||||
const index = resultEls.findIndex((item) => item.classList.contains('active'));
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
resultEls[index]?.classList.remove('active');
|
||||
const next = event.key === 'ArrowDown' ? (index + 1) % resultEls.length : index <= 0 ? resultEls.length - 1 : index - 1;
|
||||
resultEls[next].classList.add('active');
|
||||
} else if (event.key === 'Enter' && index >= 0) {
|
||||
event.preventDefault();
|
||||
select(resultEls[index]);
|
||||
} else if (event.key === 'Escape') {
|
||||
dismiss();
|
||||
}
|
||||
});
|
||||
// mousedown fires before input blur so the selection registers before blur-hide kicks in
|
||||
resultsEl.addEventListener('mousedown', (event) => {
|
||||
const target = (event.target as HTMLElement).closest<HTMLElement>('.result');
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
select(target);
|
||||
});
|
||||
outsideClickBoxes.add({container, hide: dismiss});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js';
|
||||
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||
import type {Content, Instance, Placement, Props} from 'tippy.js';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {stripTags} from '../utils.ts';
|
||||
|
||||
type TippyOpts = {
|
||||
role?: string,
|
||||
@@ -85,9 +86,10 @@ function attachTooltip(target: Element, content: Content | null = null): Instanc
|
||||
role: 'tooltip',
|
||||
theme: 'tooltip',
|
||||
hideOnClick,
|
||||
allowHTML: target.getAttribute('data-tooltip-render') === 'html',
|
||||
placement: target.getAttribute('data-tooltip-placement') as Placement || 'top-start',
|
||||
followCursor: target.getAttribute('data-tooltip-follow-cursor') as Props['followCursor'] || false,
|
||||
...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}),
|
||||
...((target.getAttribute('data-tooltip-interactive') === 'true') && {interactive: true, aria: {content: 'describedby', expanded: false}}),
|
||||
};
|
||||
|
||||
if (!target._tippy) {
|
||||
@@ -127,7 +129,11 @@ function attachLazyTooltip(el: HTMLElement): void {
|
||||
if (!el.hasAttribute('aria-label')) {
|
||||
const content = el.getAttribute('data-tooltip-content');
|
||||
if (content) {
|
||||
el.setAttribute('aria-label', content);
|
||||
const isHtml = el.getAttribute('data-tooltip-render') === 'html';
|
||||
let ariaLabelValue = content;
|
||||
if (isHtml) ariaLabelValue = stripTags(content).replace(/\s+/g, ' ').trim();
|
||||
el.setAttribute('aria-label', ariaLabelValue);
|
||||
el.removeAttribute('aria-hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {html, htmlEscape, htmlRaw} from '../utils/html.ts';
|
||||
import {svgRaw} from '../svg.ts';
|
||||
import {animateOnce, queryElems, showElem} from '../utils/dom.ts';
|
||||
import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
|
||||
import type {Intent} from '../types.ts';
|
||||
@@ -44,9 +44,8 @@ type ToastifyElement = HTMLElement & {_giteaToastifyInstance?: Toast};
|
||||
|
||||
/** See https://github.com/apvarun/toastify-js#api for options */
|
||||
function showToast(message: string, level: Intent, {gravity, position, duration, useHtmlBody, preventDuplicates = true, ...other}: ToastOpts = {}): Toast | null {
|
||||
const body = useHtmlBody ? message : htmlEscape(message);
|
||||
const parent = document.querySelector('.ui.dimmer.active') ?? document.body;
|
||||
const duplicateKey = preventDuplicates ? (preventDuplicates === true ? `${level}-${body}` : preventDuplicates) : '';
|
||||
const duplicateKey = preventDuplicates ? (typeof preventDuplicates === 'string' ? preventDuplicates : `${level}-${message}`) : '';
|
||||
|
||||
// prevent showing duplicate toasts with the same level and message, and give visual feedback for end users
|
||||
if (preventDuplicates) {
|
||||
@@ -61,12 +60,13 @@ function showToast(message: string, level: Intent, {gravity, position, duration,
|
||||
}
|
||||
|
||||
const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
|
||||
const bodyHtml = useHtmlBody ? message : htmlEscape(message);
|
||||
const toast = Toastify({
|
||||
selector: parent,
|
||||
text: `
|
||||
<div class='toast-icon'>${svg(icon)}</div>
|
||||
<div class='toast-body'><span class="toast-duplicate-number tw-hidden">1</span>${body}</div>
|
||||
<button class='btn toast-close'>${svg('octicon-x')}</button>
|
||||
text: html`
|
||||
<div class='toast-icon'>${svgRaw(icon)}</div>
|
||||
<div class='toast-body'><span class="toast-duplicate-number tw-hidden">1</span>${htmlRaw(bodyHtml)}</div>
|
||||
<button class='btn toast-close'>${svgRaw('octicon-x')}</button>
|
||||
`,
|
||||
escapeMarkup: false,
|
||||
gravity: gravity ?? 'top',
|
||||
|
||||
@@ -50,7 +50,7 @@ export class UserEventsSharedWorker {
|
||||
// * in this case, the logout fetch call already completes and has sent the "logout" message to the worker
|
||||
// * there can be a data-race between the fetch call's redirection and the "logout" message from the worker
|
||||
// * the fetch call's logout redirection should always win over the worker message, because it might have a custom location
|
||||
setTimeout(() => { window.location.href = `${appSubUrl}/` }, 1000);
|
||||
setTimeout(() => { window.location.assign(`${appSubUrl}/`) }, 1000);
|
||||
} else if (event.data.type === 'close') {
|
||||
this.sharedWorker.port.postMessage({type: 'close'});
|
||||
this.sharedWorker.port.close();
|
||||
|
||||
Reference in New Issue
Block a user