refactor: migrate remaining Vue components to <script setup> (#38752)

Migrates the last four Options API components, so all 20 `.vue` files
now use `<script setup>`, and disables the Vue Options API runtime. This
will enable optimizations like Vue Vapor mode in the future.

Bug fixes done while migrating and testing:

- Branch selector: arrowing onto "Create branch …" threw a `TypeError`,
and Enter on it did nothing
- Dashboard: the search term was not escaped, so `&` injected query
parameters and `#` truncated the request
- Dashboard: an unknown `repo-search-filter` threw, and names like
`constructor` leaked `Object.prototype` members into the request
- Dashboard: an unknown archived/private filter rendered `function
Object() { [native code] }` as a checkbox tooltip
- Dashboard: removed a dropdown init that always ran against a
not-yet-rendered element
- Branch selector: the `document.body` click listener was never removed
on unmount
- Contributors: the chart plugin relied on an undeclared chart.js field
- Contributors: `contributorsStats` was mutated behind a `shallowRef`,
so future readers would not update
- Removed template attributes and pageData keys that no component had
read for years

---------

Signed-off-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-08-06 07:45:31 +02:00
committed by GitHub
parent d8c3a1afda
commit c6836d0abb
10 changed files with 1012 additions and 1049 deletions
+194 -196
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {defineComponent} from 'vue';
<script lang="ts" setup>
import {computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {generateElemId} from '../utils/dom.ts';
@@ -20,205 +20,203 @@ type CommitListResult = {
locale: Record<string, string>,
}
export default defineComponent({
components: {SvgIcon},
data: () => {
const el = document.querySelector('#diff-commit-select')!;
return {
menuVisible: false,
isLoading: false,
queryParams: el.getAttribute('data-queryparams'),
issueLink: el.getAttribute('data-issuelink'),
locale: {
filter_changes_by_commit: el.getAttribute('data-filter_changes_by_commit'),
} as Record<string, string>,
mergeBase: el.getAttribute('data-merge-base'),
commits: [] as Array<Commit>,
hoverActivated: false,
lastReviewCommitSha: '' as string | null,
uniqueIdMenu: generateElemId('diff-commit-selector-menu-'),
uniqueIdShowAll: generateElemId('diff-commit-selector-show-all-'),
};
},
computed: {
commitsSinceLastReview() {
if (this.lastReviewCommitSha) {
return this.commits.length - this.commits.findIndex((x) => x.id === this.lastReviewCommitSha) - 1;
}
return 0;
},
},
mounted() {
document.body.addEventListener('click', this.onBodyClick);
this.$el.addEventListener('keydown', this.onKeyDown);
this.$el.addEventListener('keyup', this.onKeyUp);
},
unmounted() {
document.body.removeEventListener('click', this.onBodyClick);
this.$el.removeEventListener('keydown', this.onKeyDown);
this.$el.removeEventListener('keyup', this.onKeyUp);
},
methods: {
onBodyClick(event: MouseEvent) {
// close this menu on click outside of this element when the dropdown is currently visible opened
if (this.$el.contains(event.target)) return;
if (this.menuVisible) {
this.toggleMenu();
}
},
onKeyDown(event: KeyboardEvent) {
if (!this.menuVisible) return;
const item = document.activeElement as HTMLElement;
if (!this.$el.contains(item)) return;
switch (event.key) {
case 'ArrowDown': // select next element
event.preventDefault();
this.focusElem(item.nextElementSibling as HTMLElement, item);
break;
case 'ArrowUp': // select previous element
event.preventDefault();
this.focusElem(item.previousElementSibling as HTMLElement, item);
break;
case 'Escape': // close menu
event.preventDefault();
item.tabIndex = -1;
this.toggleMenu();
break;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const item = document.activeElement; // try to highlight the selected commits
const commitIdx = item?.matches('.item') ? item.getAttribute('data-commit-idx') : null;
if (commitIdx) this.highlight(this.commits[Number(commitIdx)]);
}
},
onKeyUp(event: KeyboardEvent) {
if (!this.menuVisible) return;
const item = document.activeElement;
if (!this.$el.contains(item)) return;
if (event.key === 'Shift' && this.hoverActivated) {
// shift is not pressed anymore -> deactivate hovering and reset hovered and selected
this.hoverActivated = false;
for (const commit of this.commits) {
commit.hovered = false;
commit.selected = false;
}
}
},
highlight(commit: Commit) {
if (!this.hoverActivated) return;
const indexSelected = this.commits.findIndex((x) => x.selected);
const indexCurrentElem = this.commits.findIndex((x) => x.id === commit.id);
for (const [idx, commit] of this.commits.entries()) {
commit.hovered = Math.min(indexSelected, indexCurrentElem) <= idx && idx <= Math.max(indexSelected, indexCurrentElem);
}
},
/** Focus given element */
focusElem(elem: HTMLElement, prevElem: HTMLElement) {
if (elem) {
elem.tabIndex = 0;
if (prevElem) prevElem.tabIndex = -1;
elem.focus();
}
},
/** Opens our menu, loads commits before opening */
async toggleMenu() {
this.menuVisible = !this.menuVisible;
// load our commits when the menu is not yet visible (it'll be toggled after loading)
// and we got no commits
if (!this.commits.length && this.menuVisible && !this.isLoading) {
this.isLoading = true;
try {
await this.fetchCommits();
} finally {
this.isLoading = false;
}
}
// set correct tabindex to allow easier navigation
this.$nextTick(() => {
if (this.menuVisible) {
this.focusElem(this.$refs.showAllChanges as HTMLElement, this.$refs.expandBtn as HTMLElement);
} else {
this.focusElem(this.$refs.expandBtn as HTMLElement, this.$refs.showAllChanges as HTMLElement);
}
});
},
const elRoot = useTemplateRef('elRoot') as Readonly<ShallowRef<HTMLDivElement>>;
const elExpandBtn = useTemplateRef('elExpandBtn') as Readonly<ShallowRef<HTMLButtonElement>>;
const elShowAllChanges = useTemplateRef('elShowAllChanges') as Readonly<ShallowRef<HTMLDivElement>>;
/** Load the commits to show in this dropdown */
async fetchCommits() {
const resp = await GET(`${this.issueLink}/commits/list`);
const results = await resp.json() as CommitListResult;
this.commits.push(...results.commits.map((x) => {
x.hovered = false;
return x;
}));
this.commits.reverse();
this.lastReviewCommitSha = results.last_review_commit_sha || null;
if (this.lastReviewCommitSha && !this.commits.some((x) => x.id === this.lastReviewCommitSha)) {
// the lastReviewCommit is not available (probably due to a force push)
// reset the last review commit sha
this.lastReviewCommitSha = null;
}
Object.assign(this.locale, results.locale);
},
showAllChanges() {
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
},
/** Called when user clicks on since last review */
changesSinceLastReviewClick() {
window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1)!.id}${this.queryParams}`);
},
/** Clicking on a single commit opens this specific commit */
commitClicked(commitId: string, newWindow = false) {
const url = `${this.issueLink}/commits/${commitId}${this.queryParams}`;
if (newWindow) {
window.open(url);
} else {
window.location.assign(url);
}
},
/**
* When a commit is clicked while holding Shift, it enables range selection.
* - The range selection is a half-open, half-closed range, meaning it excludes the start commit but includes the end commit.
* - The start of the commit range is always the previous commit of the first clicked commit.
* - If the first commit in the list is clicked, the mergeBase will be used as the start of the range instead.
* - The second Shift-click defines the end of the range.
* - Once both are selected, the diff view for the selected commit range will open.
*/
commitClickedShift(commit: Commit) {
this.hoverActivated = !this.hoverActivated;
commit.selected = true;
// Second click -> determine our range and open links accordingly
if (!this.hoverActivated) {
// since at least one commit is selected, we can determine the range
// find all selected commits and generate a link
const firstSelected = this.commits.findIndex((x) => x.selected);
const lastSelected = this.commits.findLastIndex((x) => x.selected);
let beforeCommitID: string | null = null;
if (firstSelected === 0) {
beforeCommitID = this.mergeBase;
} else {
beforeCommitID = this.commits[firstSelected - 1].id;
}
const afterCommitID = this.commits[lastSelected].id;
const elMount = document.querySelector('#diff-commit-select')!;
const queryParams = elMount.getAttribute('data-queryparams');
const issueLink = elMount.getAttribute('data-issuelink');
const mergeBase = elMount.getAttribute('data-merge-base');
const uniqueIdMenu = generateElemId('diff-commit-selector-menu-');
const uniqueIdShowAll = generateElemId('diff-commit-selector-show-all-');
if (firstSelected === lastSelected) {
// if the start and end are the same, we show this single commit
window.location.assign(`${this.issueLink}/commits/${afterCommitID}${this.queryParams}`);
} else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1)!.id) {
// if the first commit is selected and the last commit is selected, we show all commits
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
} else {
window.location.assign(`${this.issueLink}/files/${beforeCommitID}..${afterCommitID}${this.queryParams}`);
}
}
},
},
const menuVisible = shallowRef(false);
const isLoading = shallowRef(false);
const locale = shallowRef<Record<string, string>>({filter_changes_by_commit: elMount.getAttribute('data-filter_changes_by_commit')!});
const commits = ref<Array<Commit>>([]); // deep, the commit objects are mutated in place
const hoverActivated = shallowRef(false);
const lastReviewCommitSha = shallowRef<string | null>(null);
const commitsSinceLastReview = computed(() => {
if (lastReviewCommitSha.value) {
return commits.value.length - commits.value.findIndex((x) => x.id === lastReviewCommitSha.value) - 1;
}
return 0;
});
onMounted(() => {
document.body.addEventListener('click', onBodyClick);
elRoot.value.addEventListener('keydown', onKeyDown);
elRoot.value.addEventListener('keyup', onKeyUp);
});
onBeforeUnmount(() => { // template refs are null by onUnmounted
document.body.removeEventListener('click', onBodyClick);
elRoot.value.removeEventListener('keydown', onKeyDown);
elRoot.value.removeEventListener('keyup', onKeyUp);
});
function onBodyClick(event: MouseEvent) {
// close this menu on click outside of this element when the dropdown is currently visible opened
if (elRoot.value.contains(event.target as Node)) return;
if (menuVisible.value) {
toggleMenu();
}
}
function onKeyDown(event: KeyboardEvent) {
if (!menuVisible.value) return;
const item = document.activeElement as HTMLElement;
if (!elRoot.value.contains(item)) return;
switch (event.key) {
case 'ArrowDown': // select next element
event.preventDefault();
focusElem(item.nextElementSibling as HTMLElement, item);
break;
case 'ArrowUp': // select previous element
event.preventDefault();
focusElem(item.previousElementSibling as HTMLElement, item);
break;
case 'Escape': // close menu
event.preventDefault();
item.tabIndex = -1;
toggleMenu();
break;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const item = document.activeElement; // try to highlight the selected commits
const commitIdx = item?.matches('.item') ? item.getAttribute('data-commit-idx') : null;
if (commitIdx) highlight(commits.value[Number(commitIdx)]);
}
}
function onKeyUp(event: KeyboardEvent) {
if (!menuVisible.value) return;
const item = document.activeElement;
if (!elRoot.value.contains(item)) return;
if (event.key === 'Shift' && hoverActivated.value) {
// shift is not pressed anymore -> deactivate hovering and reset hovered and selected
hoverActivated.value = false;
for (const commit of commits.value) {
commit.hovered = false;
commit.selected = false;
}
}
}
function highlight(commit: Commit) {
if (!hoverActivated.value) return;
const indexSelected = commits.value.findIndex((x) => x.selected);
const indexCurrentElem = commits.value.findIndex((x) => x.id === commit.id);
for (const [idx, commit] of commits.value.entries()) {
commit.hovered = Math.min(indexSelected, indexCurrentElem) <= idx && idx <= Math.max(indexSelected, indexCurrentElem);
}
}
/** Focus given element */
function focusElem(elem: HTMLElement, prevElem: HTMLElement) {
if (elem) {
elem.tabIndex = 0;
if (prevElem) prevElem.tabIndex = -1;
elem.focus();
}
}
/** Opens our menu, loads commits before opening */
async function toggleMenu() {
menuVisible.value = !menuVisible.value;
// load our commits when the menu is not yet visible (it'll be toggled after loading)
// and we got no commits
if (!commits.value.length && menuVisible.value && !isLoading.value) {
isLoading.value = true;
try {
await fetchCommits();
} finally {
isLoading.value = false;
}
}
// set correct tabindex to allow easier navigation
nextTick(() => {
if (menuVisible.value) {
focusElem(elShowAllChanges.value, elExpandBtn.value);
} else {
focusElem(elExpandBtn.value, elShowAllChanges.value);
}
});
}
/** Load the commits to show in this dropdown */
async function fetchCommits() {
const resp = await GET(`${issueLink}/commits/list`);
const results = await resp.json() as CommitListResult;
for (const commit of results.commits) commit.hovered = false;
commits.value.push(...results.commits);
commits.value.reverse();
lastReviewCommitSha.value = results.last_review_commit_sha || null;
if (lastReviewCommitSha.value && !commits.value.some((x) => x.id === lastReviewCommitSha.value)) {
// the lastReviewCommit is not available (probably due to a force push)
// reset the last review commit sha
lastReviewCommitSha.value = null;
}
locale.value = {...locale.value, ...results.locale};
}
function showAllChanges() {
window.location.assign(`${issueLink}/files${queryParams}`);
}
/** Called when user clicks on since last review */
function changesSinceLastReviewClick() {
window.location.assign(`${issueLink}/files/${lastReviewCommitSha.value}..${commits.value.at(-1)!.id}${queryParams}`);
}
/** Clicking on a single commit opens this specific commit */
function commitClicked(commitId: string, newWindow = false) {
const url = `${issueLink}/commits/${commitId}${queryParams}`;
if (newWindow) {
window.open(url);
} else {
window.location.assign(url);
}
}
/**
* When a commit is clicked while holding Shift, it enables range selection.
* - The range selection is a half-open, half-closed range, meaning it excludes the start commit but includes the end commit.
* - The start of the commit range is always the previous commit of the first clicked commit.
* - If the first commit in the list is clicked, the mergeBase will be used as the start of the range instead.
* - The second Shift-click defines the end of the range.
* - Once both are selected, the diff view for the selected commit range will open.
*/
function commitClickedShift(commit: Commit) {
hoverActivated.value = !hoverActivated.value;
commit.selected = true;
// Second click -> determine our range and open links accordingly
if (!hoverActivated.value) {
// since at least one commit is selected, we can determine the range
// find all selected commits and generate a link
const firstSelected = commits.value.findIndex((x) => x.selected);
const lastSelected = commits.value.findLastIndex((x) => x.selected);
const beforeCommitID = firstSelected === 0 ? mergeBase : commits.value[firstSelected - 1].id;
const afterCommitID = commits.value[lastSelected].id;
if (firstSelected === lastSelected) {
// if the start and end are the same, we show this single commit
window.location.assign(`${issueLink}/commits/${afterCommitID}${queryParams}`);
} else if (beforeCommitID === mergeBase && afterCommitID === commits.value.at(-1)!.id) {
// if the first commit is selected and the last commit is selected, we show all commits
window.location.assign(`${issueLink}/files${queryParams}`);
} else {
window.location.assign(`${issueLink}/files/${beforeCommitID}..${afterCommitID}${queryParams}`);
}
}
}
</script>
<template>
<div class="ui scrolling dropdown custom diff-commit-selector">
<div class="ui scrolling dropdown custom diff-commit-selector" ref="elRoot">
<button
ref="expandBtn"
ref="elExpandBtn"
class="ui tiny basic button"
@click.stop="toggleMenu()"
:data-tooltip-content="locale.filter_changes_by_commit"
@@ -232,7 +230,7 @@ export default defineComponent({
<!-- this dropdown is not managed by Fomantic UI, so it needs some classes like "transition" explicitly -->
<div class="left menu transition" :id="uniqueIdMenu" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak :aria-expanded="menuVisible ? 'true': 'false'">
<div class="loading-indicator is-loading" v-if="isLoading"/>
<div v-if="!isLoading" class="item" :id="uniqueIdShowAll" ref="showAllChanges" role="menuitem" @keydown.enter="showAllChanges()" @click="showAllChanges()">
<div v-if="!isLoading" class="item" :id="uniqueIdShowAll" ref="elShowAllChanges" role="menuitem" @keydown.enter="showAllChanges()" @click="showAllChanges()">
<div class="gt-ellipsis">
{{ locale.show_all_commits }}
</div>