Merge branch 'main' into feature/workflow-graph

This commit is contained in:
Semenets V. Pavel authored and GitHub committed 2026-02-08 12:43:33 +03:00
commit 0f9d18a712
65 files changed
+665 -298

No files matched your search

@@ -307,6 +307,7 @@ export default defineComponent({
},
async reposFilterKeyControl(e: KeyboardEvent) {
if (e.isComposing) return;
switch (e.key) {
case 'Enter':
document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click();
+24 -20
View File
@@ -1,22 +1,26 @@
import {shouldHideLine, type LogLine} from './RepoActionView.vue';
import {createLogLineMessage, parseLogLineCommand} from './RepoActionView.vue';
test('shouldHideLine', () => {
expect(([
{index: 1, message: 'Starting build process', timestamp: 1000},
{index: 2, message: '::add-matcher::/home/runner/go/pkg/mod/example.com/tool/matcher.json', timestamp: 1001},
{index: 3, message: 'Running tests...', timestamp: 1002},
{index: 4, message: '##[add-matcher]/opt/hostedtoolcache/go/1.25.7/x64/matchers.json', timestamp: 1003},
{index: 5, message: 'Test suite started', timestamp: 1004},
{index: 7, message: 'All tests passed', timestamp: 1006},
{index: 8, message: '::remove-matcher owner=go::', timestamp: 1007},
{index: 9, message: 'Build complete', timestamp: 1008},
] as Array<LogLine>).filter((line) => !shouldHideLine(line)).map((line) => line.message)).toMatchInlineSnapshot(`
[
"Starting build process",
"Running tests...",
"Test suite started",
"All tests passed",
"Build complete",
]
`);
test('LogLineMessage', () => {
const cases = {
'normal message': '<span class="log-msg">normal message</span>',
'##[group] foo': '<span class="log-msg log-cmd-group"> foo</span>',
'::group::foo': '<span class="log-msg log-cmd-group">foo</span>',
'##[endgroup]': '<span class="log-msg log-cmd-endgroup"></span>',
'::endgroup::': '<span class="log-msg log-cmd-endgroup"></span>',
// parser shouldn't do any trim, keep origin output as-is
'##[error] foo': '<span class="log-msg log-cmd-error"> foo</span>',
'[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
// hidden is special, it is actually skipped before creating
'##[add-matcher]foo': '<span class="log-msg log-cmd-hidden">foo</span>',
'::add-matcher::foo': '<span class="log-msg log-cmd-hidden">foo</span>',
'::remove-matcher foo::': '<span class="log-msg log-cmd-hidden"> foo::</span>', // not correctly parsed, but we don't need it
};
for (const [input, html] of Object.entries(cases)) {
const line = {index: 0, timestamp: 0, message: input};
const cmd = parseLogLineCommand(line);
const el = createLogLineMessage(line, cmd);
expect(el.outerHTML).toBe(html);
}
});
+70 -48
View File
@@ -14,7 +14,12 @@ import {localUserSettings} from '../modules/user-settings.ts';
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
type StepContainerElement = HTMLElement & {_stepLogsActiveContainer?: HTMLElement}
type StepContainerElement = HTMLElement & {
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
// then the following batches of logs should still use the same group (active logs container).
// maybe it can be refactored to decouple from the HTML element in the future.
_stepLogsActiveContainer?: HTMLElement;
}
export type LogLine = {
index: number;
@@ -22,19 +27,35 @@ export type LogLine = {
message: string;
};
// `##[group]` is from Azure Pipelines, just supported by the way. https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands
const LogLinePrefixesGroup = ['::group::', '##[group]'];
const LogLinePrefixesEndGroup = ['::endgroup::', '##[endgroup]'];
// https://github.com/actions/toolkit/blob/master/docs/commands.md
// https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
// Although there should be no `##[add-matcher]` syntax, there are still such outputs when using act-runner
const LogLinePrefixesHidden = ['::add-matcher::', '##[add-matcher]', '::remove-matcher'];
type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'hidden';
type LogLineCommand = {
name: 'group' | 'endgroup',
name: LogLineCommandName,
prefix: string,
}
// How GitHub Actions logs work:
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
// * The reported logs are the processed logs.
// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
'::group::': 'group',
'##[group]': 'group',
'::endgroup::': 'endgroup',
'##[endgroup]': 'endgroup',
'##[error]': 'error',
'[command]': 'command',
// https://github.com/actions/toolkit/blob/master/docs/commands.md
// https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
'::add-matcher::': 'hidden',
'##[add-matcher]': 'hidden',
'::remove-matcher': 'hidden', // it has arguments
};
type Job = {
id: number;
job_id: string;
@@ -57,27 +78,27 @@ type JobStepState = {
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
}
function parseLineCommand(line: LogLine): LogLineCommand | null {
for (const prefix of LogLinePrefixesGroup) {
export function parseLogLineCommand(line: LogLine): LogLineCommand | null {
// TODO: in the future it can be refactored to be a general parser that can parse arguments, drop the "prefix match"
for (const prefix in LogLinePrefixCommandMap) {
if (line.message.startsWith(prefix)) {
return {name: 'group', prefix};
}
}
for (const prefix of LogLinePrefixesEndGroup) {
if (line.message.startsWith(prefix)) {
return {name: 'endgroup', prefix};
return {name: LogLinePrefixCommandMap[prefix], prefix};
}
}
return null;
}
export function shouldHideLine(line: LogLine): boolean {
for (const prefix of LogLinePrefixesHidden) {
if (line.message.startsWith(prefix)) {
return true;
}
}
return false;
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
const logMsgAttrs = {class: 'log-msg'};
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd?.name}`; // make it easier to add styles to some commands like "error"
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
const logMsg = createElementFromAttrs('span', logMsgAttrs);
logMsg.innerHTML = renderAnsi(msgContent);
return logMsg;
}
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
@@ -255,11 +276,7 @@ export default defineComponent({
beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
const el = (this.$refs.logs as any)[stepIndex] as StepContainerElement;
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
this.createLogLine(stepIndex, startTime, {
index: line.index,
timestamp: line.timestamp,
message: line.message.substring(cmd.prefix.length),
}),
this.createLogLine(stepIndex, startTime, line, cmd),
);
const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
@@ -273,11 +290,7 @@ export default defineComponent({
endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
const el = (this.$refs.logs as any)[stepIndex];
el._stepLogsActiveContainer = null;
el.append(this.createLogLine(stepIndex, startTime, {
index: line.index,
timestamp: line.timestamp,
message: line.message.substring(cmd.prefix.length),
}));
el.append(this.createLogLine(stepIndex, startTime, line, cmd));
},
// show/hide the step logs for a step
@@ -298,7 +311,7 @@ export default defineComponent({
POST(`${this.run.link}/approve`);
},
createLogLine(stepIndex: number, startTime: number, line: LogLine) {
createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand | null) {
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
String(line.index),
);
@@ -307,9 +320,7 @@ export default defineComponent({
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
);
const logMsg = createElementFromAttrs('span', {class: 'log-msg'});
logMsg.innerHTML = renderAnsi(line.message);
const logMsg = createLogLineMessage(line, cmd);
const seconds = Math.floor(line.timestamp - startTime);
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
`${seconds}s`, // for "Show seconds"
@@ -334,17 +345,20 @@ export default defineComponent({
appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
for (const line of logLines) {
if (shouldHideLine(line)) continue;
const el = this.getActiveLogsContainer(stepIndex);
const cmd = parseLineCommand(line);
if (cmd?.name === 'group') {
this.beginLogGroup(stepIndex, startTime, line, cmd);
continue;
} else if (cmd?.name === 'endgroup') {
this.endLogGroup(stepIndex, startTime, line, cmd);
continue;
const cmd = parseLogLineCommand(line);
switch (cmd?.name) {
case 'hidden':
continue;
case 'group':
this.beginLogGroup(stepIndex, startTime, line, cmd);
continue;
case 'endgroup':
this.endLogGroup(stepIndex, startTime, line, cmd);
continue;
}
el.append(this.createLogLine(stepIndex, startTime, line));
// the active logs container may change during the loop, for example: entering and leaving a group
const el = this.getActiveLogsContainer(stepIndex);
el.append(this.createLogLine(stepIndex, startTime, line, cmd));
}
},
@@ -1047,6 +1061,14 @@ export default defineComponent({
overflow-wrap: anywhere;
}
.job-step-logs .job-log-line .log-cmd-command {
color: var(--color-ansi-blue);
}
.job-step-logs .job-log-line .log-cmd-error {
color: var(--color-ansi-red);
}
/* selectors here are intentionally exact to only match fullscreen */
.full.height > .action-view-right {
@@ -159,6 +159,7 @@ export default defineComponent({
return el?.length ? el[0] : null;
},
keydown(e: KeyboardEvent) {
if (e.isComposing) return;
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
+2
View File
@@ -42,6 +42,8 @@ const handleSearchInput = () => {
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return;
if (e.key === 'Escape') {
e.preventDefault();
clearSearch();
+1
View File
@@ -14,6 +14,7 @@ export function initGlobalFormDirtyLeaveConfirm() {
export function initGlobalEnterQuickSubmit() {
document.addEventListener('keydown', (e) => {
if (e.isComposing) return;
if (e.key !== 'Enter') return;
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
if (hasCtrlOrMeta && (e.target as HTMLElement).matches('textarea')) {
@@ -223,6 +223,7 @@ function isTextExpanderShown(textarea: HTMLElement): boolean {
export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
textarea.addEventListener('keydown', (e) => {
if (e.isComposing) return;
if (isTextExpanderShown(textarea)) return;
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Tab/Shift-Tab to indent/unindent the selected lines
+1
View File
@@ -82,6 +82,7 @@ function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
});
// alt(or option) + enter to exclude selected label
elDropdown.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.isComposing) return;
if (e.altKey && e.key === 'Enter') {
const selectedItem = elDropdown.querySelector('.label-filter-query-item.selected');
if (selectedItem) excludeLabel(e, selectedItem);
+4 -1
View File
@@ -1,10 +1,13 @@
import {svg} from '../svg.ts';
import {queryElems} from '../utils/dom.ts';
export function makeCodeCopyButton(): HTMLButtonElement {
export function makeCodeCopyButton(attrs: Record<string, string> = {}): HTMLButtonElement {
const button = document.createElement('button');
button.classList.add('code-copy', 'ui', 'button');
button.innerHTML = svg('octicon-copy');
for (const [key, value] of Object.entries(attrs)) {
button.setAttribute(key, value);
}
return button;
}
+59
View File
@@ -0,0 +1,59 @@
import {sourceNeedsElk} from './mermaid.ts';
import {dedent} from '../utils/testhelper.ts';
test('MermaidConfigLayoutCheck', () => {
expect(sourceNeedsElk(dedent(`
flowchart TB
elk --> B
`))).toEqual(false);
expect(sourceNeedsElk(dedent(`
---
config:
layout : elk
---
flowchart TB
A --> B
`))).toEqual(true);
expect(sourceNeedsElk(dedent(`
---
config:
layout: elk.layered
---
flowchart TB
A --> B
`))).toEqual(true);
expect(sourceNeedsElk(`
%%{ init : { "flowchart": { "defaultRenderer": "elk" } } }%%
flowchart TB
A --> B
`)).toEqual(true);
expect(sourceNeedsElk(dedent(`
---
config:
layout: 123
---
%%{ init : { "class": { "defaultRenderer": "elk.any" } } }%%
flowchart TB
A --> B
`))).toEqual(true);
expect(sourceNeedsElk(`
%%{init:{
"layout" : "elk.layered"
}}%%
flowchart TB
A --> B
`)).toEqual(true);
expect(sourceNeedsElk(`
%%{ initialize: {
'layout' : 'elk.layered'
}}%%
flowchart TB
A --> B
`)).toEqual(true);
});
+125 -58
View File
@@ -1,8 +1,10 @@
import {isDarkTheme} from '../utils.ts';
import {isDarkTheme, parseDom} from '../utils.ts';
import {makeCodeCopyButton} from './codecopy.ts';
import {displayError} from './common.ts';
import {queryElems} from '../utils/dom.ts';
import {createElementFromAttrs, queryElems} from '../utils/dom.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {load as loadYaml} from 'js-yaml';
import type {MermaidConfig} from 'mermaid';
const {mermaidMaxSourceCharacters} = window.config;
@@ -10,78 +12,143 @@ const iframeCss = `:root {color-scheme: normal}
body {margin: 0; padding: 0; overflow: hidden}
#mermaid {display: block; margin: 0 auto}`;
function isSourceTooLarge(source: string) {
return mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters;
}
function parseYamlInitConfig(source: string): MermaidConfig | null {
// ref: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/diagram-api/regexes.ts
const yamlFrontMatterRegex = /^---\s*[\n\r](.*?)[\n\r]---\s*[\n\r]+/s;
const frontmatter = (yamlFrontMatterRegex.exec(source) || [])[1];
if (!frontmatter) return null;
try {
return (loadYaml(frontmatter) as {config: MermaidConfig})?.config;
} catch {
console.error('invalid or unsupported mermaid init YAML config', frontmatter);
}
return null;
}
function parseJsonInitConfig(source: string): MermaidConfig | null {
// https://mermaid.js.org/config/directives.html#declaring-directives
// Do as dirty as mermaid does: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/utils.ts
// It can even accept invalid JSON string like:
// %%{initialize: { 'logLevel': 'fatal', "theme":'dark', 'startOnLoad': true } }%%
const jsonInitConfigRegex = /%%\{\s*(init|initialize)\s*:\s*(.*?)\}%%/s;
const jsonInitText = (jsonInitConfigRegex.exec(source) || [])[2];
if (!jsonInitText) return null;
try {
const processed = jsonInitText.trim().replace(/'/g, '"');
return JSON.parse(processed);
} catch {
console.error('invalid or unsupported mermaid init JSON config', jsonInitText);
}
return null;
}
function configValueIsElk(layoutOrRenderer: string | undefined) {
if (typeof layoutOrRenderer !== 'string') return false;
return layoutOrRenderer === 'elk' || layoutOrRenderer.startsWith('elk.');
}
function configContainsElk(config: MermaidConfig | null) {
if (!config) return false;
// Check the layout from the following properties:
// * config.layout
// * config.{any-diagram-config}.defaultRenderer
// Although only a few diagram types like "flowchart" support "defaultRenderer",
// as long as there is no side effect, here do a general check for all properties of "config", for ease of maintenance
return configValueIsElk(config.layout) || Object.values(config).some((diagCfg) => configValueIsElk(diagCfg?.defaultRenderer));
}
export function sourceNeedsElk(source: string) {
if (isSourceTooLarge(source)) return false;
const configYaml = parseYamlInitConfig(source), configJson = parseJsonInitConfig(source);
return configContainsElk(configYaml) || configContainsElk(configJson);
}
async function loadMermaid(needElkRender: boolean) {
const mermaidPromise = import(/* webpackChunkName: "mermaid" */'mermaid');
const elkPromise = needElkRender ? import(/* webpackChunkName: "mermaid-layout-elk" */'@mermaid-js/layout-elk') : null;
const results = await Promise.all([mermaidPromise, elkPromise]);
return {
mermaid: results[0].default,
elkLayouts: results[1]?.default,
};
}
let elkLayoutsRegistered = false;
export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> {
// .markup code.language-mermaid
queryElems(elMarkup, 'code.language-mermaid', async (el) => {
const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid');
const mermaidBlocks: Array<{source: string, parentContainer: HTMLElement}> = [];
const attrMermaidRendered = 'data-markup-mermaid-rendered';
let needElkRender = false;
for (const elCodeBlock of queryElems(elMarkup, 'code.language-mermaid')) {
const parentContainer = elCodeBlock.closest('pre')!; // it must exist, if no, there must be a bug
if (parentContainer.hasAttribute(attrMermaidRendered)) continue;
parentContainer.setAttribute(attrMermaidRendered, 'true');
mermaid.initialize({
startOnLoad: false,
theme: isDarkTheme() ? 'dark' : 'neutral',
securityLevel: 'strict',
suppressErrorRendering: true,
});
const source = elCodeBlock.textContent ?? '';
needElkRender = needElkRender || sourceNeedsElk(source);
mermaidBlocks.push({source, parentContainer});
}
if (!mermaidBlocks.length) return;
const pre = el.closest('pre');
if (!pre || pre.hasAttribute('data-render-done')) return;
const {mermaid, elkLayouts} = await loadMermaid(needElkRender);
if (elkLayouts && !elkLayoutsRegistered) {
mermaid.registerLayoutLoaders(elkLayouts);
elkLayoutsRegistered = true;
}
mermaid.initialize({
startOnLoad: false,
theme: isDarkTheme() ? 'dark' : 'neutral', // TODO: maybe it should use "darkMode" to adopt more user-specified theme instead of just "dark" or "neutral"
securityLevel: 'strict',
suppressErrorRendering: true,
});
const source = el.textContent;
if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) {
displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`));
return;
// mermaid is a globally shared instance, its document also says "Multiple calls to this function will be enqueued to run serially."
// so here we just simply render the mermaid blocks one by one, no need to do "Promise.all" concurrently
for (const block of mermaidBlocks) {
const {source, parentContainer} = block;
if (isSourceTooLarge(source)) {
displayError(parentContainer, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`));
continue;
}
try {
await mermaid.parse(source);
} catch (err) {
displayError(pre, err);
return;
}
try {
// can't use bindFunctions here because we can't cross the iframe boundary. This
// means js-based interactions won't work but they aren't intended to work either
const {svg} = await mermaid.render('mermaid', source);
// render the mermaid diagram to svg text, and parse it to a DOM node
const {svg: svgText, bindFunctions} = await mermaid.render('mermaid', source, parentContainer);
const svgDoc = parseDom(svgText, 'image/svg+xml');
const svgNode = (svgDoc.documentElement as unknown) as SVGSVGElement;
// create an iframe to sandbox the svg with styles, and set correct height by reading svg's viewBox height
const iframe = document.createElement('iframe');
iframe.classList.add('markup-content-iframe', 'tw-invisible');
iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg)}</body></html>`;
iframe.classList.add('markup-content-iframe', 'is-loading');
iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body></body></html>`;
const mermaidBlock = document.createElement('div');
mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden');
mermaidBlock.append(iframe);
const btn = makeCodeCopyButton();
btn.setAttribute('data-clipboard-text', source);
mermaidBlock.append(btn);
const updateIframeHeight = () => {
const body = iframe.contentWindow?.document?.body;
if (body) {
iframe.style.height = `${body.clientHeight}px`;
}
};
// although the "viewBox" is optional, mermaid's output should always have a correct viewBox with width and height
const iframeHeightFromViewBox = Math.ceil(svgNode.viewBox?.baseVal?.height ?? 0);
if (iframeHeightFromViewBox) iframe.style.height = `${iframeHeightFromViewBox}px`;
// the iframe will be fully reloaded if its DOM context is changed (e.g.: moved in the DOM tree).
// to avoid unnecessary reloading, we should insert the iframe to its final position only once.
iframe.addEventListener('load', () => {
pre.replaceWith(mermaidBlock);
mermaidBlock.classList.remove('tw-hidden');
updateIframeHeight();
setTimeout(() => { // avoid flash of iframe background
mermaidBlock.classList.remove('is-loading');
iframe.classList.remove('tw-invisible');
}, 0);
// same origin, so we can operate "iframe body" and all elements directly
const iframeBody = iframe.contentDocument!.body;
iframeBody.append(svgNode);
bindFunctions?.(iframeBody); // follow "mermaid.render" doc, attach event handlers to the svg's container
// update height when element's visibility state changes, for example when the diagram is inside
// a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
// would initially set a incorrect height and the correct height is set during this callback.
(new IntersectionObserver(() => {
updateIframeHeight();
}, {root: document.documentElement})).observe(iframe);
// according to mermaid, the viewBox height should always exist, here just a fallback for unknown cases.
// and keep in mind: clientHeight can be 0 if the element is hidden (display: none).
if (!iframeHeightFromViewBox && iframeBody.clientHeight) iframe.style.height = `${iframeBody.clientHeight}px`;
iframe.classList.remove('is-loading');
});
document.body.append(mermaidBlock);
const container = createElementFromAttrs('div', {class: 'mermaid-block'}, iframe, makeCodeCopyButton({'data-clipboard-text': source}));
parentContainer.replaceWith(container);
} catch (err) {
displayError(pre, err);
displayError(parentContainer, err);
}
});
}
}
+1
View File
@@ -225,6 +225,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');
+16
View File
@@ -4,3 +4,19 @@
export function isInFrontendUnitTest() {
return import.meta.env.TEST === 'true';
}
/** strip common indentation from a string and trim it */
export function dedent(str: string) {
const match = str.match(/^[ \t]*(?=\S)/gm);
if (!match) return str;
let minIndent = Number.POSITIVE_INFINITY;
for (const indent of match) {
minIndent = Math.min(minIndent, indent.length);
}
if (minIndent === 0 || minIndent === Number.POSITIVE_INFINITY) {
return str;
}
return str.replace(new RegExp(`^[ \\t]{${minIndent}}`, 'gm'), '').trim();
}
@@ -22,6 +22,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
const div = document.createElement('div');
div.tabIndex = -1; // for initial focus, programmatic focus only
div.addEventListener('keydown', (e) => {
if (e.isComposing) return;
if (e.key === 'Tab') {
const items = this.tippyContent.querySelectorAll<HTMLElement>('[role="menuitem"]');
if (e.shiftKey) {