diff options
Diffstat (limited to 'web_src/js')
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; +} |