/
uzer_007
/
Rixea
Обзор
Документация
Войти
/
uzer_007
/
Rixea
Код
Запросы
0
Задачи
Вики
Пакеты
5
Релизы
2
CI/CD
Аналитика
Безопасность
master
web_src/js/components/DiffCommitSelector.vue
448 строк
17 KB
uzer_007
feat: первая версия Rixea
02 авг 2026, 22:16
02 авг 2026, 22:16
d4f7504
Код
Авторство
О чём код?
<script lang="ts"> import {defineComponent} from 'vue'; import {SvgIcon} from '../svg.ts'; import {GET} from '../modules/fetch.ts'; import {generateElemId} from '../utils/dom.ts'; import {trN} from '../modules/i18n.ts'; type Commit = { id: string, hovered: boolean, selected: boolean, summary: string, committer_or_author_name: string, time: string, short_sha: string, } type CommitPayload = Omit<Commit, 'hovered' | 'selected'>; type CommitListResult = { commits: Array<CommitPayload>, last_review_commit_sha: string, locale: Record<string, string>, } function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null; } function isCommitPayload(value: unknown): value is CommitPayload { if (!isRecord(value)) return false; return ['id', 'summary', 'committer_or_author_name', 'time', 'short_sha'] .every((key) => typeof value[key] === 'string'); } function isCommitListResult(value: unknown): value is CommitListResult { if (!isRecord(value)) return false; const {commits, locale} = value; if (!Array.isArray(commits) || !isRecord(locale)) return false; if (typeof value.last_review_commit_sha !== 'string' || !commits.every(isCommitPayload)) return false; return [ 'lang', 'show_all_commits', 'stats_num_commits', 'stats_num_commits_1', 'stats_num_commits_n', 'show_changes_since_your_last_review', 'select_commit_hold_shift_for_range', ].every((key) => typeof locale[key] === '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'), load_error: el.getAttribute('data-load-error'), loading: el.getAttribute('data-loading'), retry: el.getAttribute('data-retry'), } as Record<string, string>, mergeBase: el.getAttribute('data-merge-base'), commits: [] as Array<Commit>, hasLoadedCommits: false, hasLoadError: false, hoverActivated: false, menuTranslateX: 0, lastReviewCommitSha: null 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); window.addEventListener('resize', this.updateMenuPosition); this.$el.addEventListener('keydown', this.onKeyDown); this.$el.addEventListener('keyup', this.onKeyUp); }, unmounted() { document.body.removeEventListener('click', this.onBodyClick); window.removeEventListener('resize', this.updateMenuPosition); 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(); } }, updateMenuPosition() { const menu = this.$refs.menu as HTMLElement | undefined; if (!this.menuVisible || !menu) return; this.menuTranslateX = 0; this.$nextTick(() => { const rect = menu.getBoundingClientRect(); if (!rect.width) return; const viewportPadding = 8; if (rect.left < viewportPadding) { this.menuTranslateX = viewportPadding - rect.left; } else if (rect.right > window.innerWidth - viewportPadding) { this.menuTranslateX = window.innerWidth - viewportPadding - rect.right; } }); }, /** Opens our menu, loads commits before opening */ async toggleMenu() { this.menuVisible = !this.menuVisible; // Load once on the first opening; a valid empty response is also final. if (!this.hasLoadedCommits && !this.hasLoadError && this.menuVisible && !this.isLoading) { await this.loadCommits(); } // set correct tabindex to allow easier navigation this.$nextTick(() => { if (this.menuVisible) { const firstItem = this.hasLoadError ? this.$refs.retryButton : this.$refs.showAllChanges; this.focusElem(firstItem as HTMLElement, this.$refs.expandBtn as HTMLElement); this.updateMenuPosition(); } else { this.menuTranslateX = 0; this.focusElem(this.$refs.expandBtn as HTMLElement, this.$refs.showAllChanges as HTMLElement); } }); }, async loadCommits() { this.isLoading = true; this.hasLoadError = false; try { await this.fetchCommits(); } catch { this.hasLoadError = true; } finally { this.isLoading = false; } }, async retryCommits() { if (this.isLoading) return; await this.loadCommits(); this.$nextTick(() => { if (!this.menuVisible) { this.focusElem(this.$refs.expandBtn as HTMLElement, this.$refs.showAllChanges as HTMLElement); return; } const firstItem = this.hasLoadError ? this.$refs.retryButton : this.$refs.showAllChanges; this.focusElem(firstItem as HTMLElement, this.$refs.expandBtn as HTMLElement); this.updateMenuPosition(); }); }, /** Load the commits to show in this dropdown */ async fetchCommits() { const resp = await GET(`${this.issueLink}/commits/list`); if (!resp.ok) throw new Error(`Unexpected response status: ${resp.status}`); const results: unknown = await resp.json(); if (!isCommitListResult(results)) throw new TypeError('Unexpected commit list response'); const commits = results.commits.map((commit) => ({ ...commit, hovered: false, selected: false, })).reverse(); this.commits = commits; this.hasLoadedCommits = true; this.lastReviewCommitSha = results.last_review_commit_sha || null; if (this.lastReviewCommitSha && !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}`); }, formatCommitsSinceLastReview() { return trN(this.commitsSinceLastReview, this.locale.stats_num_commits_1, this.locale.stats_num_commits_n, {lang: this.locale.lang}); }, /** 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; 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}`); } } }, }, }); </script> <template> <div class="ui scrolling dropdown custom diff-commit-selector"> <button ref="expandBtn" class="ui tiny basic button" @click.stop="toggleMenu()" :data-tooltip-content="locale.filter_changes_by_commit" aria-haspopup="true" :aria-label="locale.filter_changes_by_commit" :aria-controls="uniqueIdMenu" :aria-expanded="menuVisible ? 'true' : 'false'" :aria-busy="isLoading ? 'true' : 'false'" :aria-activedescendant="menuVisible && !isLoading && !hasLoadError ? uniqueIdShowAll : undefined" > <svg-icon name="octicon-git-commit"/> </button> <!-- this dropdown is not managed by Fomantic UI, so it needs some classes like "transition" explicitly --> <div ref="menu" class="left menu transition" :id="uniqueIdMenu" :class="{visible: menuVisible}" :style="{transform: menuTranslateX ? `translateX(${menuTranslateX}px)` : undefined}" v-show="menuVisible" v-cloak role="menu" > <div v-if="isLoading" class="diff-commit-selector-state" role="status" aria-live="polite"> <div class="loading-indicator is-loading"/> <span>{{ locale.loading }}</span> </div> <div v-else-if="hasLoadError" class="diff-commit-selector-state" role="alert"> <span>{{ locale.load_error }}</span> <button ref="retryButton" type="button" class="ui compact basic button" @click.stop="retryCommits"> {{ locale.retry }} </button> </div> <template v-else> <div class="item" :id="uniqueIdShowAll" ref="showAllChanges" role="menuitem" @keydown.enter="showAllChanges()" @click="showAllChanges()"> <div class="gt-ellipsis"> {{ locale.show_all_commits }} </div> <div class="gt-ellipsis tw-text-text-light-2 tw-mb-0"> {{ locale.stats_num_commits }} </div> </div> <!-- only show the show changes since last review if there is a review AND we are commits ahead of the last review --> <div v-if="lastReviewCommitSha != null" class="item" role="menuitem" :class="{disabled: !commitsSinceLastReview}" @keydown.enter="changesSinceLastReviewClick()" @click="changesSinceLastReviewClick()" > <div class="gt-ellipsis"> {{ locale.show_changes_since_your_last_review }} </div> <div class="gt-ellipsis tw-text-text-light-2"> {{ formatCommitsSinceLastReview() }} </div> </div> <span class="info tw-text-text-light-2">{{ locale.select_commit_hold_shift_for_range }}</span> <template v-for="(commit, idx) in commits" :key="commit.id"> <div class="item" role="menuitem" :class="{selected: commit.selected, hovered: commit.hovered}" :data-commit-idx="idx" @keydown.enter.exact="commitClicked(commit.id)" @keydown.enter.shift.exact="commitClickedShift(commit)" @mouseover.shift="highlight(commit)" @click.exact="commitClicked(commit.id)" @click.ctrl.exact="commitClicked(commit.id, true)" @click.meta.exact="commitClicked(commit.id, true)" @click.shift.exact.stop.prevent="commitClickedShift(commit)" > <div class="tw-flex-1 tw-flex tw-flex-col tw-gap-1"> <div class="gt-ellipsis commit-list-summary"> {{ commit.summary }} </div> <div class="gt-ellipsis tw-text-text-light-2"> {{ commit.committer_or_author_name }} <span class="text right"> <!-- TODO: make this respect the PreferredTimestampTense setting --> <relative-time prefix="" :datetime="commit.time" data-tooltip-content data-tooltip-interactive="true">{{ commit.time }}</relative-time> </span> </div> </div> <div class="tw-font-mono"> {{ commit.short_sha }} </div> </div> </template> </template> </div> </div> </template> <style scoped> .ui.dropdown.diff-commit-selector .menu { margin-top: 0.25em; width: max-content; max-width: calc(100vw - 1rem); overflow-x: hidden; max-height: min(450px, calc(100vh - 5rem)); } .ui.dropdown.diff-commit-selector .menu .diff-commit-selector-state { display: flex; width: min(350px, 96vw); min-height: 180px; padding: 1rem; flex-direction: column; align-items: center; justify-content: center; gap: 0.75rem; text-align: center; } .ui.dropdown.diff-commit-selector .menu .loading-indicator { width: 100%; height: 120px; } .ui.dropdown.diff-commit-selector .menu > .item, .ui.dropdown.diff-commit-selector .menu > .info { display: flex; flex-direction: row; line-height: 1.4; gap: 0.25em; padding: 7px 14px !important; } .ui.dropdown.diff-commit-selector .menu > .item:not(:first-child), .ui.dropdown.diff-commit-selector .menu > .info:not(:first-child) { border-top: 1px solid var(--color-secondary) !important; } .ui.dropdown.diff-commit-selector .menu > .item:focus { background: var(--color-active); } .ui.dropdown.diff-commit-selector .menu > .item.hovered { background-color: var(--color-small-accent); } .ui.dropdown.diff-commit-selector .menu > .item.selected { background-color: var(--color-accent); } .ui.dropdown.diff-commit-selector .menu .commit-list-summary { max-width: min(380px, 96vw); } </style>