feat: vendor gitea 1.16.2

This commit is contained in:
2026-06-02 08:36:01 +00:00
parent 247cd60f69
commit 54798b4615
2145 changed files with 162965 additions and 126447 deletions
@@ -0,0 +1,216 @@
import {isMac, keySymbols} from '../../utils.ts';
import {trimTrailingWhitespaceFromView} from './utils.ts';
import type {EditorView} from '@codemirror/view';
import type {CodemirrorModules} from './main.ts';
export type PaletteCommand = {
label: string;
keys: string;
run: (view: EditorView) => void;
};
function formatKeys(keys: string): string[][] {
return keys.split(' ').map((chord) => chord.split('+').map((k) => keySymbols[k] || k));
}
export function commandPalette(cm: CodemirrorModules) {
const commands: PaletteCommand[] = [
{label: 'Undo', keys: 'Mod+Z', run: cm.commands.undo},
{label: 'Redo', keys: 'Mod+Shift+Z', run: cm.commands.redo},
{label: 'Find', keys: 'Mod+F', run: cm.search.openSearchPanel},
{label: 'Go to line', keys: 'Mod+Alt+G', run: cm.search.gotoLine},
{label: 'Select All', keys: 'Mod+A', run: cm.commands.selectAll},
{label: 'Delete Line', keys: 'Mod+Shift+K', run: cm.commands.deleteLine},
{label: 'Move Line Up', keys: 'Alt+Up', run: cm.commands.moveLineUp},
{label: 'Move Line Down', keys: 'Alt+Down', run: cm.commands.moveLineDown},
{label: 'Copy Line Up', keys: 'Shift+Alt+Up', run: cm.commands.copyLineUp},
{label: 'Copy Line Down', keys: 'Shift+Alt+Down', run: cm.commands.copyLineDown},
{label: 'Toggle Comment', keys: 'Mod+/', run: cm.commands.toggleComment},
{label: 'Insert Blank Line', keys: 'Mod+Enter', run: cm.commands.insertBlankLine},
{label: 'Add Cursor Above', keys: isMac ? 'Mod+Alt+Up' : 'Ctrl+Alt+Up', run: cm.commands.addCursorAbove},
{label: 'Add Cursor Below', keys: isMac ? 'Mod+Alt+Down' : 'Ctrl+Alt+Down', run: cm.commands.addCursorBelow},
{label: 'Add Next Occurrence', keys: 'Mod+D', run: cm.search.selectNextOccurrence},
{label: 'Go to Matching Bracket', keys: 'Mod+Shift+\\', run: cm.commands.cursorMatchingBracket},
{label: 'Indent More', keys: 'Mod+]', run: cm.commands.indentMore},
{label: 'Indent Less', keys: 'Mod+[', run: cm.commands.indentLess},
{label: 'Fold Code', keys: isMac ? 'Mod+Alt+[' : 'Ctrl+Shift+[', run: cm.language.foldCode},
{label: 'Unfold Code', keys: isMac ? 'Mod+Alt+]' : 'Ctrl+Shift+]', run: cm.language.unfoldCode},
{label: 'Fold All', keys: 'Ctrl+Alt+[', run: cm.language.foldAll},
{label: 'Unfold All', keys: 'Ctrl+Alt+]', run: cm.language.unfoldAll},
{label: 'Trigger Autocomplete', keys: 'Ctrl+Space', run: cm.autocomplete.startCompletion},
{label: 'Trim Trailing Whitespace', keys: 'Mod+K Mod+X', run: trimTrailingWhitespaceFromView},
];
let overlay: HTMLElement | null = null;
let filtered: PaletteCommand[] = [];
let selectedIndex = 0;
let cleanupClickOutside: (() => void) | null = null;
function hide(view: EditorView) {
if (!overlay) return;
cleanupClickOutside?.();
cleanupClickOutside = null;
overlay.remove();
overlay = null;
view.focus();
}
function renderList(list: HTMLElement, query: string) {
list.textContent = '';
if (!filtered.length) {
const empty = document.createElement('div');
empty.className = 'cm-command-palette-empty';
empty.textContent = 'No matches';
list.append(empty);
return;
}
for (const [index, cmd] of filtered.entries()) {
const item = document.createElement('div');
item.className = 'cm-command-palette-item';
item.setAttribute('role', 'option');
item.setAttribute('data-index', String(index));
if (index === selectedIndex) item.setAttribute('aria-selected', 'true');
const label = document.createElement('span');
label.className = 'cm-command-palette-label';
const matchIndex = query ? cmd.label.toLowerCase().indexOf(query) : -1;
if (matchIndex !== -1) {
label.append(cmd.label.slice(0, matchIndex));
const mark = document.createElement('mark');
mark.textContent = cmd.label.slice(matchIndex, matchIndex + query.length);
label.append(mark, cmd.label.slice(matchIndex + query.length));
} else {
label.textContent = cmd.label;
}
item.append(label);
if (cmd.keys) {
const keysEl = document.createElement('span');
keysEl.className = 'cm-command-palette-keys';
for (const [chordIndex, chord] of formatKeys(cmd.keys).entries()) {
if (chordIndex > 0) keysEl.append('→');
for (const k of chord) {
const kbd = document.createElement('kbd');
kbd.textContent = k;
keysEl.append(kbd);
}
}
item.append(keysEl);
}
list.append(item);
}
}
function show(view: EditorView, items?: PaletteCommand[], placeholder?: string) {
const container = view.dom.closest('.code-editor-container')!;
overlay = document.createElement('div');
overlay.className = 'cm-command-palette';
const input = document.createElement('input');
input.className = 'cm-command-palette-input';
input.placeholder = placeholder || 'Type a command...';
const list = document.createElement('div');
list.className = 'cm-command-palette-list';
list.setAttribute('role', 'listbox');
const source = items || commands;
filtered = source;
selectedIndex = 0;
const updateSelected = () => {
list.querySelector('[aria-selected]')?.removeAttribute('aria-selected');
const el = list.children[selectedIndex] as HTMLElement | undefined;
if (el) {
el.setAttribute('aria-selected', 'true');
if (el.offsetTop < list.scrollTop) {
list.scrollTop = el.offsetTop;
} else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight) {
list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight;
}
}
};
const execute = (cmd: PaletteCommand) => {
hide(view);
cmd.run(view);
};
list.addEventListener('pointerover', (e) => {
const item = (e.target as Element).closest<HTMLElement>('.cm-command-palette-item');
if (!item) return;
selectedIndex = Number(item.getAttribute('data-index'));
updateSelected();
});
list.addEventListener('mousedown', (e) => {
const item = (e.target as Element).closest<HTMLElement>('.cm-command-palette-item');
if (!item) return;
e.preventDefault();
const cmd = filtered[Number(item.getAttribute('data-index'))];
if (cmd) execute(cmd);
});
input.addEventListener('input', () => {
const q = input.value.toLowerCase();
filtered = q ? source.filter((cmd) => cmd.label.toLowerCase().includes(q)) : source;
selectedIndex = 0;
renderList(list, q);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % filtered.length;
updateSelected();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = (selectedIndex - 1 + filtered.length) % filtered.length;
updateSelected();
} else if (e.key === 'Enter') {
e.preventDefault();
if (filtered[selectedIndex]) execute(filtered[selectedIndex]);
} else if (e.key === 'Escape') {
e.preventDefault();
hide(view);
}
});
overlay.append(input, list);
container.append(overlay);
renderList(list, '');
input.focus();
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Element;
if (overlay && !overlay.contains(target) && !target.closest('.js-code-command-palette')) {
hide(view);
}
};
document.addEventListener('mousedown', handleClickOutside);
cleanupClickOutside = () => document.removeEventListener('mousedown', handleClickOutside);
}
function showWithItems(view: EditorView, items: PaletteCommand[], placeholder: string) {
if (overlay) hide(view);
show(view, items, placeholder);
}
function togglePalette(view: EditorView) {
if (overlay) {
hide(view);
} else {
show(view);
}
return true;
}
return {
extensions: cm.view.keymap.of([
{key: 'Mod-Shift-p', run: togglePalette, preventDefault: true},
{key: 'F1', run: togglePalette, preventDefault: true},
]),
togglePalette,
showWithItems,
};
}
@@ -0,0 +1,249 @@
import {clippie} from 'clippie';
import {createTippy} from '../tippy.ts';
import {keySymbols} from '../../utils.ts';
import {goToDefinitionAt} from './utils.ts';
import type {Instance} from 'tippy.js';
import type {EditorView} from '@codemirror/view';
import type {CodemirrorModules} from './main.ts';
type MenuItem = {
label: string;
keys?: string;
disabled?: boolean;
run: (view: EditorView) => void | Promise<void>;
} | 'separator';
/** Get the word at cursor, or selected text. Checks adjacent positions when cursor is on a non-word char. */
export function getWordAtPosition(view: EditorView, from: number, to: number): string {
if (from !== to) return view.state.doc.sliceString(from, to);
for (const pos of [from, from - 1, from + 1]) {
const range = view.state.wordAt(pos);
if (range) return view.state.doc.sliceString(range.from, range.to);
}
return '';
}
/** Select all occurrences of the word at cursor for multi-cursor editing. */
export function selectAllOccurrences(cm: CodemirrorModules, view: EditorView) {
const {from, to} = view.state.selection.main;
const word = getWordAtPosition(view, from, to);
if (!word) return;
const ranges = [];
let main = 0;
const cursor = new cm.search.SearchCursor(view.state.doc, word);
while (!cursor.done) {
cursor.next();
if (cursor.done) break;
if (cursor.value.from <= from && cursor.value.to >= from) main = ranges.length;
ranges.push(cm.state.EditorSelection.range(cursor.value.from, cursor.value.to));
}
if (ranges.length) {
view.dispatch({selection: cm.state.EditorSelection.create(ranges, main)});
}
}
/** Collect symbol definitions from the Lezer syntax tree. */
export function collectSymbols(cm: CodemirrorModules, view: EditorView): {label: string; kind: string; from: number}[] {
const tree = cm.language.syntaxTree(view.state);
const symbols: {label: string; kind: string; from: number}[] = [];
const seen = new Set<number>(); // track by position to avoid O(n²) dedup
const addSymbol = (label: string, kind: string, from: number) => {
if (!seen.has(from)) {
seen.add(from);
symbols.push({label, kind, from});
}
};
tree.iterate({
enter(node): false | void {
if (node.name === 'VariableDefinition' || node.name === 'DefName') {
addSymbol(view.state.doc.sliceString(node.from, node.to), 'variable', node.from);
} else if (node.name === 'FunctionDeclaration' || node.name === 'FunctionDecl' || node.name === 'ClassDeclaration') {
const nameNode = node.node.getChild('VariableDefinition') || node.node.getChild('DefName');
if (nameNode) {
const kind = node.name === 'ClassDeclaration' ? 'class' : 'function';
addSymbol(view.state.doc.sliceString(nameNode.from, nameNode.to), kind, nameNode.from);
}
return false;
} else if (node.name === 'MethodDeclaration' || node.name === 'MethodDecl' || node.name === 'PropertyDefinition') {
const nameNode = node.node.getChild('PropertyDefinition') || node.node.getChild('PropertyName') || node.node.getChild('DefName');
if (nameNode) {
addSymbol(view.state.doc.sliceString(nameNode.from, nameNode.to), node.name === 'PropertyDefinition' ? 'property' : 'method', nameNode.from);
}
} else if (node.name === 'TypeDecl' || node.name === 'TypeSpec') {
const nameNode = node.node.getChild('DefName');
if (nameNode) {
addSymbol(view.state.doc.sliceString(nameNode.from, nameNode.to), 'type', nameNode.from);
}
}
},
});
return symbols;
}
function buildMenuItems(cm: CodemirrorModules, view: EditorView, togglePalette: (view: EditorView) => boolean, goToSymbol: (view: EditorView) => void): MenuItem[] {
const {from, to} = view.state.selection.main;
const hasSelection = from !== to;
// Check if cursor is on a symbol that has a definition
const tree = cm.language.syntaxTree(view.state);
const nodeAtCursor = tree.resolveInner(from, 1);
const hasDefinition = nodeAtCursor?.name === 'VariableName';
const hasWord = Boolean(getWordAtPosition(view, from, to));
return [
{label: 'Go to Definition', keys: 'F12', disabled: !hasDefinition, run: (v) => { goToDefinitionAt(cm, v, v.state.selection.main.from) }},
{label: 'Go to Symbol…', keys: 'Mod+Shift+O', run: goToSymbol},
{label: 'Change All Occurrences', keys: 'Mod+F2', disabled: !hasWord, run: (v) => selectAllOccurrences(cm, v)},
'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))) {
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));
}},
{label: 'Paste', keys: 'Mod+V', run: async (view) => {
try {
const text = await navigator.clipboard.readText();
view.dispatch(view.state.replaceSelection(text));
} catch { /* clipboard permission denied */ }
}},
'separator',
{label: 'Command Palette', keys: 'F1', run: (v) => { togglePalette(v) }},
];
}
type MenuResult = {el: HTMLElement; actions: ((() => void) | null)[]};
function createMenuElement(items: MenuItem[], view: EditorView, onAction: () => void): MenuResult {
const menu = document.createElement('div');
menu.className = 'cm-context-menu';
const actions: ((() => void) | null)[] = [];
for (const item of items) {
if (item === 'separator') {
const sep = document.createElement('div');
sep.className = 'cm-context-menu-separator';
menu.append(sep);
continue;
}
const row = document.createElement('div');
row.className = `item${item.disabled ? ' disabled' : ''}`;
if (item.disabled) row.setAttribute('aria-disabled', 'true');
const label = document.createElement('span');
label.className = 'cm-context-menu-label';
label.textContent = item.label;
row.append(label);
if (item.keys) {
const keysEl = document.createElement('span');
keysEl.className = 'cm-context-menu-keys';
for (const key of item.keys.split('+')) {
const kbd = document.createElement('kbd');
kbd.textContent = keySymbols[key] || key;
keysEl.append(kbd);
}
row.append(keysEl);
}
const execute = item.disabled ? null : () => { onAction(); item.run(view) };
if (execute) {
row.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); execute() });
}
actions.push(execute);
menu.append(row);
}
return {el: menu, actions};
}
export function contextMenu(cm: CodemirrorModules, togglePalette: (view: EditorView) => boolean, goToSymbol: (view: EditorView) => void) {
let instance: Instance | null = null;
function hideMenu() {
if (instance) {
instance.destroy();
instance = null;
}
}
return cm.view.EditorView.domEventHandlers({
contextmenu(event: MouseEvent, view: EditorView) {
event.preventDefault();
hideMenu();
// Place cursor at right-click position if not inside a selection
const pos = view.posAtCoords({x: event.clientX, y: event.clientY});
if (pos !== null) {
const {from, to} = view.state.selection.main;
if (pos < from || pos > to) {
view.dispatch({selection: {anchor: pos}});
}
}
const controller = new AbortController();
const dismiss = () => {
controller.abort();
hideMenu();
};
const menuItems = buildMenuItems(cm, view, togglePalette, goToSymbol);
const {el: menuEl, actions} = createMenuElement(menuItems, view, dismiss);
// Create a virtual anchor at mouse position for tippy
const anchor = document.createElement('div');
anchor.style.position = 'fixed';
anchor.style.left = `${event.clientX}px`;
anchor.style.top = `${event.clientY}px`;
document.body.append(anchor);
instance = createTippy(anchor, {
content: menuEl,
theme: 'menu',
trigger: 'manual',
placement: 'bottom-start',
interactive: true,
arrow: false,
offset: [0, 0],
showOnCreate: true,
onHidden: () => {
anchor.remove();
instance = null;
},
});
const rows = menuEl.querySelectorAll<HTMLElement>('.item');
let focusIndex = -1;
const setFocus = (idx: number) => {
focusIndex = idx;
for (const [rowIdx, el] of rows.entries()) {
el.classList.toggle('active', rowIdx === focusIndex);
}
};
const nextEnabled = (from: number, dir: number) => {
for (let step = 1; step <= actions.length; step++) {
const idx = (from + dir * step + actions.length) % actions.length;
if (actions[idx]) return idx;
}
return from;
};
document.addEventListener('mousedown', (e: MouseEvent) => {
if (!menuEl.contains(e.target as Element)) dismiss();
}, {signal: controller.signal});
document.addEventListener('keydown', (e: KeyboardEvent) => {
e.stopPropagation();
e.preventDefault();
if (e.key === 'Escape') {
dismiss(); view.focus();
} else if (e.key === 'ArrowDown') {
setFocus(nextEnabled(focusIndex, 1));
} else if (e.key === 'ArrowUp') {
setFocus(nextEnabled(focusIndex, -1));
} else if (e.key === 'Enter' && focusIndex >= 0 && actions[focusIndex]) {
actions[focusIndex]!();
}
}, {signal: controller.signal, capture: true});
view.scrollDOM.addEventListener('scroll', dismiss, {signal: controller.signal, once: true});
document.addEventListener('scroll', dismiss, {signal: controller.signal, once: true});
window.addEventListener('blur', dismiss, {signal: controller.signal});
window.addEventListener('resize', dismiss, {signal: controller.signal});
},
});
}
@@ -0,0 +1,39 @@
import type {CodemirrorModules} from './main.ts';
import type {Extension} from '@codemirror/state';
/** Creates a linter for JSON files using `jsonParseLinter` from `@codemirror/lang-json`. */
export async function createJsonLinter(cm: CodemirrorModules): Promise<Extension> {
const {jsonParseLinter} = await import('@codemirror/lang-json');
const baseLinter = jsonParseLinter();
return cm.lint.linter((view) => {
return baseLinter(view).map((d) => {
if (d.from === d.to) {
const line = view.state.doc.lineAt(d.from);
// expand to end of line content, or at least 1 char
d.to = Math.min(Math.max(d.from + 1, line.to), view.state.doc.length);
}
return d;
});
});
}
/** Creates a generic linter that detects Lezer parse-tree error nodes. */
export function createSyntaxErrorLinter(cm: CodemirrorModules): Extension {
return cm.lint.linter((view) => {
const diagnostics: {from: number, to: number, severity: 'error', message: string}[] = [];
const tree = cm.language.syntaxTree(view.state);
tree.iterate({
enter(node) {
if (node.type.isError) {
diagnostics.push({
from: node.from,
to: node.to === node.from ? Math.min(node.from + 1, view.state.doc.length) : node.to,
severity: 'error',
message: 'Syntax error',
});
}
},
});
return diagnostics;
});
}
@@ -0,0 +1,330 @@
import {extname} from '../../utils.ts';
import {createElementFromHTML, toggleElem} from '../../utils/dom.ts';
import {html, htmlRaw} from '../../utils/html.ts';
import {svg} from '../../svg.ts';
import {commandPalette} from './command-palette.ts';
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 {Compartment, Extension} from '@codemirror/state';
import type {EditorView, ViewUpdate} from '@codemirror/view';
// CodeEditorConfig is also used by backend, defined in "editor_util.go"
const codeEditorConfigDefault = {
filename: '', // the current filename (base name, not full path), used for language detection
autofocus: false, // whether to autofocus the editor on load
previewableExtensions: [] as string[], // file extensions that support preview rendering
lineWrapExtensions: [] as string[], // file extensions that enable line wrapping by default
lineWrap: false, // whether line wrapping is enabled for the current file
indentStyle: '', // "space" or "tab", from .editorconfig, or empty for not specified (detect from source code)
indentSize: 0, // number of spaces per indent level, from .editorconfig, or 0 for not specified (detect from source code)
tabWidth: 4, // display width of a tab character, from .editorconfig, defaults to 4
trimTrailingWhitespace: false, // whether to trim trailing whitespace on save, from .editorconfig
};
type CodeEditorConfig = typeof codeEditorConfigDefault;
export type CodemirrorEditor = {
view: EditorView;
trimTrailingWhitespace: boolean;
togglePalette: (view: EditorView) => boolean;
updateFilename: (filename: string) => Promise<void>;
languages: LanguageDescription[];
compartments: {
wordWrap: Compartment;
language: Compartment;
tabSize: Compartment;
indentUnit: Compartment;
lint: Compartment;
};
};
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([
import('@codemirror/autocomplete'),
import('@codemirror/commands'),
import('@codemirror/language'),
import('@codemirror/language-data'),
import('@codemirror/lint'),
import('@codemirror/search'),
import('@codemirror/state'),
import('@codemirror/view'),
import('@lezer/highlight'),
import('@replit/codemirror-indentation-markers'),
import('@replit/codemirror-vscode-keymap'),
]);
return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap};
}
function togglePreviewDisplay(previewable: boolean): void {
// FIXME: here and below, the selector is too broad, it should only query in the editor related scope
const previewTab = document.querySelector<HTMLElement>('a[data-tab="preview"]');
// the "preview tab" exists for "file code editor", but doesn't exist for "git hook editor"
if (!previewTab) return;
toggleElem(previewTab, previewable);
if (previewable) return;
// If not previewable but the "preview" tab was active (user changes the filename to a non-previewable one),
// then the "preview" tab becomes inactive (hidden), so the "write" tab should become active
if (previewTab.classList.contains('active')) {
const writeTab = document.querySelector<HTMLElement>('a[data-tab="write"]');
writeTab!.click();
}
}
export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameInput?: HTMLInputElement): Promise<CodemirrorEditor> {
const config: CodeEditorConfig = {
...codeEditorConfigDefault,
...JSON.parse(textarea.getAttribute('data-code-editor-config')!),
};
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 matchedLang = cm.language.LanguageDescription.matchFilename(languageDescriptions, config.filename);
const container = document.createElement('div');
container.className = 'code-editor-container';
container.setAttribute('data-language', matchedLang?.name.toLowerCase() || '');
// Replace the loading placeholder with the editor container in one operation
// to avoid a flash where neither element is in the DOM.
const loading = textarea.parentNode!.querySelector<HTMLElement>('.editor-loading');
if (loading) {
loading.replaceWith(container);
} else {
textarea.parentNode!.append(container);
}
const loadedLang = matchedLang ? await matchedLang.load() : null;
const wordWrap = new cm.state.Compartment();
const language = new cm.state.Compartment();
const tabSize = new cm.state.Compartment();
const indentUnitComp = new cm.state.Compartment();
const lintComp = new cm.state.Compartment();
const palette = commandPalette(cm);
const goToSymbol = (view: EditorView) => {
const symbols = collectSymbols(cm, view);
const items: PaletteCommand[] = symbols.map((sym) => ({
label: `${sym.label} (${sym.kind})`,
keys: '',
run: (v: EditorView) => v.dispatch({selection: {anchor: sym.from}, scrollIntoView: true}),
}));
palette.showWithItems(view, items, 'Go to symbol…');
return true;
};
const view = new cm.view.EditorView({
doc: textarea.defaultValue, // use defaultValue to prevent browser from restoring form values on refresh
parent: container,
extensions: [
cm.view.lineNumbers(),
cm.language.codeFolding({
placeholderDOM(_view: EditorView, onclick: (event: Event) => void) {
const el = createElementFromHTML(html`<span class="cm-foldPlaceholder">${htmlRaw(svg('octicon-kebab-horizontal', 13))}</span>`);
el.addEventListener('click', onclick);
return el as unknown as HTMLElement;
},
}),
cm.language.foldGutter({
markerDOM(open: boolean) {
return createElementFromHTML(svg(open ? 'octicon-chevron-down' : 'octicon-chevron-right', 13));
},
}),
cm.view.highlightActiveLineGutter(),
cm.view.highlightSpecialChars(),
cm.view.highlightActiveLine(),
cm.view.drawSelection(),
cm.view.dropCursor(),
cm.view.rectangularSelection(),
cm.view.crosshairCursor(),
cm.view.placeholder(textarea.placeholder),
config.trimTrailingWhitespace ? cm.view.highlightTrailingWhitespace() : [],
cm.search.search({top: true}),
cm.search.highlightSelectionMatches(),
cm.view.keymap.of([
...cm.vscodeKeymap.vscodeKeymap,
...cm.search.searchKeymap,
...cm.lint.lintKeymap,
cm.commands.indentWithTab,
{key: 'Mod-k Mod-x', run: (view) => { trimTrailingWhitespaceFromView(view); return true }, preventDefault: true},
{key: 'Mod-Enter', run: cm.commands.insertBlankLine, preventDefault: true},
{key: 'Mod-k Mod-k', run: cm.commands.deleteToLineEnd, preventDefault: true},
{key: 'Mod-k Mod-Backspace', run: cm.commands.deleteToLineStart, preventDefault: true},
]),
cm.state.EditorState.allowMultipleSelections.of(true),
cm.language.indentOnInput(),
cm.language.syntaxHighlighting(cm.highlight.classHighlighter),
cm.language.bracketMatching(),
indentUnitComp.of(
cm.language.indentUnit.of(
config.indentStyle === 'tab' ? '\t' : ' '.repeat(config.indentSize || 4),
),
),
cm.autocomplete.closeBrackets(),
cm.autocomplete.autocompletion(),
cm.state.EditorState.languageData.of(() => [{autocomplete: cm.autocomplete.completeAnyWord}]),
cm.indentMarkers.indentationMarkers({
colors: {
light: 'transparent',
dark: 'transparent',
activeLight: 'var(--color-secondary-dark-3)',
activeDark: 'var(--color-secondary-dark-3)',
},
}),
cm.commands.history(),
palette.extensions,
cm.view.keymap.of([
{key: 'Mod-Shift-o', run: goToSymbol, preventDefault: true},
{key: 'Mod-F2', run: (v) => { selectAllOccurrences(cm, v); return true }, preventDefault: true},
{key: 'F12', run: (v) => goToDefinitionAt(cm, v, v.state.selection.main.from), preventDefault: true},
]),
contextMenu(cm, palette.togglePalette, goToSymbol),
clickableUrls(cm),
tabSize.of(cm.state.EditorState.tabSize.of(config.tabWidth || 4)),
wordWrap.of(config.lineWrap ? cm.view.EditorView.lineWrapping : []),
language.of(loadedLang ?? []),
lintComp.of(await getLinterExtension(cm, config.filename, loadedLang)),
cm.view.EditorView.updateListener.of((update: ViewUpdate) => {
if (update.docChanged) {
textarea.value = update.state.doc.toString();
textarea.dispatchEvent(new Event('change')); // needed for jquery-are-you-sure
}
}),
],
});
const editor: CodemirrorEditor = {
view,
trimTrailingWhitespace: config.trimTrailingWhitespace,
togglePalette: palette.togglePalette,
updateFilename: async (filename: string) => {
togglePreviewDisplay(previewableExts.has(extname(filename)));
await updateEditorLanguage(cm, editor, filename, lineWrapExts);
},
languages: languageDescriptions,
compartments: {wordWrap, language, tabSize, indentUnit: indentUnitComp, lint: lintComp},
};
const elEditorOptions = textarea.closest('form')!.querySelector('.code-editor-options');
if (elEditorOptions) {
const indentStyleSelect = elEditorOptions.querySelector<HTMLSelectElement>('.js-indent-style-select')!;
const indentSizeSelect = elEditorOptions.querySelector<HTMLSelectElement>('.js-indent-size-select')!;
const applyIndentSettings = (style: string, size: number) => {
view.dispatch({
effects: [
indentUnitComp.reconfigure(cm.language.indentUnit.of(style === 'tab' ? '\t' : ' '.repeat(size))),
tabSize.reconfigure(cm.state.EditorState.tabSize.of(size)),
],
});
};
indentStyleSelect.addEventListener('change', () => {
applyIndentSettings(indentStyleSelect.value, Number(indentSizeSelect.value) || 4);
});
indentSizeSelect.addEventListener('change', () => {
applyIndentSettings(indentStyleSelect.value || 'space', Number(indentSizeSelect.value) || 4);
});
elEditorOptions.querySelector('.js-code-find')!.addEventListener('click', () => {
if (cm.search.searchPanelOpen(view.state)) {
cm.search.closeSearchPanel(view);
} else {
cm.search.openSearchPanel(view);
}
});
elEditorOptions.querySelector('.js-code-command-palette')!.addEventListener('click', () => {
palette.togglePalette(view);
});
elEditorOptions.querySelector<HTMLSelectElement>('.js-line-wrap-select')!.addEventListener('change', (e) => {
const target = e.target as HTMLSelectElement;
view.dispatch({
effects: wordWrap.reconfigure(target.value === 'on' ? cm.view.EditorView.lineWrapping : []),
});
});
}
togglePreviewDisplay(previewableExts.has(extname(config.filename)));
if (config.autofocus) {
editor.view.focus();
} else if (filenameInput) {
filenameInput.focus();
}
return editor;
}
// files that are JSONC despite having a .json extension
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$/;
async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: {language: unknown} | null): Promise<Extension> {
const ext = extname(filename).toLowerCase();
if (ext === '.json' || ext === '.map') {
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 [];
return [cm.lint.lintGutter(), createSyntaxErrorLinter(cm)];
}
async function updateEditorLanguage(cm: CodemirrorModules, editor: CodemirrorEditor, filename: string, lineWrapExts: string[]): Promise<void> {
const {compartments, view, languages: editorLanguages} = editor;
const newLanguage = cm.language.LanguageDescription.matchFilename(editorLanguages, filename);
const newLoadedLang = newLanguage ? await newLanguage.load() : null;
view.dom.closest('.code-editor-container')!.setAttribute('data-language', newLanguage?.name.toLowerCase() || '');
view.dispatch(
{
effects: [
compartments.wordWrap.reconfigure(
lineWrapExts.includes(extname(filename).toLowerCase()) ? cm.view.EditorView.lineWrapping : [],
),
compartments.language.reconfigure(newLoadedLang ?? []),
compartments.lint.reconfigure(await getLinterExtension(cm, filename, newLoadedLang)),
],
},
// clear stale diagnostics from the previous language on filename change
cm.lint.setDiagnostics(view.state, []),
);
}
@@ -0,0 +1,47 @@
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://img.shields.io/npm/v/pkg.svg?style=flat)](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)');
});
test('findUrlAtPosition', () => {
const doc = 'visit https://example.com for info';
expect(findUrlAtPosition(doc, 0)).toBeNull();
expect(findUrlAtPosition(doc, 6)).toEqual('https://example.com');
expect(findUrlAtPosition(doc, 15)).toEqual('https://example.com');
expect(findUrlAtPosition(doc, 24)).toEqual('https://example.com');
expect(findUrlAtPosition(doc, 25)).toBeNull();
});
@@ -0,0 +1,131 @@
import type {EditorView, ViewUpdate} from '@codemirror/view';
import type {CodemirrorModules} from './main.ts';
/** Remove trailing whitespace from all lines in the editor. */
export function trimTrailingWhitespaceFromView(view: EditorView): void {
const changes = [];
const doc = view.state.doc;
for (let i = 1; i <= doc.lines; i++) {
const line = doc.line(i);
const trimmed = line.text.replace(/\s+$/, '');
if (trimmed.length < line.text.length) {
changes.push({from: line.from + trimmed.length, to: line.to});
}
}
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)) {
const url = trimUrlPunctuation(match[0]);
if (match.index !== undefined && pos >= match.index && pos < match.index + url.length) {
return url;
}
}
return null;
}
// Lezer syntax tree node names for identifier usages and definitions across grammars
const usageNodes = new Set(['VariableName', 'Identifier', 'TypeIdentifier', 'TypeName', 'FieldIdentifier']);
const definitionNodes = new Set(['VariableDefinition', 'DefName', 'Definition', 'TypeDefinition', 'TypeDef']);
export function goToDefinitionAt(cm: CodemirrorModules, view: EditorView, pos: number): boolean {
const tree = cm.language.syntaxTree(view.state);
const node = tree.resolveInner(pos, 1);
if (!node || !usageNodes.has(node.name)) return false;
const name = view.state.doc.sliceString(node.from, node.to);
let target: number | null = null;
tree.iterate({
enter(n): false | void {
if (target !== null) return false;
if (definitionNodes.has(n.name) && n.from !== node.from && view.state.doc.sliceString(n.from, n.to) === name) {
target = n.from;
return false;
}
},
});
if (target === null) return false;
view.dispatch({selection: {anchor: target}, scrollIntoView: true});
return true;
}
/** CodeMirror extension that makes URLs clickable via Ctrl/Cmd+click. */
export function clickableUrls(cm: CodemirrorModules) {
const urlMark = cm.view.Decoration.mark({class: 'cm-url'});
const urlDecorator = new cm.view.MatchDecorator({
regexp: urlRawRegex,
decorate: (add, from, _to, match) => {
const trimmed = trimUrlPunctuation(match[0]);
add(from, from + trimmed.length, urlMark);
},
});
const plugin = cm.view.ViewPlugin.fromClass(class {
decorations: ReturnType<typeof urlDecorator.createDeco>;
constructor(view: EditorView) {
this.decorations = urlDecorator.createDeco(view);
}
update(update: ViewUpdate) {
this.decorations = urlDecorator.updateDeco(update, this.decorations);
}
}, {decorations: (v) => v.decorations});
const handler = cm.view.EditorView.domEventHandlers({
mousedown(event: MouseEvent, view: EditorView) {
if (!event.metaKey && !event.ctrlKey) return false;
const pos = view.posAtCoords({x: event.clientX, y: event.clientY});
if (pos === null) return false;
const line = view.state.doc.lineAt(pos);
const url = findUrlAtPosition(line.text, pos - line.from);
if (url) {
window.open(url, '_blank', 'noopener');
event.preventDefault();
return true;
}
// Fall back to go-to-definition: find the symbol at cursor and jump to its definition
if (goToDefinitionAt(cm, view, pos)) {
event.preventDefault();
return true;
}
return false;
},
});
const modClass = cm.view.ViewPlugin.fromClass(class {
container: Element;
handleKeyDown: (e: KeyboardEvent) => void;
handleKeyUp: (e: KeyboardEvent) => void;
handleBlur: () => void;
constructor(view: EditorView) {
this.container = view.dom.closest('.code-editor-container')!;
this.handleKeyDown = (e) => { if (e.key === 'Meta' || e.key === 'Control') this.container.classList.add('cm-mod-held'); };
this.handleKeyUp = (e) => { if (e.key === 'Meta' || e.key === 'Control') this.container.classList.remove('cm-mod-held'); };
this.handleBlur = () => this.container.classList.remove('cm-mod-held');
document.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keyup', this.handleKeyUp);
window.addEventListener('blur', this.handleBlur);
}
destroy() {
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
window.removeEventListener('blur', this.handleBlur);
this.container.classList.remove('cm-mod-held');
}
});
return [plugin, handler, modClass];
}
@@ -0,0 +1,20 @@
import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
import type {Toast} from './toast.ts';
import {registerGlobalInitFunc} from './observer.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;
const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
for (const el of els) {
el.addEventListener('click', () => {
const level = el.getAttribute('data-toast-level')!;
const message = el.getAttribute('data-toast-message')!;
levelMap[level](message);
});
}
});
}
@@ -12,12 +12,12 @@ export type DiffTreeEntry = {
DiffStatus: DiffStatus,
EntryMode: string,
IsViewed: boolean,
Children: DiffTreeEntry[],
Children: DiffTreeEntry[] | null,
FileIcon: string,
ParentEntry?: DiffTreeEntry,
};
type DiffFileTreeData = {
export type DiffFileTreeData = {
TreeRoot: DiffTreeEntry,
};
@@ -25,7 +25,7 @@ type DiffFileTree = {
folderIcon: string;
folderOpenIcon: string;
diffFileTree: DiffFileTreeData;
fullNameMap?: Record<string, DiffTreeEntry>
fullNameMap: Record<string, DiffTreeEntry>
fileTreeIsVisible: boolean;
selectedItem: string;
};
@@ -33,7 +33,7 @@ type DiffFileTree = {
let diffTreeStoreReactive: Reactive<DiffFileTree>;
export function diffTreeStore() {
if (!diffTreeStoreReactive) {
diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree, pageData.FolderIcon, pageData.FolderOpenIcon);
diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree!, pageData.FolderIcon!, pageData.FolderOpenIcon!);
}
return diffTreeStoreReactive;
}
@@ -0,0 +1,27 @@
import {isGiteaError, showGlobalErrorMessage} from './errors.ts';
test('isGiteaError', () => {
expect(isGiteaError('', '')).toBe(true);
expect(isGiteaError('moz-extension://abc/content.js', '')).toBe(false);
expect(isGiteaError('safari-extension://abc/content.js', '')).toBe(false);
expect(isGiteaError('safari-web-extension://abc/content.js', '')).toBe(false);
expect(isGiteaError('chrome-extension://abc/content.js', '')).toBe(false);
expect(isGiteaError('https://other-site.com/script.js', '')).toBe(false);
expect(isGiteaError('http://localhost:3000/some/page', '')).toBe(true);
expect(isGiteaError('http://localhost:3000/assets/js/index.abc123.js', '')).toBe(true);
expect(isGiteaError('', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
expect(isGiteaError('', `Error\n at https://other-site.com/script.js:1:1`)).toBe(false);
expect(isGiteaError('', `Error\n at http://localhost:3000/assets/js/index.abc123.js:1:1`)).toBe(true);
expect(isGiteaError('http://localhost:3000/assets/js/index.js', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
});
test('showGlobalErrorMessage', () => {
document.body.innerHTML = '<div class="page-content"></div>';
showGlobalErrorMessage('test msg 1');
showGlobalErrorMessage('test msg 2');
showGlobalErrorMessage('test msg 1'); // duplicated
expect(document.body.innerHTML).toContain('>test msg 1 (2)<');
expect(document.body.innerHTML).toContain('>test msg 2<');
expect(document.querySelectorAll('.js-global-error').length).toEqual(2);
});
@@ -0,0 +1,57 @@
// keep this file lightweight, it's imported into IIFE chunk in bootstrap
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) {
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) {
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;
}
// 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);
}
// Detect whether an error originated from Gitea's own scripts, not from
// browser extensions or other external scripts.
const extensionRe = /(chrome|moz|safari(-web)?)-extension:\/\//;
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;
}
export function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) {
const err = error ?? reason;
// `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a
// non-critical event from the browser. We log them but don't show them to users. Examples:
// - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
// - https://github.com/mozilla-mobile/firefox-ios/issues/10817
// - https://github.com/go-gitea/gitea/issues/20240
if (!err) {
if (message) console.error(new Error(message));
if (window.config.runModeIsProd) return;
}
// 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.`);
}
@@ -1,17 +1,12 @@
import {isObject} from '../utils.ts';
import type {RequestOpts} from '../types.ts';
const {csrfToken} = window.config;
// safe HTTP methods that don't need a csrf token
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
// 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
// and array types are currently supported.
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
let body: string | FormData | URLSearchParams;
let contentType: string;
let body: string | FormData | URLSearchParams | undefined;
let contentType: string | undefined;
if (data instanceof FormData || data instanceof URLSearchParams) {
body = data;
} else if (isObject(data) || Array.isArray(data)) {
@@ -20,7 +15,6 @@ export function request(url: string, {method = 'GET', data, headers = {}, ...oth
}
const headersMerged = new Headers({
...(!safeMethods.has(method) && {'x-csrf-token': csrfToken}),
...(contentType && {'content-type': contentType}),
});
@@ -28,7 +22,7 @@ export function request(url: string, {method = 'GET', data, headers = {}, ...oth
headersMerged.set(name, value);
}
return fetch(url, {
return fetch(url, { // eslint-disable-line no-restricted-globals
method,
headers: headersMerged,
...other,
@@ -1,4 +1,3 @@
import $ from 'jquery';
import {initAriaCheckboxPatch} from './fomantic/checkbox.ts';
import {initAriaFormFieldPatch} from './fomantic/form.ts';
import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
@@ -1,4 +1,3 @@
import $ from 'jquery';
import {generateElemId} from '../../utils/dom.ts';
export function linkLabelAndInput(label: Element, input: Element) {
@@ -14,4 +13,7 @@ export function linkLabelAndInput(label: Element, input: Element) {
}
}
export const fomanticQuery = $;
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,4 +1,3 @@
import $ from 'jquery';
import {queryElemChildren} from '../../utils/dom.ts';
export function initFomanticDimmer() {
@@ -1,4 +1,3 @@
import $ from 'jquery';
import type {FomanticInitFunction} from '../../types.ts';
import {generateElemId, queryElems} from '../../utils/dom.ts';
@@ -65,7 +64,7 @@ function updateSelectionLabel(label: HTMLElement) {
const deleteIcon = label.querySelector('.delete.icon');
if (deleteIcon) {
deleteIcon.setAttribute('aria-hidden', 'false');
deleteIcon.setAttribute('aria-label', window.config.i18n.remove_label_str.replace('%s', label.getAttribute('data-value')));
deleteIcon.setAttribute('aria-label', window.config.i18n.remove_label_str.replace('%s', label.getAttribute('data-value')!));
deleteIcon.setAttribute('role', 'button');
}
}
@@ -225,6 +224,7 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
};
dropdown.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.isComposing) return;
// here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler
if (e.key === 'Enter') {
const elItem = menu.querySelector<HTMLElement>(':scope > .item.selected, .menu > .item.selected');
@@ -5,7 +5,7 @@ export function initAriaFormFieldPatch() {
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');
const input = el.querySelector(':scope > input, :scope > select');
if (!label || !input) continue;
linkLabelAndInput(label, input);
el.setAttribute('data-field-patched', 'true');
@@ -1,4 +1,3 @@
import $ from 'jquery';
import type {FomanticInitFunction} from '../../types.ts';
import {queryElems} from '../../utils/dom.ts';
import {hideToastsFrom} from '../toast.ts';
@@ -1,4 +1,3 @@
import $ from 'jquery';
import {queryElemSiblings} from '../../utils/dom.ts';
export function initFomanticTab() {
@@ -7,7 +6,7 @@ export function initFomanticTab() {
const tabName = elBtn.getAttribute('data-tab');
if (!tabName) continue;
elBtn.addEventListener('click', () => {
const elTab = document.querySelector(`.ui.tab[data-tab="${tabName}"]`);
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');
@@ -1,5 +1,3 @@
import $ from 'jquery';
export function initFomanticTransition() {
const transitionNopBehaviors = new Set([
'clear queue', 'stop', 'stop all', 'destroy',
@@ -0,0 +1,53 @@
// 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 ActionsRun = {
repoId: number,
link: string,
title: string,
titleHTML: string,
status: ActionsRunStatus,
canCancel: boolean,
canApprove: boolean,
canRerun: boolean,
canRerunFailed: boolean,
canDeleteArtifact: boolean,
done: boolean,
workflowID: string,
workflowLink: string,
isSchedule: boolean,
duration: string,
triggeredAt: number,
triggerEvent: string,
jobs: Array<ActionsJob>,
commit: {
localeCommit: string,
localePushedBy: string,
shortSHA: string,
link: string,
pusher: {
displayName: string,
link: string,
},
branch: {
name: string,
link: string,
isDeleted: boolean,
},
},
};
export type ActionsJob = {
id: number;
jobId: string;
name: string;
status: ActionsRunStatus;
canRerun: boolean;
needs?: string[];
duration: string;
};
export type ActionsArtifact = {
name: string;
status: string;
};
@@ -1,6 +1,6 @@
export class InitPerformanceTracer {
results: {name: string, dur: number}[] = [];
recordCall(name: string, func: ()=>void) {
recordCall(name: string, func: () => void) {
const start = performance.now();
func();
this.results.push({name, dur: performance.now() - start});
@@ -42,7 +42,8 @@ export function registerGlobalInitFunc<T extends HTMLElement>(name: string, hand
}
function callGlobalInitFunc(el: HTMLElement) {
const initFunc = el.getAttribute('data-global-init');
// 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`);
@@ -66,7 +67,7 @@ function attachGlobalEvents() {
});
}
export function initGlobalSelectorObserver(perfTracer?: InitPerformanceTracer): void {
export function initGlobalSelectorObserver(perfTracer: InitPerformanceTracer | null): void {
if (globalSelectorObserverInited) throw new Error('initGlobalSelectorObserver() already called');
globalSelectorObserverInited = true;
@@ -0,0 +1,76 @@
import {registerGlobalInitFunc} from './observer.ts';
import {hideElem, toggleElem} from '../utils/dom.ts';
function initShortcutKbd(kbd: HTMLElement) {
// Handle initial state: hide the kbd hint if the associated input already has a value
// (e.g., from browser autofill or back/forward navigation cache)
const elem = elemFromKbd(kbd);
if (elem?.value) hideElem(kbd);
kbd.setAttribute('aria-hidden', 'true');
kbd.setAttribute('aria-keyshortcuts', kbd.getAttribute('data-shortcut-keys')!);
}
function shortcutWrapper(el: HTMLElement): HTMLElement | null {
const parent = el.parentElement;
return parent?.matches('.global-shortcut-wrapper') ? parent : null;
}
function elemFromKbd(kbd: HTMLElement): HTMLInputElement | HTMLTextAreaElement | null {
return shortcutWrapper(kbd)?.querySelector<HTMLInputElement>('input, textarea') || null;
}
function kbdFromElem(input: HTMLElement): HTMLElement | null {
return shortcutWrapper(input)?.querySelector<HTMLElement>('kbd') || null;
}
export function initGlobalShortcut() {
registerGlobalInitFunc('onGlobalShortcut', initShortcutKbd);
// A <kbd> element next to an <input> declares a keyboard shortcut for that input.
// When the matching key is pressed, the sibling input is focused.
// When Escape is pressed inside such an input, the input is cleared and blurred.
// The <kbd> element is shown/hidden automatically based on input focus and value.
document.addEventListener('keydown', (e: KeyboardEvent) => {
// Modifier keys are not supported yet
if (e.ctrlKey || e.metaKey || e.altKey) return;
const target = e.target as HTMLElement;
// Handle Escape: clear and blur inputs that have an associated keyboard shortcut
if (e.key === 'Escape') {
const kbd = kbdFromElem(target);
if (kbd) {
(target as HTMLInputElement).value = '';
(target as HTMLInputElement).blur();
}
return;
}
// Don't trigger shortcuts when typing in input fields or contenteditable areas
if (target.matches('input, textarea, select') || target.isContentEditable) {
return;
}
// Find kbd element with matching shortcut (case-insensitive), then focus its sibling input
const key = e.key.toLowerCase();
// At the moment, only a simple match. In the future, it can be extended to support modifiers and key combinations
const kbd = document.querySelector<HTMLElement>(`.global-shortcut-wrapper > kbd[data-shortcut-keys="${CSS.escape(key)}"]`);
if (!kbd) return;
e.preventDefault();
elemFromKbd(kbd)!.focus();
});
// Toggle kbd shortcut hint visibility on input focus/blur
document.addEventListener('focusin', (e) => {
const kbd = kbdFromElem(e.target as HTMLElement);
if (!kbd) return;
hideElem(kbd);
});
document.addEventListener('focusout', (e) => {
const kbd = kbdFromElem(e.target as HTMLElement);
if (!kbd) return;
const hasContent = Boolean((e.target as HTMLInputElement).value);
toggleElem(kbd, !hasContent);
});
}
@@ -1,20 +1,20 @@
import type {SortableOptions, SortableEvent} from 'sortablejs';
import type SortableType from 'sortablejs';
export async function createSortable(el: Element, opts: {handle?: string} & SortableOptions = {}): Promise<SortableType> {
// @ts-expect-error: wrong type derived by typescript
const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs');
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}): Promise<SortableType> {
// type reassigned because typescript derives the wrong type from this import
const {Sortable} = (await import('sortablejs') as unknown as {Sortable: typeof SortableType});
return new Sortable(el, {
animation: 150,
ghostClass: 'card-ghost',
onChoose: (e: SortableEvent) => {
const handle = opts.handle ? e.item.querySelector(opts.handle) : e.item;
const handle = opts.handle ? e.item.querySelector(opts.handle)! : e.item;
handle.classList.add('tw-cursor-grabbing');
opts.onChoose?.(e);
},
onUnchoose: (e: SortableEvent) => {
const handle = opts.handle ? e.item.querySelector(opts.handle) : e.item;
const handle = opts.handle ? e.item.querySelector(opts.handle)! : e.item;
handle.classList.remove('tw-cursor-grabbing');
opts.onUnchoose?.(e);
},
@@ -1,6 +1,5 @@
import tippy, {followCursor} from 'tippy.js';
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts';
import type {Content, Instance, Placement, Props} from 'tippy.js';
import {html} from '../utils/html.ts';
@@ -68,7 +67,7 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
*
* Note: "tooltip" doesn't equal to "tippy". "tooltip" means a auto-popup content, it just uses tippy as the implementation.
*/
function attachTooltip(target: Element, content: Content = null): Instance {
function attachTooltip(target: Element, content: Content | null = null): Instance | null {
switchTitleToTooltip(target);
content = content ?? target.getAttribute('data-tooltip-content');
@@ -100,20 +99,10 @@ function attachTooltip(target: Element, content: Content = null): Instance {
}
function switchTitleToTooltip(target: Element): void {
let title = target.getAttribute('title');
const title = target.getAttribute('title');
if (title) {
// apply custom formatting to relative-time's tooltips
if (target.tagName.toLowerCase() === 'relative-time') {
const datetime = target.getAttribute('datetime');
if (datetime) {
title = formatDatetime(new Date(datetime));
}
}
target.setAttribute('data-tooltip-content', title);
target.setAttribute('aria-label', title);
// keep the attribute, in case there are some other "[title]" selectors
// and to prevent infinite loop with <relative-time> which will re-add
// title if it is absent
target.setAttribute('title', '');
}
}
@@ -125,7 +114,7 @@ function switchTitleToTooltip(target: Element): void {
* The tippy by default uses "mouseenter" event to show, so we use "mouseover" event to switch to tippy
*/
function lazyTooltipOnMouseHover(this: HTMLElement, e: Event): void {
e.target.removeEventListener('mouseover', lazyTooltipOnMouseHover, true);
(e.target as HTMLElement).removeEventListener('mouseover', lazyTooltipOnMouseHover, true);
attachTooltip(this);
}
@@ -155,7 +144,7 @@ export function initGlobalTooltips(): void {
const observerConnect = (observer: MutationObserver) => observer.observe(document, {
subtree: true,
childList: true,
attributeFilter: ['data-tooltip-content', 'title'],
attributeFilter: ['data-tooltip-content'],
});
const observer = new MutationObserver((mutationList, observer) => {
const pending = observer.takeRecords();
@@ -184,7 +173,7 @@ export function initGlobalTooltips(): void {
export function showTemporaryTooltip(target: Element, content: Content): void {
// if the target is inside a dropdown or tippy popup, the menu will be hidden soon
// so display the tooltip on the "aria-controls" element or dropdown instead
let refClientRect: DOMRect;
let refClientRect: DOMRect | undefined;
const popupTippyId = target.closest(`[data-tippy-root]`)?.id;
if (popupTippyId) {
// for example, the "Copy Permalink" button in the "File View" page for the selected lines
@@ -200,6 +189,7 @@ export function showTemporaryTooltip(target: Element, content: Content): void {
tooltipTippy.setContent(content);
tooltipTippy.setProps({getReferenceClientRect: () => refClientRect});
if (!tooltipTippy.state.isShown) tooltipTippy.show();
tooltipTippy.setProps({
onHidden: (tippy) => {
// reset the default tooltip content, if no default, then this temporary tooltip could be destroyed
@@ -208,6 +198,14 @@ export function showTemporaryTooltip(target: Element, content: Content): void {
}
},
});
// on elements where the tooltip is re-located like "Copy Link" inside fomantic dropdowns, tippy.js gets
// no `mouseout` event and the tooltip stays visible, hide it with timeout.
if (!popupTippyId) {
setTimeout(() => {
if (tooltipTippy.state.isVisible) tooltipTippy.hide();
}, 1500);
}
}
export function getAttachedTippyInstance(el: Element): Instance | null {
@@ -43,7 +43,7 @@ type ToastOpts = {
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 {
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) : '';
@@ -52,11 +52,11 @@ function showToast(message: string, level: Intent, {gravity, position, duration,
if (preventDuplicates) {
const toastEl = parent.querySelector(`:scope > .toastify.on[data-toast-unique-key="${CSS.escape(duplicateKey)}"]`);
if (toastEl) {
const toastDupNumEl = toastEl.querySelector('.toast-duplicate-number');
const toastDupNumEl = toastEl.querySelector('.toast-duplicate-number')!;
showElem(toastDupNumEl);
toastDupNumEl.textContent = String(Number(toastDupNumEl.textContent) + 1);
animateOnce(toastDupNumEl, 'pulse-1p5-200');
return;
return null;
}
}
@@ -77,21 +77,22 @@ function showToast(message: string, level: Intent, {gravity, position, duration,
});
toast.showToast();
toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast());
toast.toastElement.setAttribute('data-toast-unique-key', duplicateKey);
(toast.toastElement as ToastifyElement)._giteaToastifyInstance = toast;
const el = toast.toastElement as ToastifyElement;
el.querySelector('.toast-close')!.addEventListener('click', () => toast.hideToast());
el.setAttribute('data-toast-unique-key', duplicateKey);
el._giteaToastifyInstance = toast;
return toast;
}
export function showInfoToast(message: string, opts?: ToastOpts): Toast {
export function showInfoToast(message: string, opts?: ToastOpts): Toast | null {
return showToast(message, 'info', opts);
}
export function showWarningToast(message: string, opts?: ToastOpts): Toast {
export function showWarningToast(message: string, opts?: ToastOpts): Toast | null {
return showToast(message, 'warning', opts);
}
export function showErrorToast(message: string, opts?: ToastOpts): Toast {
export function showErrorToast(message: string, opts?: ToastOpts): Toast | null {
return showToast(message, 'error', opts);
}
@@ -0,0 +1,73 @@
/* eslint-disable no-restricted-globals */
// Some people deploy Gitea under a subpath, so it needs prefix to avoid local storage key conflicts.
// And these keys are for user settings only, it also needs a specific prefix,
// in case in the future there are other uses of local storage, and/or we need to clear some keys when the quota is exceeded.
const itemKeyPrefix = 'gitea:setting:';
function handleLocalStorageError(e: any) {
// in the future, maybe we need to handle quota exceeded errors differently
console.error('Error using local storage for user settings', e);
}
function getLocalStorageUserSetting(settingKey: string): string | null {
const legacyKey = settingKey;
const itemKey = `${itemKeyPrefix}${settingKey}`;
try {
const legacyValue = localStorage?.getItem(legacyKey) ?? null;
const value = localStorage?.getItem(itemKey) ?? null; // avoid undefined
if (value !== null && legacyValue !== null) {
// if both values exist, remove the legacy one
localStorage?.removeItem(legacyKey);
} else if (value === null && legacyValue !== null) {
// migrate legacy value to new key
localStorage?.removeItem(legacyKey);
localStorage?.setItem(itemKey, legacyValue);
return legacyValue;
}
return value;
} catch (e) {
handleLocalStorageError(e);
}
return null;
}
function setLocalStorageUserSetting(settingKey: string, value: string) {
const legacyKey = settingKey;
const itemKey = `${itemKeyPrefix}${settingKey}`;
try {
localStorage?.removeItem(legacyKey);
localStorage?.setItem(itemKey, value);
} catch (e) {
handleLocalStorageError(e);
}
}
export const localUserSettings = {
getString: (key: string, def: string = ''): string => {
return getLocalStorageUserSetting(key) ?? def;
},
setString: (key: string, value: string) => {
setLocalStorageUserSetting(key, value);
},
getBoolean: (key: string, def: boolean = false): boolean => {
return localUserSettings.getString(key, String(def)) === 'true';
},
setBoolean: (key: string, value: boolean) => {
localUserSettings.setString(key, String(value));
},
getJsonObject: <T extends Record<string, any>>(key: string, def: T): T => {
const value = getLocalStorageUserSetting(key);
try {
const decoded = value !== null ? JSON.parse(value) : null;
return {...def, ...decoded};
} catch (e) {
console.error(`Unable to parse JSON value for local user settings ${key}=${value}`, e);
}
return def;
},
setJsonObject: <T extends Record<string, any>>(key: string, value: T) => {
localUserSettings.setString(key, JSON.stringify(value));
},
};
window.localUserSettings = localUserSettings;
@@ -1,9 +1,65 @@
import {sleep} from '../utils.ts';
const {appSubUrl, sharedWorkerUri} = window.config;
const {appSubUrl} = window.config;
export class UserEventsSharedWorker {
sharedWorker: SharedWorker;
export async function logoutFromWorker(): Promise<void> {
// wait for a while because other requests (eg: logout) may be in the flight
await sleep(5000);
window.location.href = `${appSubUrl}/`;
// options can be either a string (the debug name of the worker) or an object of type WorkerOptions
constructor(options?: string | WorkerOptions) {
const worker = new SharedWorker(sharedWorkerUri, options);
this.sharedWorker = worker;
worker.addEventListener('error', (event) => {
console.error('worker error', event);
});
worker.port.addEventListener('messageerror', () => {
console.error('unable to deserialize message');
});
worker.port.postMessage({
type: 'start',
url: `${window.location.origin}${appSubUrl}/user/events`,
});
worker.port.addEventListener('error', (e) => {
console.error('worker port error', e);
});
window.addEventListener('beforeunload', () => {
// FIXME: this logic is not quite right.
// "beforeunload" can be canceled by some actions like "are-you-sure" and the navigation can be cancelled.
// In this case: the worker port is incorrectly closed while the page is still there.
worker.port.postMessage({type: 'close'});
worker.port.close();
});
}
addMessageEventListener(listener: (event: MessageEvent) => void) {
this.sharedWorker.port.addEventListener('message', (event: MessageEvent) => {
if (!event.data || !event.data.type) {
console.error('unknown worker message event', event);
return;
}
if (event.data.type === 'error') {
console.error('worker port event error', event.data);
} else if (event.data.type === 'logout') {
if (event.data.data !== 'here') return;
this.sharedWorker.port.postMessage({type: 'close'});
this.sharedWorker.port.close();
// slightly delay our "logout" for a short while, in case there are other logout requests in-flight.
// * if the logout is triggered by a page redirection (e.g.: user clicks "/user/logout")
// * "beforeunload" event is triggered, this code path won't execute
// * if the logout is triggered by a fetch call
// * "beforeunload" event is not triggered until JS does the redirection.
// * 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);
} else if (event.data.type === 'close') {
this.sharedWorker.port.postMessage({type: 'close'});
this.sharedWorker.port.close();
}
listener(event);
});
}
startPort() {
this.sharedWorker.port.start();
}
}