aboutsummaryrefslogtreecommitdiffstats
path: root/web_src/js
diff options
context:
space:
mode:
Diffstat (limited to 'web_src/js')
-rw-r--r--web_src/js/components/PullRequestMergeForm.vue2
-rw-r--r--web_src/js/features/captcha.ts13
-rw-r--r--web_src/js/features/comp/EditorMarkdown.test.ts170
-rw-r--r--web_src/js/features/comp/EditorMarkdown.ts141
-rw-r--r--web_src/js/features/comp/TextExpander.ts73
-rw-r--r--web_src/js/features/issue.ts23
-rw-r--r--web_src/js/features/repo-code.ts2
-rw-r--r--web_src/js/features/repo-common.test.ts7
-rw-r--r--web_src/js/features/repo-common.ts11
-rw-r--r--web_src/js/features/repo-diff.ts5
-rw-r--r--web_src/js/features/repo-editor.ts8
-rw-r--r--web_src/js/features/repo-issue-sidebar.md5
-rw-r--r--web_src/js/features/repo-issue.ts22
-rw-r--r--web_src/js/features/repo-template.ts18
-rw-r--r--web_src/js/features/stopwatch.ts27
-rw-r--r--web_src/js/features/user-auth-webauthn.ts11
-rw-r--r--web_src/js/globals.d.ts1
-rw-r--r--web_src/js/modules/tippy.ts5
-rw-r--r--web_src/js/svg.ts2
-rw-r--r--web_src/js/types.ts5
-rw-r--r--web_src/js/utils.test.ts14
-rw-r--r--web_src/js/utils.ts12
-rw-r--r--web_src/js/webcomponents/polyfill.test.ts7
-rw-r--r--web_src/js/webcomponents/polyfills.ts16
24 files changed, 485 insertions, 115 deletions
diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue
index e8bcee70db..bafeec6c97 100644
--- a/web_src/js/components/PullRequestMergeForm.vue
+++ b/web_src/js/components/PullRequestMergeForm.vue
@@ -147,7 +147,7 @@ function clearMergeMessage() {
</template>
</span>
</button>
- <div class="ui dropdown icon button" @click.stop="showMergeStyleMenu = !showMergeStyleMenu" v-if="mergeStyleAllowedCount>1">
+ <div class="ui dropdown icon button" @click.stop="showMergeStyleMenu = !showMergeStyleMenu">
<svg-icon name="octicon-triangle-down" :size="14"/>
<div class="menu" :class="{'show':showMergeStyleMenu}">
<template v-for="msd in mergeForm.mergeStyles">
diff --git a/web_src/js/features/captcha.ts b/web_src/js/features/captcha.ts
index 69b4aa6852..df234d0e5c 100644
--- a/web_src/js/features/captcha.ts
+++ b/web_src/js/features/captcha.ts
@@ -34,13 +34,18 @@ export async function initCaptcha() {
break;
}
case 'm-captcha': {
- const {default: mCaptcha} = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
- // @ts-expect-error
+ const mCaptcha = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
+
+ // FIXME: the mCaptcha code is not right, it's a miracle that the wrong code could run
+ // * the "vanilla-glue" has some problems with es6 module.
+ // * the INPUT_NAME is a "const", it should not be changed.
+ // * the "mCaptcha.default" is actually the "Widget".
+
+ // @ts-expect-error TS2540: Cannot assign to 'INPUT_NAME' because it is a read-only property.
mCaptcha.INPUT_NAME = 'm-captcha-response';
const instanceURL = captchaEl.getAttribute('data-instance-url');
- // @ts-expect-error
- mCaptcha.default({
+ new mCaptcha.default({
siteKey: {
instanceUrl: new URL(instanceURL),
key: siteKey,
diff --git a/web_src/js/features/comp/EditorMarkdown.test.ts b/web_src/js/features/comp/EditorMarkdown.test.ts
index 7b4b44e83c..9f34d77348 100644
--- a/web_src/js/features/comp/EditorMarkdown.test.ts
+++ b/web_src/js/features/comp/EditorMarkdown.test.ts
@@ -1,4 +1,166 @@
-import {initTextareaMarkdown} from './EditorMarkdown.ts';
+import {initTextareaMarkdown, markdownHandleIndention, textareaSplitLines} from './EditorMarkdown.ts';
+
+test('textareaSplitLines', () => {
+ let ret = textareaSplitLines('a\nbc\nd', 0);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 0});
+
+ ret = textareaSplitLines('a\nbc\nd', 1);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 1});
+
+ ret = textareaSplitLines('a\nbc\nd', 2);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 0});
+
+ ret = textareaSplitLines('a\nbc\nd', 3);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 1});
+
+ ret = textareaSplitLines('a\nbc\nd', 4);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 2});
+
+ ret = textareaSplitLines('a\nbc\nd', 5);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 0});
+
+ ret = textareaSplitLines('a\nbc\nd', 6);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 1});
+});
+
+test('markdownHandleIndention', () => {
+ const testInput = (input: string, expected?: string) => {
+ const inputPos = input.indexOf('|');
+ input = input.replace('|', '');
+ const ret = markdownHandleIndention({value: input, selStart: inputPos, selEnd: inputPos});
+ if (expected === null) {
+ expect(ret).toEqual({handled: false});
+ } else {
+ const expectedPos = expected.indexOf('|');
+ expected = expected.replace('|', '');
+ expect(ret).toEqual({
+ handled: true,
+ valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos},
+ });
+ }
+ };
+
+ testInput(`
+ a|b
+`, `
+ a
+ |b
+`);
+
+ testInput(`
+1. a
+2. |
+`, `
+1. a
+|
+`);
+
+ testInput(`
+|1. a
+`, null); // let browser handle it
+
+ testInput(`
+1. a
+1. b|c
+`, `
+1. a
+2. b
+3. |c
+`);
+
+ testInput(`
+2. a
+2. b|
+
+1. x
+1. y
+`, `
+1. a
+2. b
+3. |
+
+1. x
+1. y
+`);
+
+ testInput(`
+2. a
+2. b
+
+1. x|
+1. y
+`, `
+2. a
+2. b
+
+1. x
+2. |
+3. y
+`);
+
+ testInput(`
+1. a
+2. b|
+3. c
+`, `
+1. a
+2. b
+3. |
+4. c
+`);
+
+ testInput(`
+1. a
+ 1. b
+ 2. b
+ 3. b
+ 4. b
+1. c|
+`, `
+1. a
+ 1. b
+ 2. b
+ 3. b
+ 4. b
+2. c
+3. |
+`);
+
+ testInput(`
+1. a
+2. a
+3. a
+4. a
+5. a
+6. a
+7. a
+8. a
+9. b|c
+`, `
+1. a
+2. a
+3. a
+4. a
+5. a
+6. a
+7. a
+8. a
+9. b
+10. |c
+`);
+
+ // this is a special case, it's difficult to re-format the parent level at the moment, so leave it to the future
+ testInput(`
+1. a
+ 2. b|
+3. c
+`, `
+1. a
+ 1. b
+ 2. |
+3. c
+`);
+});
test('EditorMarkdown', () => {
const textarea = document.createElement('textarea');
@@ -32,10 +194,10 @@ test('EditorMarkdown', () => {
testInput({value: '1. \n2. ', pos: 3}, {value: '\n2. ', pos: 0});
testInput('- x', '- x\n- ');
- testInput('1. foo', '1. foo\n1. ');
- testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n1. \n2. b\n3. c', pos: 8});
+ testInput('1. foo', '1. foo\n2. ');
+ testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n2. \n3. b\n4. c', pos: 8});
testInput('- [ ]', '- [ ]\n- ');
testInput('- [ ] foo', '- [ ] foo\n- [ ] ');
testInput('* [x] foo', '* [x] foo\n* [ ] ');
- testInput('1. [x] foo', '1. [x] foo\n1. [ ] ');
+ testInput('1. [x] foo', '1. [x] foo\n2. [ ] ');
});
diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts
index 5e2ef121f5..08306531f1 100644
--- a/web_src/js/features/comp/EditorMarkdown.ts
+++ b/web_src/js/features/comp/EditorMarkdown.ts
@@ -14,7 +14,13 @@ export function textareaInsertText(textarea, value) {
triggerEditorContentChanged(textarea);
}
-function handleIndentSelection(textarea, e) {
+type TextareaValueSelection = {
+ value: string;
+ selStart: number;
+ selEnd: number;
+}
+
+function handleIndentSelection(textarea: HTMLTextAreaElement, e) {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd === selStart) return; // do not process when no selection
@@ -56,58 +62,135 @@ function handleIndentSelection(textarea, e) {
triggerEditorContentChanged(textarea);
}
-function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
- const selStart = textarea.selectionStart;
- const selEnd = textarea.selectionEnd;
- if (selEnd !== selStart) return; // do not process when there is a selection
+type MarkdownHandleIndentionResult = {
+ handled: boolean;
+ valueSelection?: TextareaValueSelection;
+}
+
+type TextLinesBuffer = {
+ lines: string[];
+ lengthBeforePosLine: number;
+ posLineIndex: number;
+ inlinePos: number
+}
+
+export function textareaSplitLines(value: string, pos: number): TextLinesBuffer {
+ const lines = value.split('\n');
+ let lengthBeforePosLine = 0, inlinePos = 0, posLineIndex = 0;
+ for (; posLineIndex < lines.length; posLineIndex++) {
+ const lineLength = lines[posLineIndex].length + 1;
+ if (lengthBeforePosLine + lineLength > pos) {
+ inlinePos = pos - lengthBeforePosLine;
+ break;
+ }
+ lengthBeforePosLine += lineLength;
+ }
+ return {lines, lengthBeforePosLine, posLineIndex, inlinePos};
+}
+
+function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: string) {
+ const reDeeperIndention = new RegExp(`^${indention}\\s+`);
+ const reSameLevel = new RegExp(`^${indention}([0-9]+)\\.`);
+ let firstLineIdx: number;
+ for (firstLineIdx = linesBuf.posLineIndex - 1; firstLineIdx >= 0; firstLineIdx--) {
+ const line = linesBuf.lines[firstLineIdx];
+ if (!reDeeperIndention.test(line) && !reSameLevel.test(line)) break;
+ }
+ firstLineIdx++;
+ let num = 1;
+ for (let i = firstLineIdx; i < linesBuf.lines.length; i++) {
+ const oldLine = linesBuf.lines[i];
+ const sameLevel = reSameLevel.test(oldLine);
+ if (!sameLevel && !reDeeperIndention.test(oldLine)) break;
+ if (sameLevel) {
+ const newLine = `${indention}${num}.${oldLine.replace(reSameLevel, '')}`;
+ linesBuf.lines[i] = newLine;
+ num++;
+ if (linesBuf.posLineIndex === i) {
+ // need to correct the cursor inline position if the line length changes
+ linesBuf.inlinePos += newLine.length - oldLine.length;
+ linesBuf.inlinePos = Math.max(0, linesBuf.inlinePos);
+ linesBuf.inlinePos = Math.min(newLine.length, linesBuf.inlinePos);
+ }
+ }
+ }
+ recalculateLengthBeforeLine(linesBuf);
+}
+
+function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) {
+ linesBuf.lengthBeforePosLine = 0;
+ for (let i = 0; i < linesBuf.posLineIndex; i++) {
+ linesBuf.lengthBeforePosLine += linesBuf.lines[i].length + 1;
+ }
+}
- const value = textarea.value;
+export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHandleIndentionResult {
+ const unhandled: MarkdownHandleIndentionResult = {handled: false};
+ if (tvs.selEnd !== tvs.selStart) return unhandled; // do not process when there is a selection
- // find the current line
- // * if selStart is 0, lastIndexOf(..., -1) is the same as lastIndexOf(..., 0)
- // * if lastIndexOf reruns -1, lineStart is 0 and it is still correct.
- const lineStart = value.lastIndexOf('\n', selStart - 1) + 1;
- let lineEnd = value.indexOf('\n', selStart);
- lineEnd = lineEnd < 0 ? value.length : lineEnd;
- let line = value.slice(lineStart, lineEnd);
- if (!line) return; // if the line is empty, do nothing, let the browser handle it
+ const linesBuf = textareaSplitLines(tvs.value, tvs.selStart);
+ const line = linesBuf.lines[linesBuf.posLineIndex] ?? '';
+ if (!line) return unhandled; // if the line is empty, do nothing, let the browser handle it
// parse the indention
- const indention = /^\s*/.exec(line)[0];
- line = line.slice(indention.length);
+ let lineContent = line;
+ const indention = /^\s*/.exec(lineContent)[0];
+ lineContent = lineContent.slice(indention.length);
+ if (linesBuf.inlinePos <= indention.length) return unhandled; // if cursor is at the indention, do nothing, let the browser handle it
// parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists
// there must be a space after the prefix because none of "1.foo" / "-foo" is a list item
- const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(line);
+ const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(lineContent);
let prefix = '';
if (prefixMatch) {
prefix = prefixMatch[0];
- if (lineStart + prefix.length > selStart) prefix = ''; // do not add new line if cursor is at prefix
+ if (prefix.length > linesBuf.inlinePos) prefix = ''; // do not add new line if cursor is at prefix
}
- line = line.slice(prefix.length);
- if (!indention && !prefix) return; // if no indention and no prefix, do nothing, let the browser handle it
+ lineContent = lineContent.slice(prefix.length);
+ if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it
- e.preventDefault();
- if (!line) {
+ if (!lineContent) {
// clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list
- textarea.value = value.slice(0, lineStart) + value.slice(lineEnd);
- textarea.setSelectionRange(selStart - prefix.length, selStart - prefix.length);
+ linesBuf.lines[linesBuf.posLineIndex] = '';
+ linesBuf.inlinePos = 0;
} else {
- // start a new line with the same indention and prefix
+ // start a new line with the same indention
let newPrefix = prefix;
- // a simple approach, otherwise it needs to parse the lines after the current line
if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`;
newPrefix = newPrefix.replace('[x]', '[ ]');
- const newLine = `\n${indention}${newPrefix}`;
- textarea.value = value.slice(0, selStart) + newLine + value.slice(selEnd);
- textarea.setSelectionRange(selStart + newLine.length, selStart + newLine.length);
+
+ const inlinePos = linesBuf.inlinePos;
+ linesBuf.lines[linesBuf.posLineIndex] = line.substring(0, inlinePos);
+ const newLineLeft = `${indention}${newPrefix}`;
+ const newLine = `${newLineLeft}${line.substring(inlinePos)}`;
+ linesBuf.lines.splice(linesBuf.posLineIndex + 1, 0, newLine);
+ linesBuf.posLineIndex++;
+ linesBuf.inlinePos = newLineLeft.length;
+ recalculateLengthBeforeLine(linesBuf);
}
+
+ markdownReformatListNumbers(linesBuf, indention);
+ const newPos = linesBuf.lengthBeforePosLine + linesBuf.inlinePos;
+ return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
+}
+
+function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
+ const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
+ if (!ret.handled) return;
+ e.preventDefault();
+ textarea.value = ret.valueSelection.value;
+ textarea.setSelectionRange(ret.valueSelection.selStart, ret.valueSelection.selEnd);
triggerEditorContentChanged(textarea);
}
+function isTextExpanderShown(textarea: HTMLElement): boolean {
+ return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
+}
+
export function initTextareaMarkdown(textarea) {
textarea.addEventListener('keydown', (e) => {
+ if (isTextExpanderShown(textarea)) return;
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Tab/Shift-Tab to indent/unindent the selected lines
handleIndentSelection(textarea, e);
diff --git a/web_src/js/features/comp/TextExpander.ts b/web_src/js/features/comp/TextExpander.ts
index e0c4abed75..1e6d46f977 100644
--- a/web_src/js/features/comp/TextExpander.ts
+++ b/web_src/js/features/comp/TextExpander.ts
@@ -1,18 +1,37 @@
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
import {emojiString} from '../emoji.ts';
import {svg} from '../../svg.ts';
-import {parseIssueHref, parseIssueNewHref} from '../../utils.ts';
+import {parseIssueHref, parseRepoOwnerPathInfo} from '../../utils.ts';
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
import {getIssueColor, getIssueIcon} from '../issue.ts';
import {debounce} from 'perfect-debounce';
+import type TextExpanderElement from '@github/text-expander-element';
-const debouncedSuggestIssues = debounce((key: string, text: string) => new Promise<{matched:boolean; fragment?: HTMLElement}>(async (resolve) => {
- let issuePathInfo = parseIssueHref(window.location.href);
- if (!issuePathInfo.ownerName) issuePathInfo = parseIssueNewHref(window.location.href);
- if (!issuePathInfo.ownerName) return resolve({matched: false});
+type TextExpanderProvideResult = {
+ matched: boolean,
+ fragment?: HTMLElement,
+}
+
+type TextExpanderChangeEvent = Event & {
+ detail?: {
+ key: string,
+ text: string,
+ provide: (result: TextExpanderProvideResult | Promise<TextExpanderProvideResult>) => void,
+ }
+}
+
+async function fetchIssueSuggestions(key: string, text: string): Promise<TextExpanderProvideResult> {
+ const issuePathInfo = parseIssueHref(window.location.href);
+ if (!issuePathInfo.ownerName) {
+ const repoOwnerPathInfo = parseRepoOwnerPathInfo(window.location.pathname);
+ issuePathInfo.ownerName = repoOwnerPathInfo.ownerName;
+ issuePathInfo.repoName = repoOwnerPathInfo.repoName;
+ // then no issuePathInfo.indexString here, it is only used to exclude the current issue when "matchIssue"
+ }
+ if (!issuePathInfo.ownerName) return {matched: false};
const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text);
- if (!matches.length) return resolve({matched: false});
+ if (!matches.length) return {matched: false};
const ul = createElementFromAttrs('ul', {class: 'suggestions'});
for (const issue of matches) {
@@ -24,11 +43,40 @@ const debouncedSuggestIssues = debounce((key: string, text: string) => new Promi
);
ul.append(li);
}
- resolve({matched: true, fragment: ul});
-}), 100);
+ return {matched: true, fragment: ul};
+}
-export function initTextExpander(expander) {
- expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
+export function initTextExpander(expander: TextExpanderElement) {
+ if (!expander) return;
+
+ const textarea = expander.querySelector<HTMLTextAreaElement>('textarea');
+
+ // help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line
+ const shouldShowIssueSuggestions = () => {
+ const posVal = textarea.value.substring(0, textarea.selectionStart);
+ const lineStart = posVal.lastIndexOf('\n');
+ const keyStart = posVal.lastIndexOf('#');
+ return keyStart > lineStart;
+ };
+
+ const debouncedIssueSuggestions = debounce(async (key: string, text: string): Promise<TextExpanderProvideResult> => {
+ // https://github.com/github/text-expander-element/issues/71
+ // Upstream bug: when using "multiword+promise", TextExpander will get wrong "key" position.
+ // To reproduce, comment out the "shouldShowIssueSuggestions" check, use the "await sleep" below,
+ // then use content "close #20\nclose #20\nclose #20" (3 lines), keep changing the last line `#20` part from the end (including removing the `#`)
+ // There will be a JS error: Uncaught (in promise) IndexSizeError: Failed to execute 'setStart' on 'Range': The offset 28 is larger than the node's length (27).
+
+ // check the input before the request, to avoid emitting empty query to backend (still related to the upstream bug)
+ if (!shouldShowIssueSuggestions()) return {matched: false};
+ // await sleep(Math.random() * 1000); // help to reproduce the text-expander bug
+ const ret = await fetchIssueSuggestions(key, text);
+ // check the input again to avoid text-expander using incorrect position (upstream bug)
+ if (!shouldShowIssueSuggestions()) return {matched: false};
+ return ret;
+ }, 300); // to match onInputDebounce delay
+
+ expander.addEventListener('text-expander-change', (e: TextExpanderChangeEvent) => {
+ const {key, text, provide} = e.detail;
if (key === ':') {
const matches = matchEmoji(text);
if (!matches.length) return provide({matched: false});
@@ -76,10 +124,11 @@ export function initTextExpander(expander) {
provide({matched: true, fragment: ul});
} else if (key === '#') {
- provide(debouncedSuggestIssues(key, text));
+ provide(debouncedIssueSuggestions(key, text));
}
});
- expander?.addEventListener('text-expander-value', ({detail}) => {
+
+ expander.addEventListener('text-expander-value', ({detail}: Record<string, any>) => {
if (detail?.item) {
// add a space after @mentions and #issue as it's likely the user wants one
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
diff --git a/web_src/js/features/issue.ts b/web_src/js/features/issue.ts
index a56015a2a2..911cf713d9 100644
--- a/web_src/js/features/issue.ts
+++ b/web_src/js/features/issue.ts
@@ -1,17 +1,21 @@
import type {Issue} from '../types.ts';
+// the getIssueIcon/getIssueColor logic should be kept the same as "templates/shared/issueicon.tmpl"
+
export function getIssueIcon(issue: Issue) {
if (issue.pull_request) {
if (issue.state === 'open') {
- if (issue.pull_request.draft === true) {
+ if (issue.pull_request.draft) {
return 'octicon-git-pull-request-draft'; // WIP PR
}
return 'octicon-git-pull-request'; // Open PR
- } else if (issue.pull_request.merged === true) {
+ } else if (issue.pull_request.merged) {
return 'octicon-git-merge'; // Merged PR
}
- return 'octicon-git-pull-request'; // Closed PR
- } else if (issue.state === 'open') {
+ return 'octicon-git-pull-request-closed'; // Closed PR
+ }
+
+ if (issue.state === 'open') {
return 'octicon-issue-opened'; // Open Issue
}
return 'octicon-issue-closed'; // Closed Issue
@@ -19,12 +23,17 @@ export function getIssueIcon(issue: Issue) {
export function getIssueColor(issue: Issue) {
if (issue.pull_request) {
- if (issue.pull_request.draft === true) {
- return 'grey'; // WIP PR
- } else if (issue.pull_request.merged === true) {
+ if (issue.state === 'open') {
+ if (issue.pull_request.draft) {
+ return 'grey'; // WIP PR
+ }
+ return 'green'; // Open PR
+ } else if (issue.pull_request.merged) {
return 'purple'; // Merged PR
}
+ return 'red'; // Closed PR
}
+
if (issue.state === 'open') {
return 'green'; // Open Issue
}
diff --git a/web_src/js/features/repo-code.ts b/web_src/js/features/repo-code.ts
index a8d6e8f97d..7b5b348db5 100644
--- a/web_src/js/features/repo-code.ts
+++ b/web_src/js/features/repo-code.ts
@@ -59,7 +59,7 @@ function selectRange($linesEls, $selectionEndEl, $selectionStartEls?) {
copyPermalink.setAttribute('data-url', link);
};
- if ($selectionStartEls) {
+ if ($selectionStartEls && $selectionStartEls[0]) {
let a = parseInt($selectionEndEl[0].getAttribute('rel').slice(1));
let b = parseInt($selectionStartEls[0].getAttribute('rel').slice(1));
let c;
diff --git a/web_src/js/features/repo-common.test.ts b/web_src/js/features/repo-common.test.ts
new file mode 100644
index 0000000000..009dfc86b1
--- /dev/null
+++ b/web_src/js/features/repo-common.test.ts
@@ -0,0 +1,7 @@
+import {substituteRepoOpenWithUrl} from './repo-common.ts';
+
+test('substituteRepoOpenWithUrl', () => {
+ // For example: "x-github-client://openRepo/https://github.com/go-gitea/gitea"
+ expect(substituteRepoOpenWithUrl('proto://a/{url}', 'https://gitea')).toEqual('proto://a/https://gitea');
+ expect(substituteRepoOpenWithUrl('proto://a?link={url}', 'https://gitea')).toEqual('proto://a?link=https%3A%2F%2Fgitea');
+});
diff --git a/web_src/js/features/repo-common.ts b/web_src/js/features/repo-common.ts
index 90860720e4..2fe8270793 100644
--- a/web_src/js/features/repo-common.ts
+++ b/web_src/js/features/repo-common.ts
@@ -42,6 +42,14 @@ export function initRepoActivityTopAuthorsChart() {
}
}
+export function substituteRepoOpenWithUrl(tmpl: string, url: string): string {
+ const pos = tmpl.indexOf('{url}');
+ if (pos === -1) return tmpl;
+ const posQuestionMark = tmpl.indexOf('?');
+ const needEncode = posQuestionMark >= 0 && posQuestionMark < pos;
+ return tmpl.replace('{url}', needEncode ? encodeURIComponent(url) : url);
+}
+
function initCloneSchemeUrlSelection(parent: Element) {
const elCloneUrlInput = parent.querySelector<HTMLInputElement>('.repo-clone-url');
@@ -70,7 +78,7 @@ function initCloneSchemeUrlSelection(parent: Element) {
}
}
for (const el of parent.querySelectorAll<HTMLAnchorElement>('.js-clone-url-editor')) {
- el.href = el.getAttribute('data-href-template').replace('{url}', encodeURIComponent(link));
+ el.href = substituteRepoOpenWithUrl(el.getAttribute('data-href-template'), link);
}
};
@@ -99,6 +107,7 @@ function initClonePanelButton(btn: HTMLButtonElement) {
placement: 'bottom-end',
interactive: true,
hideOnClick: true,
+ arrow: false,
});
}
diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts
index 58e0d88092..89bf7f4cdf 100644
--- a/web_src/js/features/repo-diff.ts
+++ b/web_src/js/features/repo-diff.ts
@@ -221,7 +221,10 @@ function initRepoDiffShowMore() {
if (!resp) {
return;
}
- $target.parent().replaceWith($(resp).find('#diff-file-boxes .diff-file-body .file-body').children());
+ const $respFileBody = $(resp).find('#diff-file-boxes .diff-file-body .file-body');
+ const respFileBodyChildren = Array.from($respFileBody.children());
+ $target.parent().replaceWith($respFileBody.children());
+ for (const el of respFileBodyChildren) window.htmx.process(el);
onShowMoreFiles();
} catch (error) {
console.error('Error:', error);
diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts
index 32d0b84f4c..239035b3fe 100644
--- a/web_src/js/features/repo-editor.ts
+++ b/web_src/js/features/repo-editor.ts
@@ -38,9 +38,6 @@ export function initRepoEditor() {
const dropzoneUpload = document.querySelector<HTMLElement>('.page-content.repository.editor.upload .dropzone');
if (dropzoneUpload) initDropzone(dropzoneUpload);
- const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area');
- if (!editArea) return;
-
for (const el of queryElems<HTMLInputElement>(document, '.js-quick-pull-choice-option')) {
el.addEventListener('input', () => {
if (el.value === 'commit-to-new-branch') {
@@ -55,6 +52,7 @@ export function initRepoEditor() {
}
const filenameInput = document.querySelector<HTMLInputElement>('#file-name');
+ if (!filenameInput) return;
function joinTreePath() {
const parts = [];
for (const el of document.querySelectorAll('.breadcrumb span.section')) {
@@ -144,6 +142,10 @@ export function initRepoEditor() {
}
});
+ // on the upload page, there is no editor(textarea)
+ const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area');
+ if (!editArea) return;
+
const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form');
initEditPreviewTab(elForm);
diff --git a/web_src/js/features/repo-issue-sidebar.md b/web_src/js/features/repo-issue-sidebar.md
index 6de013f1c2..e1ce0927e1 100644
--- a/web_src/js/features/repo-issue-sidebar.md
+++ b/web_src/js/features/repo-issue-sidebar.md
@@ -22,10 +22,13 @@ A sidebar combo (dropdown+list) is like this:
When the selected items change, the `combo-value` input will be updated.
If there is `data-update-url`, it also calls backend to attach/detach the changed items.
-Also, the changed items will be syncronized to the `ui list` items.
+Also, the changed items will be synchronized to the `ui list` items.
The items with the same data-scope only allow one selected at a time.
The dropdown selection could work in 2 modes:
* single: only one item could be selected, it updates immediately when the item is selected.
* multiple: multiple items could be selected, it defers the update until the dropdown is hidden.
+
+When using "scrolling menu", the items must be in the same level,
+otherwise keyboard (ArrowUp/ArrowDown/Enter) won't work.
diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts
index 7541039786..39eff21347 100644
--- a/web_src/js/features/repo-issue.ts
+++ b/web_src/js/features/repo-issue.ts
@@ -371,10 +371,6 @@ export async function handleReply(el) {
export function initRepoPullRequestReview() {
if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) {
- // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing
- if (window.history.scrollRestoration !== 'manual') {
- window.history.scrollRestoration = 'manual';
- }
const commentDiv = document.querySelector(window.location.hash);
if (commentDiv) {
// get the name of the parent id
@@ -382,14 +378,6 @@ export function initRepoPullRequestReview() {
if (groupID && groupID.startsWith('code-comments-')) {
const id = groupID.slice(14);
const ancestorDiffBox = commentDiv.closest('.diff-file-box');
- // on pages like conversation, there is no diff header
- const diffHeader = ancestorDiffBox?.querySelector('.diff-file-header');
-
- // offset is for scrolling
- let offset = 30;
- if (diffHeader) {
- offset += $('.diff-detail-box').outerHeight() + $(diffHeader).outerHeight();
- }
hideElem(`#show-outdated-${id}`);
showElem(`#code-comments-${id}, #code-preview-${id}, #hide-outdated-${id}`);
@@ -397,12 +385,12 @@ export function initRepoPullRequestReview() {
if (ancestorDiffBox?.getAttribute('data-folded') === 'true') {
setFileFolding(ancestorDiffBox, ancestorDiffBox.querySelector('.fold-file'), false);
}
-
- window.scrollTo({
- top: $(commentDiv).offset().top - offset,
- behavior: 'instant',
- });
}
+
+ // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing
+ if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual';
+ // wait for a while because some elements (eg: image, editor, etc.) may change the viewport's height.
+ setTimeout(() => commentDiv.scrollIntoView({block: 'start'}), 100);
}
}
diff --git a/web_src/js/features/repo-template.ts b/web_src/js/features/repo-template.ts
index fbd7b656ed..f3d79eba80 100644
--- a/web_src/js/features/repo-template.ts
+++ b/web_src/js/features/repo-template.ts
@@ -1,11 +1,13 @@
import $ from 'jquery';
import {htmlEscape} from 'escape-goat';
-import {hideElem, showElem} from '../utils/dom.ts';
+import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts';
const {appSubUrl} = window.config;
export function initRepoTemplateSearch() {
const $repoTemplate = $('#repo_template');
+ if (!$repoTemplate.length) return; // make sure the current page is "new repo" page
+
const checkTemplate = function () {
const $templateUnits = $('#template_units');
const $nonTemplate = $('#non_template');
@@ -21,6 +23,20 @@ export function initRepoTemplateSearch() {
checkTemplate();
const changeOwner = function () {
+ const elUid = document.querySelector<HTMLInputElement>('#uid');
+ const elForm = elUid.closest('form');
+ const elSubmitButton = querySingleVisibleElem<HTMLInputElement>(elForm, '.ui.primary.button');
+ const elCreateRepoErrorMessage = elForm.querySelector('#create-repo-error-message');
+ const elOwnerItem = document.querySelector(`.ui.selection.owner.dropdown .menu > .item[data-value="${CSS.escape(elUid.value)}"]`);
+ hideElem(elCreateRepoErrorMessage);
+ elSubmitButton.disabled = false;
+ if (elOwnerItem) {
+ elCreateRepoErrorMessage.textContent = elOwnerItem.getAttribute('data-create-repo-disallowed-prompt') ?? '';
+ const hasError = Boolean(elCreateRepoErrorMessage.textContent);
+ toggleElem(elCreateRepoErrorMessage, hasError);
+ elSubmitButton.disabled = hasError;
+ }
+
$('#repo_template_search')
.dropdown({
apiSettings: {
diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts
index af52be4e24..46168b2cd7 100644
--- a/web_src/js/features/stopwatch.ts
+++ b/web_src/js/features/stopwatch.ts
@@ -1,6 +1,6 @@
import {createTippy} from '../modules/tippy.ts';
import {GET} from '../modules/fetch.ts';
-import {hideElem, showElem} from '../utils/dom.ts';
+import {hideElem, queryElems, showElem} from '../utils/dom.ts';
import {logoutFromWorker} from '../modules/worker.ts';
const {appSubUrl, notificationSettings, enableTimeTracking, assetVersionEncoded} = window.config;
@@ -144,23 +144,10 @@ function updateStopwatchData(data) {
return Boolean(data.length);
}
-// TODO: This flickers on page load, we could avoid this by making a custom
-// element to render time periods. Feeding a datetime in backend does not work
-// when time zone between server and client differs.
-function updateStopwatchTime(seconds) {
- if (!Number.isFinite(seconds)) return;
- const datetime = (new Date(Date.now() - seconds * 1000)).toISOString();
- for (const parent of document.querySelectorAll('.header-stopwatch-dot')) {
- const existing = parent.querySelector(':scope > relative-time');
- if (existing) {
- existing.setAttribute('datetime', datetime);
- } else {
- const el = document.createElement('relative-time');
- el.setAttribute('format', 'micro');
- el.setAttribute('datetime', datetime);
- el.setAttribute('lang', 'en-US');
- el.setAttribute('title', ''); // make <relative-time> show no title and therefor no tooltip
- parent.append(el);
- }
- }
+// TODO: This flickers on page load, we could avoid this by making a custom element to render time periods.
+function updateStopwatchTime(seconds: number) {
+ const hours = seconds / 3600 || 0;
+ const minutes = seconds / 60 || 0;
+ const timeText = hours >= 1 ? `${Math.round(hours)}h` : `${Math.round(minutes)}m`;
+ queryElems(document, '.header-stopwatch-dot', (el) => el.textContent = timeText);
}
diff --git a/web_src/js/features/user-auth-webauthn.ts b/web_src/js/features/user-auth-webauthn.ts
index 70516c280d..743b39a11e 100644
--- a/web_src/js/features/user-auth-webauthn.ts
+++ b/web_src/js/features/user-auth-webauthn.ts
@@ -1,5 +1,5 @@
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
-import {showElem} from '../utils/dom.ts';
+import {hideElem, showElem} from '../utils/dom.ts';
import {GET, POST} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
@@ -11,6 +11,15 @@ export async function initUserAuthWebAuthn() {
return;
}
+ if (window.location.protocol === 'http:') {
+ // webauthn is only supported on secure contexts
+ const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
+ if (!isLocalhost) {
+ hideElem(elSignInPasskeyBtn);
+ return;
+ }
+ }
+
if (!detectWebAuthnSupport()) {
return;
}
diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts
index a5ec29a83f..bddabd54ee 100644
--- a/web_src/js/globals.d.ts
+++ b/web_src/js/globals.d.ts
@@ -63,6 +63,7 @@ interface Window {
jQuery: typeof import('@types/jquery'),
htmx: Omit<typeof import('htmx.org/dist/htmx.esm.js').default, 'config'> & {
config?: Writable<typeof import('htmx.org').default.config>,
+ process?: (elt: Element | string) => void,
},
ui?: any,
_globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & {
diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts
index 4e7f1ac093..6eca6d9a86 100644
--- a/web_src/js/modules/tippy.ts
+++ b/web_src/js/modules/tippy.ts
@@ -42,16 +42,17 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
visibleInstances.add(instance);
return onShow?.(instance);
},
- arrow: arrow || (theme === 'bare' ? false : arrowSvg),
+ arrow: arrow ?? (theme === 'bare' ? false : arrowSvg),
// HTML role attribute, ideally the default role would be "popover" but it does not exist
role: role || 'menu',
// CSS theme, either "default", "tooltip", "menu", "box-with-header" or "bare"
theme: theme || role || 'default',
+ offset: [0, arrow ? 10 : 6],
plugins: [followCursor],
...other,
} satisfies Partial<Props>);
- if (role === 'menu') {
+ if (instance.props.role === 'menu') {
target.setAttribute('aria-haspopup', 'true');
}
diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts
index 90b12fa87d..8b4a2df336 100644
--- a/web_src/js/svg.ts
+++ b/web_src/js/svg.ts
@@ -34,6 +34,7 @@ import octiconGitBranch from '../../public/assets/img/svg/octicon-git-branch.svg
import octiconGitCommit from '../../public/assets/img/svg/octicon-git-commit.svg';
import octiconGitMerge from '../../public/assets/img/svg/octicon-git-merge.svg';
import octiconGitPullRequest from '../../public/assets/img/svg/octicon-git-pull-request.svg';
+import octiconGitPullRequestClosed from '../../public/assets/img/svg/octicon-git-pull-request-closed.svg';
import octiconGitPullRequestDraft from '../../public/assets/img/svg/octicon-git-pull-request-draft.svg';
import octiconGrabber from '../../public/assets/img/svg/octicon-grabber.svg';
import octiconHeading from '../../public/assets/img/svg/octicon-heading.svg';
@@ -110,6 +111,7 @@ const svgs = {
'octicon-git-commit': octiconGitCommit,
'octicon-git-merge': octiconGitMerge,
'octicon-git-pull-request': octiconGitPullRequest,
+ 'octicon-git-pull-request-closed': octiconGitPullRequestClosed,
'octicon-git-pull-request-draft': octiconGitPullRequestDraft,
'octicon-grabber': octiconGrabber,
'octicon-heading': octiconHeading,
diff --git a/web_src/js/types.ts b/web_src/js/types.ts
index e7c9ac0df4..e972994928 100644
--- a/web_src/js/types.ts
+++ b/web_src/js/types.ts
@@ -30,6 +30,11 @@ export type RequestOpts = {
data?: RequestData,
} & RequestInit;
+export type RepoOwnerPathInfo = {
+ ownerName: string,
+ repoName: string,
+}
+
export type IssuePathInfo = {
ownerName: string,
repoName: string,
diff --git a/web_src/js/utils.test.ts b/web_src/js/utils.test.ts
index ac9d4fab91..ccdbc2dbd7 100644
--- a/web_src/js/utils.test.ts
+++ b/web_src/js/utils.test.ts
@@ -1,7 +1,7 @@
import {
basename, extname, isObject, stripTags, parseIssueHref,
parseUrl, translateMonth, translateDay, blobToDataURI,
- toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseIssueNewHref,
+ toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
} from './utils.ts';
test('basename', () => {
@@ -45,10 +45,14 @@ test('parseIssueHref', () => {
expect(parseIssueHref('')).toEqual({ownerName: undefined, repoName: undefined, type: undefined, index: undefined});
});
-test('parseIssueNewHref', () => {
- expect(parseIssueNewHref('/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
- expect(parseIssueNewHref('/owner/repo/issues/new?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
- expect(parseIssueNewHref('/sub/owner/repo/issues/new#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
+test('parseRepoOwnerPathInfo', () => {
+ expect(parseRepoOwnerPathInfo('/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'});
+ expect(parseRepoOwnerPathInfo('/owner/repo/releases')).toEqual({ownerName: 'owner', repoName: 'repo'});
+ expect(parseRepoOwnerPathInfo('/other')).toEqual({});
+ window.config.appSubUrl = '/sub';
+ expect(parseRepoOwnerPathInfo('/sub/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'});
+ expect(parseRepoOwnerPathInfo('/sub/owner/repo/compare/feature/branch-1...fix/branch-2')).toEqual({ownerName: 'owner', repoName: 'repo'});
+ window.config.appSubUrl = '';
});
test('parseUrl', () => {
diff --git a/web_src/js/utils.ts b/web_src/js/utils.ts
index 997a4d1ff3..86bdd3790e 100644
--- a/web_src/js/utils.ts
+++ b/web_src/js/utils.ts
@@ -1,5 +1,5 @@
import {decode, encode} from 'uint8-to-base64';
-import type {IssuePageInfo, IssuePathInfo} from './types.ts';
+import type {IssuePageInfo, IssuePathInfo, RepoOwnerPathInfo} from './types.ts';
// transform /path/to/file.ext to file.ext
export function basename(path: string): string {
@@ -32,15 +32,17 @@ export function stripTags(text: string): string {
}
export function parseIssueHref(href: string): IssuePathInfo {
+ // FIXME: it should use pathname and trim the appSubUrl ahead
const path = (href || '').replace(/[#?].*$/, '');
const [_, ownerName, repoName, pathType, indexString] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || [];
return {ownerName, repoName, pathType, indexString};
}
-export function parseIssueNewHref(href: string): IssuePathInfo {
- const path = (href || '').replace(/[#?].*$/, '');
- const [_, ownerName, repoName, pathType, indexString] = /([^/]+)\/([^/]+)\/(issues|pulls)\/new/.exec(path) || [];
- return {ownerName, repoName, pathType, indexString};
+export function parseRepoOwnerPathInfo(pathname: string): RepoOwnerPathInfo {
+ const appSubUrl = window.config.appSubUrl;
+ if (appSubUrl && pathname.startsWith(appSubUrl)) pathname = pathname.substring(appSubUrl.length);
+ const [_, ownerName, repoName] = /([^/]+)\/([^/]+)/.exec(pathname) || [];
+ return {ownerName, repoName};
}
export function parseIssuePageInfo(): IssuePageInfo {
diff --git a/web_src/js/webcomponents/polyfill.test.ts b/web_src/js/webcomponents/polyfill.test.ts
new file mode 100644
index 0000000000..4fb4621547
--- /dev/null
+++ b/web_src/js/webcomponents/polyfill.test.ts
@@ -0,0 +1,7 @@
+import {weakRefClass} from './polyfills.ts';
+
+test('polyfillWeakRef', () => {
+ const WeakRef = weakRefClass();
+ const r = new WeakRef(123);
+ expect(r.deref()).toEqual(123);
+});
diff --git a/web_src/js/webcomponents/polyfills.ts b/web_src/js/webcomponents/polyfills.ts
index 4a84ee9562..9575324b5a 100644
--- a/web_src/js/webcomponents/polyfills.ts
+++ b/web_src/js/webcomponents/polyfills.ts
@@ -16,3 +16,19 @@ try {
return intlNumberFormat(locales, options);
};
}
+
+export function weakRefClass() {
+ const weakMap = new WeakMap();
+ return class {
+ constructor(target: any) {
+ weakMap.set(this, target);
+ }
+ deref() {
+ return weakMap.get(this);
+ }
+ };
+}
+
+if (!window.WeakRef) {
+ window.WeakRef = weakRefClass() as any;
+}