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.

RepoBranchTagSelector.vue 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. <script>
  2. import {createApp, nextTick} from 'vue';
  3. import $ from 'jquery';
  4. import {SvgIcon} from '../svg.js';
  5. import {pathEscapeSegments} from '../utils/url.js';
  6. import {showErrorToast} from '../modules/toast.js';
  7. import {GET} from '../modules/fetch.js';
  8. const sfc = {
  9. components: {SvgIcon},
  10. // no `data()`, at the moment, the `data()` is provided by the init code, which is not ideal and should be fixed in the future
  11. computed: {
  12. filteredItems() {
  13. const items = this.items.filter((item) => {
  14. return ((this.mode === 'branches' && item.branch) || (this.mode === 'tags' && item.tag)) &&
  15. (!this.searchTerm || item.name.toLowerCase().includes(this.searchTerm.toLowerCase()));
  16. });
  17. // TODO: fix this anti-pattern: side-effects-in-computed-properties
  18. this.active = !items.length && this.showCreateNewBranch ? 0 : -1;
  19. return items;
  20. },
  21. showNoResults() {
  22. return !this.filteredItems.length && !this.showCreateNewBranch;
  23. },
  24. showCreateNewBranch() {
  25. if (this.disableCreateBranch || !this.searchTerm) {
  26. return false;
  27. }
  28. return !this.items.filter((item) => {
  29. return item.name.toLowerCase() === this.searchTerm.toLowerCase();
  30. }).length;
  31. },
  32. formActionUrl() {
  33. return `${this.repoLink}/branches/_new/${this.branchNameSubURL}`;
  34. },
  35. shouldCreateTag() {
  36. return this.mode === 'tags';
  37. },
  38. },
  39. watch: {
  40. menuVisible(visible) {
  41. if (visible) {
  42. this.focusSearchField();
  43. this.fetchBranchesOrTags();
  44. }
  45. },
  46. },
  47. beforeMount() {
  48. if (this.viewType === 'tree') {
  49. this.isViewTree = true;
  50. this.refNameText = this.commitIdShort;
  51. } else if (this.viewType === 'tag') {
  52. this.isViewTag = true;
  53. this.refNameText = this.tagName;
  54. } else {
  55. this.isViewBranch = true;
  56. this.refNameText = this.branchName;
  57. }
  58. document.body.addEventListener('click', (event) => {
  59. if (this.$el.contains(event.target)) return;
  60. if (this.menuVisible) {
  61. this.menuVisible = false;
  62. }
  63. });
  64. },
  65. methods: {
  66. selectItem(item) {
  67. const prev = this.getSelected();
  68. if (prev !== null) {
  69. prev.selected = false;
  70. }
  71. item.selected = true;
  72. const url = (item.tag) ? this.tagURLPrefix + item.url + this.tagURLSuffix : this.branchURLPrefix + item.url + this.branchURLSuffix;
  73. if (!this.branchForm) {
  74. window.location.href = url;
  75. } else {
  76. this.isViewTree = false;
  77. this.isViewTag = false;
  78. this.isViewBranch = false;
  79. this.$refs.dropdownRefName.textContent = item.name;
  80. if (this.setAction) {
  81. document.getElementById(this.branchForm)?.setAttribute('action', url);
  82. } else {
  83. $(`#${this.branchForm} input[name="refURL"]`).val(url);
  84. }
  85. $(`#${this.branchForm} input[name="ref"]`).val(item.name);
  86. if (item.tag) {
  87. this.isViewTag = true;
  88. $(`#${this.branchForm} input[name="refType"]`).val('tag');
  89. } else {
  90. this.isViewBranch = true;
  91. $(`#${this.branchForm} input[name="refType"]`).val('branch');
  92. }
  93. if (this.submitForm) {
  94. $(`#${this.branchForm}`).trigger('submit');
  95. }
  96. this.menuVisible = false;
  97. }
  98. },
  99. createNewBranch() {
  100. if (!this.showCreateNewBranch) return;
  101. $(this.$refs.newBranchForm).trigger('submit');
  102. },
  103. focusSearchField() {
  104. nextTick(() => {
  105. this.$refs.searchField.focus();
  106. });
  107. },
  108. getSelected() {
  109. for (let i = 0, j = this.items.length; i < j; ++i) {
  110. if (this.items[i].selected) return this.items[i];
  111. }
  112. return null;
  113. },
  114. getSelectedIndexInFiltered() {
  115. for (let i = 0, j = this.filteredItems.length; i < j; ++i) {
  116. if (this.filteredItems[i].selected) return i;
  117. }
  118. return -1;
  119. },
  120. scrollToActive() {
  121. let el = this.$refs[`listItem${this.active}`]; // eslint-disable-line no-jquery/variable-pattern
  122. if (!el || !el.length) return;
  123. if (Array.isArray(el)) {
  124. el = el[0];
  125. }
  126. const cont = this.$refs.scrollContainer;
  127. if (el.offsetTop < cont.scrollTop) {
  128. cont.scrollTop = el.offsetTop;
  129. } else if (el.offsetTop + el.clientHeight > cont.scrollTop + cont.clientHeight) {
  130. cont.scrollTop = el.offsetTop + el.clientHeight - cont.clientHeight;
  131. }
  132. },
  133. keydown(event) {
  134. if (event.keyCode === 40) { // arrow down
  135. event.preventDefault();
  136. if (this.active === -1) {
  137. this.active = this.getSelectedIndexInFiltered();
  138. }
  139. if (this.active + (this.showCreateNewBranch ? 0 : 1) >= this.filteredItems.length) {
  140. return;
  141. }
  142. this.active++;
  143. this.scrollToActive();
  144. } else if (event.keyCode === 38) { // arrow up
  145. event.preventDefault();
  146. if (this.active === -1) {
  147. this.active = this.getSelectedIndexInFiltered();
  148. }
  149. if (this.active <= 0) {
  150. return;
  151. }
  152. this.active--;
  153. this.scrollToActive();
  154. } else if (event.keyCode === 13) { // enter
  155. event.preventDefault();
  156. if (this.active >= this.filteredItems.length) {
  157. this.createNewBranch();
  158. } else if (this.active >= 0) {
  159. this.selectItem(this.filteredItems[this.active]);
  160. }
  161. } else if (event.keyCode === 27) { // escape
  162. event.preventDefault();
  163. this.menuVisible = false;
  164. }
  165. },
  166. handleTabSwitch(mode) {
  167. if (this.isLoading) return;
  168. this.mode = mode;
  169. this.focusSearchField();
  170. this.fetchBranchesOrTags();
  171. },
  172. async fetchBranchesOrTags() {
  173. if (!['branches', 'tags'].includes(this.mode) || this.isLoading) return;
  174. // only fetch when branch/tag list has not been initialized
  175. if (this.hasListInitialized[this.mode] ||
  176. (this.mode === 'branches' && !this.showBranchesInDropdown) ||
  177. (this.mode === 'tags' && this.noTag)
  178. ) {
  179. return;
  180. }
  181. this.isLoading = true;
  182. try {
  183. const resp = await GET(`${this.repoLink}/${this.mode}/list`);
  184. const {results} = await resp.json();
  185. for (const result of results) {
  186. let selected = false;
  187. if (this.mode === 'branches') {
  188. selected = result === this.defaultSelectedRefName;
  189. } else {
  190. selected = result === (this.release ? this.release.tagName : this.defaultSelectedRefName);
  191. }
  192. this.items.push({name: result, url: pathEscapeSegments(result), branch: this.mode === 'branches', tag: this.mode === 'tags', selected});
  193. }
  194. this.hasListInitialized[this.mode] = true;
  195. } catch (e) {
  196. showErrorToast(`Network error when fetching ${this.mode}, error: ${e}`);
  197. } finally {
  198. this.isLoading = false;
  199. }
  200. },
  201. },
  202. };
  203. export function initRepoBranchTagSelector(selector) {
  204. for (const [elIndex, elRoot] of document.querySelectorAll(selector).entries()) {
  205. const data = {
  206. csrfToken: window.config.csrfToken,
  207. items: [],
  208. searchTerm: '',
  209. refNameText: '',
  210. menuVisible: false,
  211. release: null,
  212. isViewTag: false,
  213. isViewBranch: false,
  214. isViewTree: false,
  215. active: 0,
  216. isLoading: false,
  217. // This means whether branch list/tag list has initialized
  218. hasListInitialized: {
  219. 'branches': false,
  220. 'tags': false,
  221. },
  222. ...window.config.pageData.branchDropdownDataList[elIndex],
  223. };
  224. const comp = {...sfc, data() { return data }};
  225. createApp(comp).mount(elRoot);
  226. }
  227. }
  228. export default sfc; // activate IDE's Vue plugin
  229. </script>
  230. <template>
  231. <div class="ui dropdown custom branch-selector-dropdown ellipsis-items-nowrap">
  232. <div class="ui button branch-dropdown-button" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
  233. <span class="flex-text-block gt-ellipsis">
  234. <template v-if="release">{{ textReleaseCompare }}</template>
  235. <template v-else>
  236. <svg-icon v-if="isViewTag" name="octicon-tag"/>
  237. <svg-icon v-else name="octicon-git-branch"/>
  238. <strong ref="dropdownRefName" class="tw-ml-2 tw-inline-block gt-ellipsis">{{ refNameText }}</strong>
  239. </template>
  240. </span>
  241. <svg-icon name="octicon-triangle-down" :size="14" class-name="dropdown icon"/>
  242. </div>
  243. <div class="menu transition" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak>
  244. <div class="ui icon search input">
  245. <i class="icon"><svg-icon name="octicon-filter" :size="16"/></i>
  246. <input name="search" ref="searchField" autocomplete="off" v-model="searchTerm" @keydown="keydown($event)" :placeholder="searchFieldPlaceholder">
  247. </div>
  248. <div v-if="showBranchesInDropdown" class="branch-tag-tab">
  249. <a class="branch-tag-item muted" :class="{active: mode === 'branches'}" href="#" @click="handleTabSwitch('branches')">
  250. <svg-icon name="octicon-git-branch" :size="16" class-name="tw-mr-1"/>{{ textBranches }}
  251. </a>
  252. <a v-if="!noTag" class="branch-tag-item muted" :class="{active: mode === 'tags'}" href="#" @click="handleTabSwitch('tags')">
  253. <svg-icon name="octicon-tag" :size="16" class-name="tw-mr-1"/>{{ textTags }}
  254. </a>
  255. </div>
  256. <div class="branch-tag-divider"/>
  257. <div class="scrolling menu" ref="scrollContainer">
  258. <svg-icon name="octicon-rss" symbol-id="svg-symbol-octicon-rss"/>
  259. <div class="loading-indicator is-loading" v-if="isLoading"/>
  260. <div v-for="(item, index) in filteredItems" :key="item.name" class="item" :class="{selected: item.selected, active: active === index}" @click="selectItem(item)" :ref="'listItem' + index">
  261. {{ item.name }}
  262. <div class="ui label" v-if="item.name===repoDefaultBranch && mode === 'branches'">
  263. {{ textDefaultBranchLabel }}
  264. </div>
  265. <a v-show="enableFeed && mode === 'branches'" role="button" class="rss-icon" :href="rssURLPrefix + item.url" target="_blank" @click.stop>
  266. <!-- creating a lot of Vue component is pretty slow, so we use a static SVG here -->
  267. <svg width="14" height="14" class="svg octicon-rss"><use href="#svg-symbol-octicon-rss"/></svg>
  268. </a>
  269. </div>
  270. <div class="item" v-if="showCreateNewBranch" :class="{active: active === filteredItems.length}" :ref="'listItem' + filteredItems.length">
  271. <a href="#" @click="createNewBranch()">
  272. <div v-show="shouldCreateTag">
  273. <i class="reference tags icon"/>
  274. <!-- eslint-disable-next-line vue/no-v-html -->
  275. <span v-html="textCreateTag.replace('%s', searchTerm)"/>
  276. </div>
  277. <div v-show="!shouldCreateTag">
  278. <svg-icon name="octicon-git-branch"/>
  279. <!-- eslint-disable-next-line vue/no-v-html -->
  280. <span v-html="textCreateBranch.replace('%s', searchTerm)"/>
  281. </div>
  282. <div class="text small">
  283. <span v-if="isViewBranch || release">{{ textCreateBranchFrom.replace('%s', branchName) }}</span>
  284. <span v-else-if="isViewTag">{{ textCreateBranchFrom.replace('%s', tagName) }}</span>
  285. <span v-else>{{ textCreateBranchFrom.replace('%s', commitIdShort) }}</span>
  286. </div>
  287. </a>
  288. <form ref="newBranchForm" :action="formActionUrl" method="post">
  289. <input type="hidden" name="_csrf" :value="csrfToken">
  290. <input type="hidden" name="new_branch_name" v-model="searchTerm">
  291. <input type="hidden" name="create_tag" v-model="shouldCreateTag">
  292. <input type="hidden" name="current_path" v-model="treePath" v-if="treePath">
  293. </form>
  294. </div>
  295. </div>
  296. <div class="message" v-if="showNoResults && !isLoading">
  297. {{ noResults }}
  298. </div>
  299. </div>
  300. </div>
  301. </template>