You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

DiffCommitSelector.vue 11KB

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