1<script>
2import {SvgIcon} from '../svg.js';
3import {GET} from '../modules/fetch.js';
4
5export default {
6 components: {SvgIcon},
7 data: () => {
8 const el = document.getElementById('diff-commit-select');
9 return {
10 menuVisible: false,
11 isLoading: false,
12 locale: {
13 filter_changes_by_commit: el.getAttribute('data-filter_changes_by_commit'),
14 },
15 commits: [],
16 hoverActivated: false,
17 lastReviewCommitSha: null,
18 };
19 },
20 computed: {
21 commitsSinceLastReview() {
22 if (this.lastReviewCommitSha) {
23 return this.commits.length - this.commits.findIndex((x) => x.id === this.lastReviewCommitSha) - 1;
24 }
25 return 0;
26 },
27 queryParams() {
28 return this.$el.parentNode.getAttribute('data-queryparams');
29 },
30 issueLink() {
31 return this.$el.parentNode.getAttribute('data-issuelink');
32 },
33 },
34 mounted() {
35 document.body.addEventListener('click', this.onBodyClick);
36 this.$el.addEventListener('keydown', this.onKeyDown);
37 this.$el.addEventListener('keyup', this.onKeyUp);
38 },
39 unmounted() {
40 document.body.removeEventListener('click', this.onBodyClick);
41 this.$el.removeEventListener('keydown', this.onKeyDown);
42 this.$el.removeEventListener('keyup', this.onKeyUp);
43 },
44 methods: {
45 onBodyClick(event) {
46 // close this menu on click outside of this element when the dropdown is currently visible opened
47 if (this.$el.contains(event.target)) return;
48 if (this.menuVisible) {
49 this.toggleMenu();
50 }
51 },
52 onKeyDown(event) {
53 if (!this.menuVisible) return;
54 const item = document.activeElement;
55 if (!this.$el.contains(item)) return;
56 switch (event.key) {
57 case 'ArrowDown': // select next element
58 event.preventDefault();
59 this.focusElem(item.nextElementSibling, item);
60 break;
61 case 'ArrowUp': // select previous element
62 event.preventDefault();
63 this.focusElem(item.previousElementSibling, item);
64 break;
65 case 'Escape': // close menu
66 event.preventDefault();
67 item.tabIndex = -1;
68 this.toggleMenu();
69 break;
70 }
71 },
72 onKeyUp(event) {
73 if (!this.menuVisible) return;
74 const item = document.activeElement;
75 if (!this.$el.contains(item)) return;
76 if (event.key === 'Shift' && this.hoverActivated) {
77 // shift is not pressed anymore -> deactivate hovering and reset hovered and selected
78 this.hoverActivated = false;
79 for (const commit of this.commits) {
80 commit.hovered = false;
81 commit.selected = false;
82 }
83 }
84 },
85 highlight(commit) {
86 if (!this.hoverActivated) return;
87 const indexSelected = this.commits.findIndex((x) => x.selected);
88 const indexCurrentElem = this.commits.findIndex((x) => x.id === commit.id);
89 for (const [idx, commit] of this.commits.entries()) {
90 commit.hovered = Math.min(indexSelected, indexCurrentElem) <= idx && idx <= Math.max(indexSelected, indexCurrentElem);
91 }
92 },
93 /** Focus given element */
94 focusElem(elem, prevElem) {
95 if (elem) {
96 elem.tabIndex = 0;
97 if (prevElem) prevElem.tabIndex = -1;
98 elem.focus();
99 }
100 },
101 /** Opens our menu, loads commits before opening */
102 async toggleMenu() {
103 this.menuVisible = !this.menuVisible;
104 // load our commits when the menu is not yet visible (it'll be toggled after loading)
105 // and we got no commits
106 if (!this.commits.length && this.menuVisible && !this.isLoading) {
107 this.isLoading = true;
108 try {
109 await this.fetchCommits();
110 } finally {
111 this.isLoading = false;
112 }
113 }
114 // set correct tabindex to allow easier navigation
115 this.$nextTick(() => {
116 const expandBtn = this.$el.querySelector('#diff-commit-list-expand');
117 const showAllChanges = this.$el.querySelector('#diff-commit-list-show-all');
118 if (this.menuVisible) {
119 this.focusElem(showAllChanges, expandBtn);
120 } else {
121 this.focusElem(expandBtn, showAllChanges);
122 }
123 });
124 },
125 /** Load the commits to show in this dropdown */
126 async fetchCommits() {
127 const resp = await GET(`${this.issueLink}/commits/list`);
128 const results = await resp.json();
129 this.commits.push(...results.commits.map((x) => {
130 x.hovered = false;
131 return x;
132 }));
133 this.commits.reverse();
134 this.lastReviewCommitSha = results.last_review_commit_sha || null;
135 if (this.lastReviewCommitSha && !this.commits.some((x) => x.id === this.lastReviewCommitSha)) {
136 // the lastReviewCommit is not available (probably due to a force push)
137 // reset the last review commit sha
138 this.lastReviewCommitSha = null;
139 }
140 Object.assign(this.locale, results.locale);
141 },
142 showAllChanges() {
143 window.location.assign(`${this.issueLink}/files${this.queryParams}`);
144 },
145 /** Called when user clicks on since last review */
146 changesSinceLastReviewClick() {
147 window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`);
148 },
149 /** Clicking on a single commit opens this specific commit */
150 commitClicked(commitId, newWindow = false) {
151 const url = `${this.issueLink}/commits/${commitId}${this.queryParams}`;
152 if (newWindow) {
153 window.open(url);
154 } else {
155 window.location.assign(url);
156 }
157 },
158 /**
159 * When a commit is clicked with shift this enables the range
160 * selection. Second click (with shift) defines the end of the
161 * range. This opens the diff of this range
162 * Exception: first commit is the first commit of this PR. Then
163 * the diff from beginning of PR up to the second clicked commit is
164 * opened
165 */
166 commitClickedShift(commit) {
167 this.hoverActivated = !this.hoverActivated;
168 commit.selected = true;
169 // Second click -> determine our range and open links accordingly
170 if (!this.hoverActivated) {
171 // find all selected commits and generate a link
172 if (this.commits[0].selected) {
173 // first commit is selected - generate a short url with only target sha
174 const lastCommitIdx = this.commits.findLastIndex((x) => x.selected);
175 if (lastCommitIdx === this.commits.length - 1) {
176 // user selected all commits - just show the normal diff page
177 window.location.assign(`${this.issueLink}/files${this.queryParams}`);
178 } else {
179 window.location.assign(`${this.issueLink}/files/${this.commits[lastCommitIdx].id}${this.queryParams}`);
180 }
181 } else {
182 const start = this.commits[this.commits.findIndex((x) => x.selected) - 1].id;
183 const end = this.commits.findLast((x) => x.selected).id;
184 window.location.assign(`${this.issueLink}/files/${start}..${end}${this.queryParams}`);
185 }
186 }
187 },
188 },
189};
190</script>
191<template>
192 <div class="ui scrolling dropdown custom">
193 <button
194 class="ui basic button"
195 id="diff-commit-list-expand"
196 @click.stop="toggleMenu()"
197 :data-tooltip-content="locale.filter_changes_by_commit"
198 aria-haspopup="true"
199 aria-controls="diff-commit-selector-menu"
200 :aria-label="locale.filter_changes_by_commit"
201 aria-activedescendant="diff-commit-list-show-all"
202 >
203 <svg-icon name="octicon-git-commit"/>
204 </button>
205 <div class="menu left transition" id="diff-commit-selector-menu" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak :aria-expanded="menuVisible ? 'true': 'false'">
206 <div class="loading-indicator is-loading" v-if="isLoading"/>
207 <div v-if="!isLoading" class="vertical item" id="diff-commit-list-show-all" role="menuitem" @keydown.enter="showAllChanges()" @click="showAllChanges()">
208 <div class="gt-ellipsis">
209 {{ locale.show_all_commits }}
210 </div>
211 <div class="gt-ellipsis text light-2 tw-mb-0">
212 {{ locale.stats_num_commits }}
213 </div>
214 </div>
215 <!-- only show the show changes since last review if there is a review AND we are commits ahead of the last review -->
216 <div
217 v-if="lastReviewCommitSha != null" role="menuitem"
218 class="vertical item"
219 :class="{disabled: !commitsSinceLastReview}"
220 @keydown.enter="changesSinceLastReviewClick()"
221 @click="changesSinceLastReviewClick()"
222 >
223 <div class="gt-ellipsis">
224 {{ locale.show_changes_since_your_last_review }}
225 </div>
226 <div class="gt-ellipsis text light-2">
227 {{ commitsSinceLastReview }} commits
228 </div>
229 </div>
230 <span v-if="!isLoading" class="info text light-2">{{ locale.select_commit_hold_shift_for_range }}</span>
231 <template v-for="commit in commits" :key="commit.id">
232 <div
233 class="vertical item" role="menuitem"
234 :class="{selection: commit.selected, hovered: commit.hovered}"
235 @keydown.enter.exact="commitClicked(commit.id)"
236 @keydown.enter.shift.exact="commitClickedShift(commit)"
237 @mouseover.shift="highlight(commit)"
238 @click.exact="commitClicked(commit.id)"
239 @click.ctrl.exact="commitClicked(commit.id, true)"
240 @click.meta.exact="commitClicked(commit.id, true)"
241 @click.shift.exact.stop.prevent="commitClickedShift(commit)"
242 >
243 <div class="tw-flex-1 tw-flex tw-flex-col tw-gap-1">
244 <div class="gt-ellipsis commit-list-summary">
245 {{ commit.summary }}
246 </div>
247 <div class="gt-ellipsis text light-2">
248 {{ commit.committer_or_author_name }}
249 <span class="text right">
250 <!-- TODO: make this respect the PreferredTimestampTense setting -->
251 <relative-time prefix="" :datetime="commit.time" data-tooltip-content data-tooltip-interactive="true">{{ commit.time }}</relative-time>
252 </span>
253 </div>
254 </div>
255 <div class="tw-font-mono">
256 {{ commit.short_sha }}
257 </div>
258 </div>
259 </template>
260 </div>
261 </div>
262</template>
263<style scoped>
264 .hovered:not(.selection) {
265 background-color: var(--color-small-accent) !important;
266 }
267 .selection {
268 background-color: var(--color-accent) !important;
269 }
270
271 .info {
272 display: inline-block;
273 padding: 7px 14px !important;
274 line-height: 1.4;
275 width: 100%;
276 }
277
278 #diff-commit-selector-menu {
279 overflow-x: hidden;
280 max-height: 450px;
281 }
282
283 #diff-commit-selector-menu .loading-indicator {
284 height: 200px;
285 width: 350px;
286 }
287
288 #diff-commit-selector-menu .item,
289 #diff-commit-selector-menu .info {
290 display: flex !important;
291 flex-direction: row;
292 line-height: 1.4;
293 padding: 7px 14px !important;
294 border-top: 1px solid var(--color-secondary) !important;
295 gap: 0.25em;
296 }
297
298 #diff-commit-selector-menu .item:focus {
299 color: var(--color-text);
300 background: var(--color-hover);
301 }
302
303 #diff-commit-selector-menu .commit-list-summary {
304 max-width: min(380px, 96vw);
305 }
306</style>