diff options
-rw-r--r-- | templates/repo/branch_dropdown.tmpl | 98 | ||||
-rw-r--r-- | web_src/js/components/DashboardRepoList.vue | 2 | ||||
-rw-r--r-- | web_src/js/components/PullRequestMergeForm.vue | 7 | ||||
-rw-r--r-- | web_src/js/components/RepoBranchTagDropdown.js | 208 | ||||
-rw-r--r-- | web_src/js/components/RepoBranchTagSelector.vue | 293 | ||||
-rw-r--r-- | web_src/js/features/repo-findfile.js | 7 | ||||
-rw-r--r-- | web_src/js/features/repo-findfile.test.js | 7 | ||||
-rw-r--r-- | web_src/js/features/repo-legacy.js | 4 | ||||
-rw-r--r-- | web_src/js/svg.js | 18 | ||||
-rw-r--r-- | web_src/js/utils/url.js | 3 | ||||
-rw-r--r-- | web_src/js/utils/url.test.js | 7 | ||||
-rw-r--r-- | web_src/less/_base.less | 4 | ||||
-rw-r--r-- | web_src/less/_repository.less | 6 | ||||
-rw-r--r-- | webpack.config.js | 4 |
14 files changed, 359 insertions, 309 deletions
diff --git a/templates/repo/branch_dropdown.tmpl b/templates/repo/branch_dropdown.tmpl index 8e81373aec..1ec4b7ef16 100644 --- a/templates/repo/branch_dropdown.tmpl +++ b/templates/repo/branch_dropdown.tmpl @@ -1,6 +1,20 @@ -{{$release := .release}} -{{$defaultBranch := $.root.BranchName}}{{if and .root.IsViewTag (not .noTag)}}{{$defaultBranch = .root.TagName}}{{end}}{{if eq $defaultBranch ""}}{{$defaultBranch = $.root.Repository.DefaultBranch}}{{end}} -{{$type := ""}}{{if and .root.IsViewTag (not .noTag)}}{{$type = "tag"}}{{else if .root.IsViewBranch}}{{$type = "branch"}}{{else}}{{$type = "tree"}}{{end}} +{{$defaultBranch := $.root.BranchName}} +{{if and .root.IsViewTag (not .noTag)}} + {{$defaultBranch = .root.TagName}} +{{end}} +{{if eq $defaultBranch ""}} + {{$defaultBranch = $.root.Repository.DefaultBranch}} +{{end}} + +{{$type := ""}} +{{if and .root.IsViewTag (not .noTag)}} + {{$type = "tag"}} +{{else if .root.IsViewBranch}} + {{$type = "branch"}} +{{else}} + {{$type = "tree"}} +{{end}} + {{$showBranchesInDropdown := not .root.HideBranchesInDropdown}} <script type="module"> @@ -30,8 +44,8 @@ 'defaultBranch': {{$defaultBranch}}, 'branchURLPrefix': '{{if .branchURLPrefix}}{{.branchURLPrefix}}{{else}}{{$.root.RepoLink}}/{{if $.root.PageIsCommits}}commits{{else}}src{{end}}/branch/{{end}}', 'branchURLSuffix': '{{if .branchURLSuffix}}{{.branchURLSuffix}}{{else}}{{if $.root.TreePath}}/{{PathEscapeSegments $.root.TreePath}}{{end}}{{end}}', - 'tagURLPrefix': '{{if .tagURLPrefix}}{{.tagURLPrefix}}{{else if $release}}{{$.root.RepoLink}}/compare/{{else}}{{$.root.RepoLink}}/{{if $.root.PageIsCommits}}commits{{else}}src{{end}}/tag/{{end}}', - 'tagURLSuffix': '{{if .tagURLSuffix}}{{.tagURLSuffix}}{{else if $release}}...{{if $release.IsDraft}}{{PathEscapeSegments $release.Target}}{{else}}{{if $release.TagName}}{{PathEscapeSegments $release.TagName}}{{else}}{{PathEscapeSegments $release.Sha1}}{{end}}{{end}}{{else}}{{if $.root.TreePath}}/{{PathEscapeSegments $.root.TreePath}}{{end}}{{end}}', + 'tagURLPrefix': '{{if .tagURLPrefix}}{{.tagURLPrefix}}{{else if .release}}{{$.root.RepoLink}}/compare/{{else}}{{$.root.RepoLink}}/{{if $.root.PageIsCommits}}commits{{else}}src{{end}}/tag/{{end}}', + 'tagURLSuffix': '{{if .tagURLSuffix}}{{.tagURLSuffix}}{{else if .release}}...{{if .release.IsDraft}}{{PathEscapeSegments .release.Target}}{{else}}{{if .release.TagName}}{{PathEscapeSegments .release.TagName}}{{else}}{{PathEscapeSegments .release.Sha1}}{{end}}{{end}}{{else}}{{if $.root.TreePath}}/{{PathEscapeSegments $.root.TreePath}}{{end}}{{end}}', 'repoLink': {{.root.RepoLink}}, 'treePath': {{.root.TreePath}}, 'branchNameSubURL': {{.root.BranchNameSubURL}}, @@ -46,71 +60,23 @@ window.config.pageData.branchDropdownDataList.push(data); </script> -<div class="fitted item choose reference"> +<div class="fitted item js-branch-tag-selector"> + {{/* show dummy elements before Vue componment is mounted, this code must match the code in BranchTagSelector.vue */}} <div class="ui floating filter dropdown custom"> - <button class="branch-dropdown-button gt-ellipsis ui basic small compact button gt-df" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible"> + <button class="branch-dropdown-button gt-ellipsis ui basic small compact button gt-df"> <span class="text gt-df gt-ac gt-mr-2"> - {{/* v-cloak is used to hide unnecessary elements before Vue componment is mounted */}} - <span v-cloak v-if="release">${ textReleaseCompare }</span> - <span :class="{visible: isViewTag}" v-if="isViewTag" {{if not (eq $type "tag")}}v-cloak{{end}}>{{svg "octicon-tag"}}</span> - <span :class="{visible: isViewBranch}" v-if="isViewBranch" {{if not (eq $type "branch")}}v-cloak{{end}}>{{svg "octicon-git-branch"}}</span> - <span :class="{visible: isViewTree}" v-if="isViewTree" {{if not (eq $type "tree")}}v-cloak{{end}}>{{svg "octicon-git-branch"}}</span> - <strong ref="dropdownRefName" class="gt-ml-3">{{if and .root.IsViewTag (not .noTag)}}{{.root.TagName}}{{else if .root.IsViewBranch}}{{.root.BranchName}}{{else}}{{ShortSha .root.CommitID}}{{end}}</strong> + {{if .release}} + {{.root.locale.Tr "repo.release.compare"}} + {{else}} + {{if eq $type "tag"}} + {{svg "octicon-tag"}} + {{else}} + {{svg "octicon-git-branch"}} + {{end}} + <strong ref="dropdownRefName" class="gt-ml-3">{{if and .root.IsViewTag (not .noTag)}}{{.root.TagName}}{{else if .root.IsViewBranch}}{{.root.BranchName}}{{else}}{{ShortSha .root.CommitID}}{{end}}</strong> + {{end}} </span> {{svg "octicon-triangle-down" 14 "dropdown icon"}} </button> - <div class="menu transition" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak> - <div class="ui icon search input"> - <i class="icon gt-df gt-ac gt-jc gt-m-0">{{svg "octicon-filter" 16}}</i> - <input name="search" ref="searchField" autocomplete="off" v-model="searchTerm" @keydown="keydown($event)" :placeholder="searchFieldPlaceholder"> - </div> - <template v-if="showBranchesInDropdown"> - <div class="header branch-tag-choice"> - <div class="ui grid"> - <div class="two column row"> - <a class="reference column" href="#" @click="createTag = false; mode = 'branches'; focusSearchField()"> - <span class="text" :class="{black: mode === 'branches'}"> - {{svg "octicon-git-branch" 16 "gt-mr-2"}}${ textBranches } - </span> - </a> - <template v-if="!noTag"> - <a class="reference column" href="#" @click="createTag = true; mode = 'tags'; focusSearchField()"> - <span class="text" :class="{black: mode === 'tags'}"> - {{svg "octicon-tag" 16 "gt-mr-2"}}${ textTags } - </span> - </a> - </template> - </div> - </div> - </div> - </template> - <div class="scrolling menu" ref="scrollContainer"> - <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">${ item.name }</div> - <div class="item" v-if="showCreateNewBranch" :class="{active: active === filteredItems.length}" :ref="'listItem' + filteredItems.length"> - <a href="#" @click="createNewBranch()"> - <div v-show="createTag"> - <i class="reference tags icon"></i> - <span v-html="textCreateTag.replace('%s', searchTerm)"></span> - </div> - <div v-show="!createTag"> - {{svg "octicon-git-branch"}} - <span v-html="textCreateBranch.replace('%s', searchTerm)"></span> - </div> - <div class="text small"> - <span v-if="isViewBranch || release">${ textCreateBranchFrom.replace('%s', branchName) }</span> - <span v-else-if="isViewTag">${ textCreateBranchFrom.replace('%s', tagName) }</span> - <span v-else>${ textCreateBranchFrom.replace('%s', commitIdShort) }</span> - </div> - </a> - <form ref="newBranchForm" action="{{.root.RepoLink}}/branches/_new/{{.root.BranchNameSubURL}}" method="post"> - <input type="hidden" name="_csrf" :value="csrfToken"> - <input type="hidden" name="new_branch_name" v-model="searchTerm"> - <input type="hidden" name="create_tag" v-model="createTag"> - <input type="hidden" name="current_path" v-model="treePath" v-if="treePath"> - </form> - </div> - </div> - <div class="message" v-if="showNoResults">${ noResults }</div> - </div> </div> </div> diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue index e295910fd0..cc76ab627f 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -73,7 +73,7 @@ <li v-for="repo in repos" :class="{'private': repo.private || repo.internal}" :key="repo.id"> <a class="repo-list-link gt-df gt-ac gt-sb" :href="repo.link"> <div class="item-name gt-df gt-ac gt-f1 gt-mr-2"> - <svg-icon :name="repoIcon(repo)" size="16" class-name="gt-mr-2"/> + <svg-icon :name="repoIcon(repo)" :size="16" class-name="gt-mr-2"/> <div class="text gt-bold truncate gt-ml-1">{{ repo.full_name }}</div> <span v-if="repo.archived"> <svg-icon name="octicon-archive" :size="16" class-name="gt-ml-2"/> diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue index fc610d2194..4d8c14a76d 100644 --- a/web_src/js/components/PullRequestMergeForm.vue +++ b/web_src/js/components/PullRequestMergeForm.vue @@ -10,8 +10,8 @@ -d '{"context": "test/context", "description": "description", "state": "${state}", "target_url": "http://localhost"}' --> <div> - <!-- eslint-disable --> - <div v-if="mergeForm.hasPendingPullRequestMerge" v-html="mergeForm.hasPendingPullRequestMergeTip" class="ui info message"></div> + <!-- eslint-disable-next-line vue/no-v-html --> + <div v-if="mergeForm.hasPendingPullRequestMerge" v-html="mergeForm.hasPendingPullRequestMergeTip" class="ui info message"/> <div class="ui form" v-if="showActionForm"> <form :action="mergeForm.baseLink+'/merge'" method="post"> @@ -30,7 +30,8 @@ <button @click.prevent="clearMergeMessage" class="ui tertiary button"> {{ mergeForm.textClearMergeMessage }} </button> - <div class="ui label"><!-- TODO: Convert to tooltip once we can use tooltips in Vue templates --> + <div class="ui label"> + <!-- TODO: Convert to tooltip once we can use tooltips in Vue templates --> {{ mergeForm.textClearMergeMessageHint }} </div> </template> diff --git a/web_src/js/components/RepoBranchTagDropdown.js b/web_src/js/components/RepoBranchTagDropdown.js deleted file mode 100644 index a8945b82d1..0000000000 --- a/web_src/js/components/RepoBranchTagDropdown.js +++ /dev/null @@ -1,208 +0,0 @@ -import {createApp, nextTick} from 'vue'; -import $ from 'jquery'; - -export function initRepoBranchTagDropdown(selector) { - $(selector).each(function (dropdownIndex, elRoot) { - const data = { - csrfToken: window.config.csrfToken, - items: [], - searchTerm: '', - menuVisible: false, - createTag: false, - release: null, - - isViewTag: false, - isViewBranch: false, - isViewTree: false, - - active: 0, - - ...window.config.pageData.branchDropdownDataList[dropdownIndex], - }; - - // the "data.defaultBranch" is ambiguous, it could be "branch name" or "tag name" - - if (data.showBranchesInDropdown && data.branches) { - for (const branch of data.branches) { - data.items.push({name: branch, url: branch, branch: true, tag: false, selected: branch === data.defaultBranch}); - } - } - if (!data.noTag && data.tags) { - for (const tag of data.tags) { - if (data.release) { - data.items.push({name: tag, url: tag, branch: false, tag: true, selected: tag === data.release.tagName}); - } else { - data.items.push({name: tag, url: tag, branch: false, tag: true, selected: tag === data.defaultBranch}); - } - } - } - - const view = createApp({ - delimiters: ['${', '}'], - data() { - return data; - }, - computed: { - filteredItems() { - const items = this.items.filter((item) => { - return ((this.mode === 'branches' && item.branch) || (this.mode === 'tags' && item.tag)) && - (!this.searchTerm || item.name.toLowerCase().includes(this.searchTerm.toLowerCase())); - }); - - // no idea how to fix this so linting rule is disabled instead - this.active = (items.length === 0 && this.showCreateNewBranch ? 0 : -1); // eslint-disable-line vue/no-side-effects-in-computed-properties - return items; - }, - showNoResults() { - return this.filteredItems.length === 0 && !this.showCreateNewBranch; - }, - showCreateNewBranch() { - if (this.disableCreateBranch || !this.searchTerm) { - return false; - } - - return this.items.filter((item) => item.name.toLowerCase() === this.searchTerm.toLowerCase()).length === 0; - } - }, - - watch: { - menuVisible(visible) { - if (visible) { - this.focusSearchField(); - } - } - }, - - beforeMount() { - switch (data.viewType) { - case 'tree': - this.isViewTree = true; - break; - case 'tag': - this.isViewTag = true; - break; - default: - this.isViewBranch = true; - break; - } - - document.body.addEventListener('click', (event) => { - if (elRoot.contains(event.target)) return; - if (this.menuVisible) { - this.menuVisible = false; - } - }); - }, - - methods: { - selectItem(item) { - const prev = this.getSelected(); - if (prev !== null) { - prev.selected = false; - } - item.selected = true; - const url = (item.tag) ? this.tagURLPrefix + item.url + this.tagURLSuffix : this.branchURLPrefix + item.url + this.branchURLSuffix; - if (!this.branchForm) { - window.location.href = url; - } else { - this.isViewTree = false; - this.isViewTag = false; - this.isViewBranch = false; - this.$refs.dropdownRefName.textContent = item.name; - if (this.setAction) { - $(`#${this.branchForm}`).attr('action', url); - } else { - $(`#${this.branchForm} input[name="refURL"]`).val(url); - } - $(`#${this.branchForm} input[name="ref"]`).val(item.name); - if (item.tag) { - this.isViewTag = true; - $(`#${this.branchForm} input[name="refType"]`).val('tag'); - } else { - this.isViewBranch = true; - $(`#${this.branchForm} input[name="refType"]`).val('branch'); - } - if (this.submitForm) { - $(`#${this.branchForm}`).trigger('submit'); - } - this.menuVisible = false; - } - }, - createNewBranch() { - if (!this.showCreateNewBranch) return; - $(this.$refs.newBranchForm).trigger('submit'); - }, - focusSearchField() { - nextTick(() => { - this.$refs.searchField.focus(); - }); - }, - getSelected() { - for (let i = 0, j = this.items.length; i < j; ++i) { - if (this.items[i].selected) return this.items[i]; - } - return null; - }, - getSelectedIndexInFiltered() { - for (let i = 0, j = this.filteredItems.length; i < j; ++i) { - if (this.filteredItems[i].selected) return i; - } - return -1; - }, - scrollToActive() { - let el = this.$refs[`listItem${this.active}`]; - if (!el || !el.length) return; - if (Array.isArray(el)) { - el = el[0]; - } - - const cont = this.$refs.scrollContainer; - if (el.offsetTop < cont.scrollTop) { - cont.scrollTop = el.offsetTop; - } else if (el.offsetTop + el.clientHeight > cont.scrollTop + cont.clientHeight) { - cont.scrollTop = el.offsetTop + el.clientHeight - cont.clientHeight; - } - }, - keydown(event) { - if (event.keyCode === 40) { // arrow down - event.preventDefault(); - - if (this.active === -1) { - this.active = this.getSelectedIndexInFiltered(); - } - - if (this.active + (this.showCreateNewBranch ? 0 : 1) >= this.filteredItems.length) { - return; - } - this.active++; - this.scrollToActive(); - } else if (event.keyCode === 38) { // arrow up - event.preventDefault(); - - if (this.active === -1) { - this.active = this.getSelectedIndexInFiltered(); - } - - if (this.active <= 0) { - return; - } - this.active--; - this.scrollToActive(); - } else if (event.keyCode === 13) { // enter - event.preventDefault(); - - if (this.active >= this.filteredItems.length) { - this.createNewBranch(); - } else if (this.active >= 0) { - this.selectItem(this.filteredItems[this.active]); - } - } else if (event.keyCode === 27) { // escape - event.preventDefault(); - this.menuVisible = false; - } - } - } - }); - view.mount(this); - }); -} diff --git a/web_src/js/components/RepoBranchTagSelector.vue b/web_src/js/components/RepoBranchTagSelector.vue new file mode 100644 index 0000000000..6a65eeec6f --- /dev/null +++ b/web_src/js/components/RepoBranchTagSelector.vue @@ -0,0 +1,293 @@ +<template> + <div class="ui floating filter dropdown custom"> + <button class="branch-dropdown-button gt-ellipsis ui basic small compact button gt-df" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible"> + <span class="text gt-df gt-ac gt-mr-2"> + <template v-if="release">{{ textReleaseCompare }}</template> + <template v-else> + <svg-icon v-if="isViewTag" name="octicon-tag" /> + <svg-icon v-else name="octicon-git-branch"/> + <strong ref="dropdownRefName" class="gt-ml-3">{{ refNameText }}</strong> + </template> + </span> + <svg-icon name="octicon-triangle-down" :size="14" class-name="dropdown icon"/> + </button> + <div class="menu transition" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak> + <div class="ui icon search input"> + <i class="icon gt-df gt-ac gt-jc gt-m-0"><svg-icon name="octicon-filter" :size="16"/></i> + <input name="search" ref="searchField" autocomplete="off" v-model="searchTerm" @keydown="keydown($event)" :placeholder="searchFieldPlaceholder"> + </div> + <template v-if="showBranchesInDropdown"> + <div class="header branch-tag-choice"> + <div class="ui grid"> + <div class="two column row"> + <a class="reference column" href="#" @click="createTag = false; mode = 'branches'; focusSearchField()"> + <span class="text" :class="{black: mode === 'branches'}"> + <svg-icon name="octicon-git-branch" :size="16" class-name="gt-mr-2"/>{{ textBranches }} + </span> + </a> + <template v-if="!noTag"> + <a class="reference column" href="#" @click="createTag = true; mode = 'tags'; focusSearchField()"> + <span class="text" :class="{black: mode === 'tags'}"> + <svg-icon name="octicon-tag" :size="16" class-name="gt-mr-2"/>{{ textTags }} + </span> + </a> + </template> + </div> + </div> + </div> + </template> + <div class="scrolling menu" ref="scrollContainer"> + <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"> + {{ item.name }} + </div> + <div class="item" v-if="showCreateNewBranch" :class="{active: active === filteredItems.length}" :ref="'listItem' + filteredItems.length"> + <a href="#" @click="createNewBranch()"> + <div v-show="createTag"> + <i class="reference tags icon"/> + <!-- eslint-disable-next-line vue/no-v-html --> + <span v-html="textCreateTag.replace('%s', searchTerm)"/> + </div> + <div v-show="!createTag"> + <svg-icon name="octicon-git-branch"/> + <!-- eslint-disable-next-line vue/no-v-html --> + <span v-html="textCreateBranch.replace('%s', searchTerm)"/> + </div> + <div class="text small"> + <span v-if="isViewBranch || release">{{ textCreateBranchFrom.replace('%s', branchName) }}</span> + <span v-else-if="isViewTag">{{ textCreateBranchFrom.replace('%s', tagName) }}</span> + <span v-else>{{ textCreateBranchFrom.replace('%s', commitIdShort) }}</span> + </div> + </a> + <form ref="newBranchForm" :action="formActionUrl" method="post"> + <input type="hidden" name="_csrf" :value="csrfToken"> + <input type="hidden" name="new_branch_name" v-model="searchTerm"> + <input type="hidden" name="create_tag" v-model="createTag"> + <input type="hidden" name="current_path" v-model="treePath" v-if="treePath"> + </form> + </div> + </div> + <div class="message" v-if="showNoResults"> + {{ noResults }} + </div> + </div> + </div> +</template> + +<script> +import {createApp, nextTick} from 'vue'; +import $ from 'jquery'; +import {SvgIcon} from '../svg.js'; +import {pathEscapeSegments} from '../utils/url.js'; + +const sfc = { + components: {SvgIcon}, + + // no `data()`, at the moment, the `data()` is provided by the init code, which is not ideal and should be fixed in the future + + computed: { + filteredItems() { + const items = this.items.filter((item) => { + return ((this.mode === 'branches' && item.branch) || (this.mode === 'tags' && item.tag)) && + (!this.searchTerm || item.name.toLowerCase().includes(this.searchTerm.toLowerCase())); + }); + + // TODO: fix this anti-pattern: side-effects-in-computed-properties + this.active = (items.length === 0 && this.showCreateNewBranch ? 0 : -1); + return items; + }, + showNoResults() { + return this.filteredItems.length === 0 && !this.showCreateNewBranch; + }, + showCreateNewBranch() { + if (this.disableCreateBranch || !this.searchTerm) { + return false; + } + return this.items.filter((item) => item.name.toLowerCase() === this.searchTerm.toLowerCase()).length === 0; + }, + formActionUrl() { + return `${this.repoLink}/branches/_new/${pathEscapeSegments(this.branchNameSubURL)}`; + }, + }, + + watch: { + menuVisible(visible) { + if (visible) { + this.focusSearchField(); + } + } + }, + + beforeMount() { + if (this.viewType === 'tree') { + this.isViewTree = true; + this.refNameText = this.commitIdShort; + } else if (this.viewType === 'tag') { + this.isViewTag = true; + this.refNameText = this.tagName; + } else { + this.isViewBranch = true; + this.refNameText = this.branchName; + } + + document.body.addEventListener('click', (event) => { + if (this.$el.contains(event.target)) return; + if (this.menuVisible) { + this.menuVisible = false; + } + }); + }, + + methods: { + selectItem(item) { + const prev = this.getSelected(); + if (prev !== null) { + prev.selected = false; + } + item.selected = true; + const url = (item.tag) ? this.tagURLPrefix + item.url + this.tagURLSuffix : this.branchURLPrefix + item.url + this.branchURLSuffix; + if (!this.branchForm) { + window.location.href = url; + } else { + this.isViewTree = false; + this.isViewTag = false; + this.isViewBranch = false; + this.$refs.dropdownRefName.textContent = item.name; + if (this.setAction) { + $(`#${this.branchForm}`).attr('action', url); + } else { + $(`#${this.branchForm} input[name="refURL"]`).val(url); + } + $(`#${this.branchForm} input[name="ref"]`).val(item.name); + if (item.tag) { + this.isViewTag = true; + $(`#${this.branchForm} input[name="refType"]`).val('tag'); + } else { + this.isViewBranch = true; + $(`#${this.branchForm} input[name="refType"]`).val('branch'); + } + if (this.submitForm) { + $(`#${this.branchForm}`).trigger('submit'); + } + this.menuVisible = false; + } + }, + createNewBranch() { + if (!this.showCreateNewBranch) return; + $(this.$refs.newBranchForm).trigger('submit'); + }, + focusSearchField() { + nextTick(() => { + this.$refs.searchField.focus(); + }); + }, + getSelected() { + for (let i = 0, j = this.items.length; i < j; ++i) { + if (this.items[i].selected) return this.items[i]; + } + return null; + }, + getSelectedIndexInFiltered() { + for (let i = 0, j = this.filteredItems.length; i < j; ++i) { + if (this.filteredItems[i].selected) return i; + } + return -1; + }, + scrollToActive() { + let el = this.$refs[`listItem${this.active}`]; + if (!el || !el.length) return; + if (Array.isArray(el)) { + el = el[0]; + } + + const cont = this.$refs.scrollContainer; + if (el.offsetTop < cont.scrollTop) { + cont.scrollTop = el.offsetTop; + } else if (el.offsetTop + el.clientHeight > cont.scrollTop + cont.clientHeight) { + cont.scrollTop = el.offsetTop + el.clientHeight - cont.clientHeight; + } + }, + keydown(event) { + if (event.keyCode === 40) { // arrow down + event.preventDefault(); + + if (this.active === -1) { + this.active = this.getSelectedIndexInFiltered(); + } + + if (this.active + (this.showCreateNewBranch ? 0 : 1) >= this.filteredItems.length) { + return; + } + this.active++; + this.scrollToActive(); + } else if (event.keyCode === 38) { // arrow up + event.preventDefault(); + + if (this.active === -1) { + this.active = this.getSelectedIndexInFiltered(); + } + + if (this.active <= 0) { + return; + } + this.active--; + this.scrollToActive(); + } else if (event.keyCode === 13) { // enter + event.preventDefault(); + + if (this.active >= this.filteredItems.length) { + this.createNewBranch(); + } else if (this.active >= 0) { + this.selectItem(this.filteredItems[this.active]); + } + } else if (event.keyCode === 27) { // escape + event.preventDefault(); + this.menuVisible = false; + } + } + } +}; + +export function initRepoBranchTagSelector(selector) { + for (const [elIndex, elRoot] of document.querySelectorAll(selector).entries()) { + const data = { + csrfToken: window.config.csrfToken, + items: [], + searchTerm: '', + refNameText: '', + menuVisible: false, + createTag: false, + release: null, + + isViewTag: false, + isViewBranch: false, + isViewTree: false, + + active: 0, + + ...window.config.pageData.branchDropdownDataList[elIndex], + }; + + // the "data.defaultBranch" is ambiguous, it could be "branch name" or "tag name" + + if (data.showBranchesInDropdown && data.branches) { + for (const branch of data.branches) { + data.items.push({name: branch, url: branch, branch: true, tag: false, selected: branch === data.defaultBranch}); + } + } + if (!data.noTag && data.tags) { + for (const tag of data.tags) { + if (data.release) { + data.items.push({name: tag, url: tag, branch: false, tag: true, selected: tag === data.release.tagName}); + } else { + data.items.push({name: tag, url: tag, branch: false, tag: true, selected: tag === data.defaultBranch}); + } + } + } + + const comp = {...sfc, data() { return data }}; + createApp(comp).mount(elRoot); + } +} + +export default sfc; // activate IDE's Vue plugin +</script> diff --git a/web_src/js/features/repo-findfile.js b/web_src/js/features/repo-findfile.js index 093f90fe8e..078c822aa2 100644 --- a/web_src/js/features/repo-findfile.js +++ b/web_src/js/features/repo-findfile.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import {svg} from '../svg.js'; import {toggleElem} from '../utils/dom.js'; +import {pathEscapeSegments} from '../utils/url.js'; const {csrf} = window.config; @@ -73,10 +74,6 @@ export function filterRepoFilesWeighted(files, filter) { return filterResult; } -export function escapePath(s) { - return s.split('/').map(encodeURIComponent).join('/'); -} - function filterRepoFiles(filter) { const treeLink = $repoFindFileInput.attr('data-url-tree-link'); $repoFindFileTableBody.empty(); @@ -88,7 +85,7 @@ function filterRepoFiles(filter) { for (const r of filterResult) { const $row = $(tmplRow); const $a = $row.find('a'); - $a.attr('href', `${treeLink}/${escapePath(r.matchResult.join(''))}`); + $a.attr('href', `${treeLink}/${pathEscapeSegments(r.matchResult.join(''))}`); const $octiconFile = $(svg('octicon-file')).addClass('gt-mr-3'); $a.append($octiconFile); // if the target file path is "abc/xyz", to search "bx", then the matchResult is ['a', 'b', 'c/', 'x', 'yz'] diff --git a/web_src/js/features/repo-findfile.test.js b/web_src/js/features/repo-findfile.test.js index 5032185396..a90b0bf0a2 100644 --- a/web_src/js/features/repo-findfile.test.js +++ b/web_src/js/features/repo-findfile.test.js @@ -1,5 +1,5 @@ import {describe, expect, test} from 'vitest'; -import {strSubMatch, calcMatchedWeight, filterRepoFilesWeighted, escapePath} from './repo-findfile.js'; +import {strSubMatch, calcMatchedWeight, filterRepoFilesWeighted} from './repo-findfile.js'; describe('Repo Find Files', () => { test('strSubMatch', () => { @@ -32,9 +32,4 @@ describe('Repo Find Files', () => { expect(res).toHaveLength(2); expect(res[0].matchResult).toEqual(['', 'word', '.txt']); }); - - test('escapePath', () => { - expect(escapePath('a/b/c')).toEqual('a/b/c'); - expect(escapePath('a/b/ c')).toEqual('a/b/%20c'); - }); }); diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js index 5346a0d274..4454b92ccc 100644 --- a/web_src/js/features/repo-legacy.js +++ b/web_src/js/features/repo-legacy.js @@ -11,7 +11,7 @@ import { import {initUnicodeEscapeButton} from './repo-unicode-escape.js'; import {svg} from '../svg.js'; import {htmlEscape} from 'escape-goat'; -import {initRepoBranchTagDropdown} from '../components/RepoBranchTagDropdown.js'; +import {initRepoBranchTagSelector} from '../components/RepoBranchTagSelector.vue'; import { initRepoCloneLink, initRepoCommonBranchOrTagDropdown, initRepoCommonFilterSearchDropdown, initRepoCommonLanguageStats, @@ -486,7 +486,7 @@ export function initRepository() { // File list and commits if ($('.repository.file.list').length > 0 || $('.branch-dropdown').length > 0 || $('.repository.commits').length > 0 || $('.repository.release').length > 0) { - initRepoBranchTagDropdown('.choose.reference .ui.dropdown'); + initRepoBranchTagSelector('.js-branch-tag-selector'); } // Wiki diff --git a/web_src/js/svg.js b/web_src/js/svg.js index 9eabca3fd3..e431ca57e6 100644 --- a/web_src/js/svg.js +++ b/web_src/js/svg.js @@ -1,3 +1,4 @@ +import {h} from 'vue'; import octiconChevronDown from '../../public/img/svg/octicon-chevron-down.svg'; import octiconChevronRight from '../../public/img/svg/octicon-chevron-right.svg'; import octiconClock from '../../public/img/svg/octicon-clock.svg'; @@ -40,6 +41,8 @@ import giteaDoubleChevronLeft from '../../public/img/svg/gitea-double-chevron-le import giteaDoubleChevronRight from '../../public/img/svg/gitea-double-chevron-right.svg'; import octiconChevronLeft from '../../public/img/svg/octicon-chevron-left.svg'; import octiconOrganization from '../../public/img/svg/octicon-organization.svg'; +import octiconTag from '../../public/img/svg/octicon-tag.svg'; +import octiconGitBranch from '../../public/img/svg/octicon-git-branch.svg'; const svgs = { 'octicon-blocked': octiconBlocked, @@ -84,9 +87,13 @@ const svgs = { 'gitea-double-chevron-right': giteaDoubleChevronRight, 'octicon-chevron-left': octiconChevronLeft, 'octicon-organization': octiconOrganization, + 'octicon-tag': octiconTag, + 'octicon-git-branch': octiconGitBranch, }; -// TODO: use a more general approach to access SVG icons. At the moment, developers must check, pick and fill the names manually, most of the SVG icons in assets couldn't be used directly. +// TODO: use a more general approach to access SVG icons. +// At the moment, developers must check, pick and fill the names manually, +// most of the SVG icons in assets couldn't be used directly. const parser = new DOMParser(); const serializer = new XMLSerializer(); @@ -112,12 +119,7 @@ export const SvgIcon = { size: {type: Number, default: 16}, className: {type: String, default: ''}, }, - - computed: { - svg() { - return svg(this.name, this.size, this.className); - }, + render() { + return h('span', {innerHTML: svg(this.name, this.size, this.className)}); }, - - template: `<span v-html="svg" />` }; diff --git a/web_src/js/utils/url.js b/web_src/js/utils/url.js new file mode 100644 index 0000000000..a40737ca6f --- /dev/null +++ b/web_src/js/utils/url.js @@ -0,0 +1,3 @@ +export function pathEscapeSegments(s) { + return s.split('/').map(encodeURIComponent).join('/'); +} diff --git a/web_src/js/utils/url.test.js b/web_src/js/utils/url.test.js new file mode 100644 index 0000000000..ef2ffaa5f9 --- /dev/null +++ b/web_src/js/utils/url.test.js @@ -0,0 +1,7 @@ +import {expect, test} from 'vitest'; +import {pathEscapeSegments} from './url.js'; + +test('pathEscapeSegments', () => { + expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c'); + expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c'); +}); diff --git a/web_src/less/_base.less b/web_src/less/_base.less index 1cf65e784c..cabf707aad 100644 --- a/web_src/less/_base.less +++ b/web_src/less/_base.less @@ -1924,10 +1924,6 @@ footer { display: block; } -[v-cloak] { - display: none !important; -} - .repos-search { padding-bottom: 0 !important; } diff --git a/web_src/less/_repository.less b/web_src/less/_repository.less index 0069a31cec..c842c4ca65 100644 --- a/web_src/less/_repository.less +++ b/web_src/less/_repository.less @@ -222,12 +222,6 @@ font-size: 1.2em; } - .choose.reference { - .header .icon { - font-size: 1.4em; - } - } - .repo-path { .section, diff --git a/webpack.config.js b/webpack.config.js index 245791e7ea..46bdd6acfa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -196,6 +196,10 @@ export default { ], }, plugins: [ + new webpack.DefinePlugin({ + __VUE_OPTIONS_API__: true, // at the moment, many Vue components still use the Vue Options API + __VUE_PROD_DEVTOOLS__: false, // do not enable devtools support in production + }), new VueLoaderPlugin(), new MiniCssExtractPlugin({ filename: 'css/[name].css', |