mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 10:56:10 +01:00 
			
		
		
		
	Add commits dropdown in PR files view and allow commit by commit review (#25528)
This PR adds a new dropdown to select a commit or a commit range (shift-click like github) of a Pull Request. After selection of a commit only the changes of this commit will be shown. When selecting a range of commits the diff of this range is shown. This allows to review a PR commit by commit or by viewing only commit ranges. The "Show changes since your last review" mechanism github uses is implemented, too. When reviewing a single commit or a commit range the "Viewed" functionality is disabled. ## Screenshots ### The commit dropdown  ### Selecting a commit range  ### Show changes of a single commit only  ### Show changes of a commit range  Fixes https://github.com/go-gitea/gitea/issues/20989 Fixes https://github.com/go-gitea/gitea/issues/19263 --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: KN4CK3R <admin@oldschoolhack.me> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: delvh <dev.lh@web.de>
This commit is contained in:
		
							
								
								
									
										299
									
								
								web_src/js/components/DiffCommitSelector.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										299
									
								
								web_src/js/components/DiffCommitSelector.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,299 @@ | ||||
| <template> | ||||
|   <div class="ui scrolling dropdown custom"> | ||||
|     <button | ||||
|       class="ui basic button" | ||||
|       id="diff-commit-list-expand" | ||||
|       @click.stop="toggleMenu()" | ||||
|       :data-tooltip-content="locale.filter_changes_by_commit" | ||||
|       aria-haspopup="true" | ||||
|       tabindex="0" | ||||
|       aria-controls="diff-commit-selector-menu" | ||||
|       :aria-label="locale.filter_changes_by_commit" | ||||
|       aria-activedescendant="diff-commit-list-show-all" | ||||
|     > | ||||
|       <svg-icon name="octicon-git-commit"/> | ||||
|     </button> | ||||
|     <div class="menu left transition" id="diff-commit-selector-menu" :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="vertical item gt-df gt-fc gt-gap-2" id="diff-commit-list-show-all" role="menuitem" tabindex="-1" @keydown.enter="showAllChanges()" @click="showAllChanges()"> | ||||
|         <div class="gt-ellipsis"> | ||||
|           {{ locale.show_all_commits }} | ||||
|         </div> | ||||
|         <div class="gt-ellipsis text light-2 gt-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" role="menuitem" tabindex="-1" | ||||
|         class="vertical item gt-df gt-fc gt-gap-2 gt-border-secondary-top" | ||||
|         :class="{disabled: commitsSinceLastReview === 0}" | ||||
|         @keydown.enter="changesSinceLastReviewClick()" | ||||
|         @click="changesSinceLastReviewClick()" | ||||
|       > | ||||
|         <div class="gt-ellipsis"> | ||||
|           {{ locale.show_changes_since_your_last_review }} | ||||
|         </div> | ||||
|         <div class="gt-ellipsis text light-2"> | ||||
|           {{ commitsSinceLastReview }} commits | ||||
|         </div> | ||||
|       </div> | ||||
|       <span v-if="!isLoading" class="info gt-border-secondary-top text light-2">{{ locale.select_commit_hold_shift_for_range }}</span> | ||||
|       <template v-for="commit in commits" :key="commit.id"> | ||||
|         <div | ||||
|           class="vertical item gt-df gt-gap-2 gt-border-secondary-top" role="menuitem" tabindex="-1" | ||||
|           :class="{selection: commit.selected, hovered: commit.hovered}" | ||||
|           @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="gt-f1 gt-df gt-fc gt-gap-2"> | ||||
|             <div class="gt-ellipsis commit-list-summary"> | ||||
|               {{ commit.summary }} | ||||
|             </div> | ||||
|             <div class="gt-ellipsis text light-2"> | ||||
|               {{ commit.committer_or_author_name }} | ||||
|               <span class="text right"> | ||||
|                 <relative-time class="time-since" prefix="" :datetime="commit.time" data-tooltip-content data-tooltip-interactive="true">{{ commit.time }}</relative-time> | ||||
|               </span> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="gt-mono"> | ||||
|             {{ commit.short_sha }} | ||||
|           </div> | ||||
|         </div> | ||||
|       </template> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import {SvgIcon} from '../svg.js'; | ||||
|  | ||||
| export default { | ||||
|   components: {SvgIcon}, | ||||
|   data: () => { | ||||
|     return { | ||||
|       menuVisible: false, | ||||
|       isLoading: false, | ||||
|       locale: {}, | ||||
|       commits: [], | ||||
|       hoverActivated: false, | ||||
|       lastReviewCommitSha: null | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     commitsSinceLastReview() { | ||||
|       if (this.lastReviewCommitSha) { | ||||
|         return this.commits.length - this.commits.findIndex((x) => x.id === this.lastReviewCommitSha) - 1; | ||||
|       } | ||||
|       return 0; | ||||
|     }, | ||||
|     queryParams() { | ||||
|       return this.$el.parentNode.getAttribute('data-queryparams'); | ||||
|     }, | ||||
|     issueLink() { | ||||
|       return this.$el.parentNode.getAttribute('data-issuelink'); | ||||
|     } | ||||
|   }, | ||||
|   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) { | ||||
|       // 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) { | ||||
|       if (!this.menuVisible) return; | ||||
|       const item = document.activeElement; | ||||
|       if (!this.$el.contains(item)) return; | ||||
|       switch (event.key) { | ||||
|         case 'ArrowDown': // select next element | ||||
|           event.preventDefault(); | ||||
|           this.focusElem(item.nextElementSibling, item); | ||||
|           break; | ||||
|         case 'ArrowUp': // select previous element | ||||
|           event.preventDefault(); | ||||
|           this.focusElem(item.previousElementSibling, item); | ||||
|           break; | ||||
|         case 'Escape': // close menu | ||||
|           event.preventDefault(); | ||||
|           item.tabIndex = -1; | ||||
|           this.toggleMenu(); | ||||
|           break; | ||||
|       } | ||||
|     }, | ||||
|     onKeyUp(event) { | ||||
|       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) { | ||||
|       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, prevElem) { | ||||
|       if (elem) { | ||||
|         elem.tabIndex = 0; | ||||
|         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 === 0 && this.menuVisible && !this.isLoading) { | ||||
|         this.isLoading = true; | ||||
|         try { | ||||
|           await this.fetchCommits(); | ||||
|         } finally { | ||||
|           this.isLoading = false; | ||||
|         } | ||||
|       } | ||||
|       // set correct tabindex to allow easier navigation | ||||
|       this.$nextTick(() => { | ||||
|         const expandBtn = this.$el.querySelector('#diff-commit-list-expand'); | ||||
|         const showAllChanges = this.$el.querySelector('#diff-commit-list-show-all'); | ||||
|         if (this.menuVisible) { | ||||
|           this.focusElem(showAllChanges, expandBtn); | ||||
|         } else { | ||||
|           this.focusElem(expandBtn, showAllChanges); | ||||
|         } | ||||
|       }); | ||||
|     }, | ||||
|     /** Load the commits to show in this dropdown */ | ||||
|     async fetchCommits() { | ||||
|       const resp = await fetch(`${this.issueLink}/commits/list`); | ||||
|       const results = await resp.json(); | ||||
|       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.findIndex((x) => x.id === this.lastReviewCommitSha) === -1) { | ||||
|         // 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 = `${this.issueLink}/files${this.queryParams}`; | ||||
|     }, | ||||
|     /** Called when user clicks on since last review */ | ||||
|     changesSinceLastReviewClick() { | ||||
|       window.location = `${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`; | ||||
|     }, | ||||
|     /** Clicking on a single commit opens this specific commit */ | ||||
|     commitClicked(commitId, newWindow = false) { | ||||
|       const url = `${this.issueLink}/commits/${commitId}${this.queryParams}`; | ||||
|       if (newWindow) { | ||||
|         window.open(url); | ||||
|       } else { | ||||
|         window.location = url; | ||||
|       } | ||||
|     }, | ||||
|     /** | ||||
|      * When a commit is clicked with shift this enables the range | ||||
|      * selection. Second click (with shift) defines the end of the | ||||
|      * range. This opens the diff of this range | ||||
|      * Exception: first commit is the first commit of this PR. Then | ||||
|      * the diff from beginning of PR up to the second clicked commit is | ||||
|      * opened | ||||
|      */ | ||||
|     commitClickedShift(commit) { | ||||
|       this.hoverActivated = !this.hoverActivated; | ||||
|       commit.selected = true; | ||||
|       // Second click -> determine our range and open links accordingly | ||||
|       if (!this.hoverActivated) { | ||||
|         // find all selected commits and generate a link | ||||
|         if (this.commits[0].selected) { | ||||
|           // first commit is selected - generate a short url with only target sha | ||||
|           const lastCommitIdx = this.commits.findLastIndex((x) => x.selected); | ||||
|           if (lastCommitIdx === this.commits.length - 1) { | ||||
|             // user selected all commits - just show the normal diff page | ||||
|             window.location = `${this.issueLink}/files${this.queryParams}`; | ||||
|           } else { | ||||
|             window.location = `${this.issueLink}/files/${this.commits[lastCommitIdx].id}${this.queryParams}`; | ||||
|           } | ||||
|         } else { | ||||
|           const start = this.commits[this.commits.findIndex((x) => x.selected) - 1].id; | ||||
|           const end = this.commits.findLast((x) => x.selected).id; | ||||
|           window.location = `${this.issueLink}/files/${start}..${end}${this.queryParams}`; | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|   } | ||||
| }; | ||||
| </script> | ||||
| <style scoped> | ||||
|   .hovered:not(.selection) { | ||||
|     background-color: var(--color-small-accent) !important; | ||||
|   } | ||||
|   .selection { | ||||
|     background-color: var(--color-accent) !important; | ||||
|   } | ||||
|  | ||||
|   .info { | ||||
|     display: inline-block; | ||||
|     padding: 7px 14px !important; | ||||
|     line-height: 1.4; | ||||
|     width: 100%; | ||||
|   } | ||||
|  | ||||
|   #diff-commit-selector-menu { | ||||
|     overflow-x: hidden; | ||||
|     max-height: 450px; | ||||
|   } | ||||
|  | ||||
|   #diff-commit-selector-menu .loading-indicator { | ||||
|     height: 200px; | ||||
|     width: 350px; | ||||
|   } | ||||
|  | ||||
|   #diff-commit-selector-menu .item { | ||||
|     flex-direction: row; | ||||
|     line-height: 1.4; | ||||
|     padding: 7px 14px !important; | ||||
|   } | ||||
|  | ||||
|   #diff-commit-selector-menu .item:focus { | ||||
|     color: var(--color-text); | ||||
|     background: var(--color-hover); | ||||
|   } | ||||
|  | ||||
|   #diff-commit-selector-menu .commit-list-summary { | ||||
|     max-width: min(380px, 96vw); | ||||
|   } | ||||
| </style> | ||||
		Reference in New Issue
	
	Block a user