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

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