code.gitea.io/gitea@v1.22.3/web_src/js/components/DashboardRepoList.vue (about) 1 <script> 2 import {createApp, nextTick} from 'vue'; 3 import $ from 'jquery'; 4 import {SvgIcon} from '../svg.js'; 5 import {GET} from '../modules/fetch.js'; 6 7 const {appSubUrl, assetUrlPrefix, pageData} = window.config; 8 9 // make sure this matches templates/repo/commit_status.tmpl 10 const commitStatus = { 11 pending: {name: 'octicon-dot-fill', color: 'yellow'}, 12 success: {name: 'octicon-check', color: 'green'}, 13 error: {name: 'gitea-exclamation', color: 'red'}, 14 failure: {name: 'octicon-x', color: 'red'}, 15 warning: {name: 'gitea-exclamation', color: 'yellow'}, 16 }; 17 18 const sfc = { 19 components: {SvgIcon}, 20 data() { 21 const params = new URLSearchParams(window.location.search); 22 const tab = params.get('repo-search-tab') || 'repos'; 23 const reposFilter = params.get('repo-search-filter') || 'all'; 24 const privateFilter = params.get('repo-search-private') || 'both'; 25 const archivedFilter = params.get('repo-search-archived') || 'unarchived'; 26 const searchQuery = params.get('repo-search-query') || ''; 27 const page = Number(params.get('repo-search-page')) || 1; 28 29 return { 30 tab, 31 repos: [], 32 reposTotalCount: 0, 33 reposFilter, 34 archivedFilter, 35 privateFilter, 36 page, 37 finalPage: 1, 38 searchQuery, 39 isLoading: false, 40 staticPrefix: assetUrlPrefix, 41 counts: {}, 42 repoTypes: { 43 all: { 44 searchMode: '', 45 }, 46 forks: { 47 searchMode: 'fork', 48 }, 49 mirrors: { 50 searchMode: 'mirror', 51 }, 52 sources: { 53 searchMode: 'source', 54 }, 55 collaborative: { 56 searchMode: 'collaborative', 57 }, 58 }, 59 textArchivedFilterTitles: {}, 60 textPrivateFilterTitles: {}, 61 62 organizations: [], 63 isOrganization: true, 64 canCreateOrganization: false, 65 organizationsTotalCount: 0, 66 organizationId: 0, 67 68 subUrl: appSubUrl, 69 ...pageData.dashboardRepoList, 70 activeIndex: -1, // don't select anything at load, first cursor down will select 71 }; 72 }, 73 74 computed: { 75 showMoreReposLink() { 76 return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`]; 77 }, 78 searchURL() { 79 return `${this.subUrl}/repo/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=${this.searchQuery 80 }&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode 81 }${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : '' 82 }${this.privateFilter === 'private' ? '&is_private=true' : ''}${this.privateFilter === 'public' ? '&is_private=false' : '' 83 }`; 84 }, 85 repoTypeCount() { 86 return this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`]; 87 }, 88 checkboxArchivedFilterTitle() { 89 return this.textArchivedFilterTitles[this.archivedFilter]; 90 }, 91 checkboxArchivedFilterProps() { 92 return {checked: this.archivedFilter === 'archived', indeterminate: this.archivedFilter === 'both'}; 93 }, 94 checkboxPrivateFilterTitle() { 95 return this.textPrivateFilterTitles[this.privateFilter]; 96 }, 97 checkboxPrivateFilterProps() { 98 return {checked: this.privateFilter === 'private', indeterminate: this.privateFilter === 'both'}; 99 }, 100 }, 101 102 mounted() { 103 const el = document.getElementById('dashboard-repo-list'); 104 this.changeReposFilter(this.reposFilter); 105 $(el).find('.dropdown').dropdown(); 106 nextTick(() => { 107 this.$refs.search.focus(); 108 }); 109 110 this.textArchivedFilterTitles = { 111 'archived': this.textShowOnlyArchived, 112 'unarchived': this.textShowOnlyUnarchived, 113 'both': this.textShowBothArchivedUnarchived, 114 }; 115 116 this.textPrivateFilterTitles = { 117 'private': this.textShowOnlyPrivate, 118 'public': this.textShowOnlyPublic, 119 'both': this.textShowBothPrivatePublic, 120 }; 121 }, 122 123 methods: { 124 changeTab(t) { 125 this.tab = t; 126 this.updateHistory(); 127 }, 128 129 changeReposFilter(filter) { 130 this.reposFilter = filter; 131 this.repos = []; 132 this.page = 1; 133 this.counts[`${filter}:${this.archivedFilter}:${this.privateFilter}`] = 0; 134 this.searchRepos(); 135 }, 136 137 updateHistory() { 138 const params = new URLSearchParams(window.location.search); 139 140 if (this.tab === 'repos') { 141 params.delete('repo-search-tab'); 142 } else { 143 params.set('repo-search-tab', this.tab); 144 } 145 146 if (this.reposFilter === 'all') { 147 params.delete('repo-search-filter'); 148 } else { 149 params.set('repo-search-filter', this.reposFilter); 150 } 151 152 if (this.privateFilter === 'both') { 153 params.delete('repo-search-private'); 154 } else { 155 params.set('repo-search-private', this.privateFilter); 156 } 157 158 if (this.archivedFilter === 'unarchived') { 159 params.delete('repo-search-archived'); 160 } else { 161 params.set('repo-search-archived', this.archivedFilter); 162 } 163 164 if (this.searchQuery === '') { 165 params.delete('repo-search-query'); 166 } else { 167 params.set('repo-search-query', this.searchQuery); 168 } 169 170 if (this.page === 1) { 171 params.delete('repo-search-page'); 172 } else { 173 params.set('repo-search-page', `${this.page}`); 174 } 175 176 const queryString = params.toString(); 177 if (queryString) { 178 window.history.replaceState({}, '', `?${queryString}`); 179 } else { 180 window.history.replaceState({}, '', window.location.pathname); 181 } 182 }, 183 184 toggleArchivedFilter() { 185 if (this.archivedFilter === 'unarchived') { 186 this.archivedFilter = 'archived'; 187 } else if (this.archivedFilter === 'archived') { 188 this.archivedFilter = 'both'; 189 } else { // including both 190 this.archivedFilter = 'unarchived'; 191 } 192 this.page = 1; 193 this.repos = []; 194 this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; 195 this.searchRepos(); 196 }, 197 198 togglePrivateFilter() { 199 if (this.privateFilter === 'both') { 200 this.privateFilter = 'public'; 201 } else if (this.privateFilter === 'public') { 202 this.privateFilter = 'private'; 203 } else { // including private 204 this.privateFilter = 'both'; 205 } 206 this.page = 1; 207 this.repos = []; 208 this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; 209 this.searchRepos(); 210 }, 211 212 changePage(page) { 213 this.page = page; 214 if (this.page > this.finalPage) { 215 this.page = this.finalPage; 216 } 217 if (this.page < 1) { 218 this.page = 1; 219 } 220 this.repos = []; 221 this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; 222 this.searchRepos(); 223 }, 224 225 async searchRepos() { 226 this.isLoading = true; 227 228 const searchedMode = this.repoTypes[this.reposFilter].searchMode; 229 const searchedURL = this.searchURL; 230 const searchedQuery = this.searchQuery; 231 232 let response, json; 233 try { 234 if (!this.reposTotalCount) { 235 const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`; 236 response = await GET(totalCountSearchURL); 237 this.reposTotalCount = response.headers.get('X-Total-Count') ?? '?'; 238 } 239 240 response = await GET(searchedURL); 241 json = await response.json(); 242 } catch { 243 if (searchedURL === this.searchURL) { 244 this.isLoading = false; 245 } 246 return; 247 } 248 249 if (searchedURL === this.searchURL) { 250 this.repos = json.data.map((webSearchRepo) => { 251 return { 252 ...webSearchRepo.repository, 253 latest_commit_status_state: webSearchRepo.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status 254 latest_commit_status_state_link: webSearchRepo.latest_commit_status?.TargetURL, 255 locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status, 256 }; 257 }); 258 const count = response.headers.get('X-Total-Count'); 259 if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') { 260 this.reposTotalCount = count; 261 } 262 this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = count; 263 this.finalPage = Math.ceil(count / this.searchLimit); 264 this.updateHistory(); 265 this.isLoading = false; 266 } 267 }, 268 269 repoIcon(repo) { 270 if (repo.fork) { 271 return 'octicon-repo-forked'; 272 } else if (repo.mirror) { 273 return 'octicon-mirror'; 274 } else if (repo.template) { 275 return `octicon-repo-template`; 276 } else if (repo.private) { 277 return 'octicon-lock'; 278 } else if (repo.internal) { 279 return 'octicon-repo'; 280 } 281 return 'octicon-repo'; 282 }, 283 284 statusIcon(status) { 285 return commitStatus[status].name; 286 }, 287 288 statusColor(status) { 289 return commitStatus[status].color; 290 }, 291 292 reposFilterKeyControl(e) { 293 switch (e.key) { 294 case 'Enter': 295 document.querySelector('.repo-owner-name-list li.active a')?.click(); 296 break; 297 case 'ArrowUp': 298 if (this.activeIndex > 0) { 299 this.activeIndex--; 300 } else if (this.page > 1) { 301 this.changePage(this.page - 1); 302 this.activeIndex = this.searchLimit - 1; 303 } 304 break; 305 case 'ArrowDown': 306 if (this.activeIndex < this.repos.length - 1) { 307 this.activeIndex++; 308 } else if (this.page < this.finalPage) { 309 this.activeIndex = 0; 310 this.changePage(this.page + 1); 311 } 312 break; 313 case 'ArrowRight': 314 if (this.page < this.finalPage) { 315 this.changePage(this.page + 1); 316 } 317 break; 318 case 'ArrowLeft': 319 if (this.page > 1) { 320 this.changePage(this.page - 1); 321 } 322 break; 323 } 324 if (this.activeIndex === -1 || this.activeIndex > this.repos.length - 1) { 325 this.activeIndex = 0; 326 } 327 }, 328 }, 329 }; 330 331 export function initDashboardRepoList() { 332 const el = document.getElementById('dashboard-repo-list'); 333 if (el) { 334 createApp(sfc).mount(el); 335 } 336 } 337 338 export default sfc; // activate the IDE's Vue plugin 339 </script> 340 <template> 341 <div> 342 <div v-if="!isOrganization" class="ui two item menu"> 343 <a :class="{item: true, active: tab === 'repos'}" @click="changeTab('repos')">{{ textRepository }}</a> 344 <a :class="{item: true, active: tab === 'organizations'}" @click="changeTab('organizations')">{{ textOrganization }}</a> 345 </div> 346 <div v-show="tab === 'repos'" class="ui tab active list dashboard-repos"> 347 <h4 class="ui top attached header tw-flex tw-items-center"> 348 <div class="tw-flex-1 tw-flex tw-items-center"> 349 {{ textMyRepos }} 350 <span class="ui grey label tw-ml-2">{{ reposTotalCount }}</span> 351 </div> 352 <a class="tw-flex tw-items-center muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo"> 353 <svg-icon name="octicon-plus"/> 354 </a> 355 </h4> 356 <div class="ui attached segment repos-search"> 357 <div class="ui small fluid action left icon input"> 358 <input type="search" spellcheck="false" maxlength="255" @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" @keydown="reposFilterKeyControl" :placeholder="textSearchRepos"> 359 <i class="icon loading-icon-3px" :class="{'is-loading': isLoading}"><svg-icon name="octicon-search" :size="16"/></i> 360 <div class="ui dropdown icon button" :title="textFilter"> 361 <svg-icon name="octicon-filter" :size="16"/> 362 <div class="menu"> 363 <a class="item" @click="toggleArchivedFilter()"> 364 <div class="ui checkbox" ref="checkboxArchivedFilter" :title="checkboxArchivedFilterTitle"> 365 <!--the "hidden" is necessary to make the checkbox work without Fomantic UI js, 366 otherwise if the "input" handles click event for intermediate status, it breaks the internal state--> 367 <input type="checkbox" class="hidden" v-bind.prop="checkboxArchivedFilterProps"> 368 <label> 369 <svg-icon name="octicon-archive" :size="16" class-name="tw-mr-1"/> 370 {{ textShowArchived }} 371 </label> 372 </div> 373 </a> 374 <a class="item" @click="togglePrivateFilter()"> 375 <div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle"> 376 <input type="checkbox" class="hidden" v-bind.prop="checkboxPrivateFilterProps"> 377 <label> 378 <svg-icon name="octicon-lock" :size="16" class-name="tw-mr-1"/> 379 {{ textShowPrivate }} 380 </label> 381 </div> 382 </a> 383 </div> 384 </div> 385 </div> 386 <overflow-menu class="ui secondary pointing tabular borderless menu repos-filter"> 387 <div class="overflow-menu-items tw-justify-center"> 388 <a class="item" tabindex="0" :class="{active: reposFilter === 'all'}" @click="changeReposFilter('all')"> 389 {{ textAll }} 390 <div v-show="reposFilter === 'all'" class="ui circular mini grey label">{{ repoTypeCount }}</div> 391 </a> 392 <a class="item" tabindex="0" :class="{active: reposFilter === 'sources'}" @click="changeReposFilter('sources')"> 393 {{ textSources }} 394 <div v-show="reposFilter === 'sources'" class="ui circular mini grey label">{{ repoTypeCount }}</div> 395 </a> 396 <a class="item" tabindex="0" :class="{active: reposFilter === 'forks'}" @click="changeReposFilter('forks')"> 397 {{ textForks }} 398 <div v-show="reposFilter === 'forks'" class="ui circular mini grey label">{{ repoTypeCount }}</div> 399 </a> 400 <a class="item" tabindex="0" :class="{active: reposFilter === 'mirrors'}" @click="changeReposFilter('mirrors')" v-if="isMirrorsEnabled"> 401 {{ textMirrors }} 402 <div v-show="reposFilter === 'mirrors'" class="ui circular mini grey label">{{ repoTypeCount }}</div> 403 </a> 404 <a class="item" tabindex="0" :class="{active: reposFilter === 'collaborative'}" @click="changeReposFilter('collaborative')"> 405 {{ textCollaborative }} 406 <div v-show="reposFilter === 'collaborative'" class="ui circular mini grey label">{{ repoTypeCount }}</div> 407 </a> 408 </div> 409 </overflow-menu> 410 </div> 411 <div v-if="repos.length" class="ui attached table segment tw-rounded-b"> 412 <ul class="repo-owner-name-list"> 413 <li class="tw-flex tw-items-center tw-py-2" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id"> 414 <a class="repo-list-link muted" :href="repo.link"> 415 <svg-icon :name="repoIcon(repo)" :size="16" class-name="repo-list-icon"/> 416 <div class="text truncate">{{ repo.full_name }}</div> 417 <div v-if="repo.archived"> 418 <svg-icon name="octicon-archive" :size="16"/> 419 </div> 420 </a> 421 <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link" :data-tooltip-content="repo.locale_latest_commit_status_state"> 422 <!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl --> 423 <svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class-name="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/> 424 </a> 425 </li> 426 </ul> 427 <div v-if="showMoreReposLink" class="tw-text-center"> 428 <div class="divider tw-my-0"/> 429 <div class="ui borderless pagination menu narrow tw-my-2"> 430 <a 431 class="item navigation tw-py-1" :class="{'disabled': page === 1}" 432 @click="changePage(1)" :title="textFirstPage" 433 > 434 <svg-icon name="gitea-double-chevron-left" :size="16" class-name="tw-mr-1"/> 435 </a> 436 <a 437 class="item navigation tw-py-1" :class="{'disabled': page === 1}" 438 @click="changePage(page - 1)" :title="textPreviousPage" 439 > 440 <svg-icon name="octicon-chevron-left" :size="16" clsas-name="tw-mr-1"/> 441 </a> 442 <a class="active item tw-py-1">{{ page }}</a> 443 <a 444 class="item navigation" :class="{'disabled': page === finalPage}" 445 @click="changePage(page + 1)" :title="textNextPage" 446 > 447 <svg-icon name="octicon-chevron-right" :size="16" class-name="tw-ml-1"/> 448 </a> 449 <a 450 class="item navigation tw-py-1" :class="{'disabled': page === finalPage}" 451 @click="changePage(finalPage)" :title="textLastPage" 452 > 453 <svg-icon name="gitea-double-chevron-right" :size="16" class-name="tw-ml-1"/> 454 </a> 455 </div> 456 </div> 457 </div> 458 </div> 459 <div v-if="!isOrganization" v-show="tab === 'organizations'" class="ui tab active list dashboard-orgs"> 460 <h4 class="ui top attached header tw-flex tw-items-center"> 461 <div class="tw-flex-1 tw-flex tw-items-center"> 462 {{ textMyOrgs }} 463 <span class="ui grey label tw-ml-2">{{ organizationsTotalCount }}</span> 464 </div> 465 <a class="tw-flex tw-items-center muted" v-if="canCreateOrganization" :href="subUrl + '/org/create'" :data-tooltip-content="textNewOrg"> 466 <svg-icon name="octicon-plus"/> 467 </a> 468 </h4> 469 <div v-if="organizations.length" class="ui attached table segment tw-rounded-b"> 470 <ul class="repo-owner-name-list"> 471 <li class="tw-flex tw-items-center tw-py-2" v-for="org in organizations" :key="org.name"> 472 <a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)"> 473 <svg-icon name="octicon-organization" :size="16" class-name="repo-list-icon"/> 474 <div class="text truncate">{{ org.name }}</div> 475 <div><!-- div to prevent underline of label on hover --> 476 <span class="ui tiny basic label" v-if="org.org_visibility !== 'public'"> 477 {{ org.org_visibility === 'limited' ? textOrgVisibilityLimited: textOrgVisibilityPrivate }} 478 </span> 479 </div> 480 </a> 481 <div class="text light grey tw-flex tw-items-center tw-ml-2"> 482 {{ org.num_repos }} 483 <svg-icon name="octicon-repo" :size="16" class-name="tw-ml-1 tw-mt-0.5"/> 484 </div> 485 </li> 486 </ul> 487 </div> 488 </div> 489 </div> 490 </template> 491 <style scoped> 492 ul { 493 list-style: none; 494 margin: 0; 495 padding-left: 0; 496 } 497 498 ul li { 499 padding: 0 10px; 500 } 501 502 ul li:not(:last-child) { 503 border-bottom: 1px solid var(--color-secondary); 504 } 505 506 .repos-search { 507 padding-bottom: 0 !important; 508 } 509 510 .repos-filter { 511 margin-top: 0 !important; 512 border-bottom-width: 0 !important; 513 } 514 515 .repos-filter .item { 516 padding-left: 6px !important; 517 padding-right: 6px !important; 518 } 519 520 .repo-list-link { 521 min-width: 0; /* for text truncation */ 522 display: flex; 523 align-items: center; 524 flex: 1; 525 gap: 0.5rem; 526 } 527 528 .repo-list-link .svg { 529 color: var(--color-text-light-2); 530 } 531 532 .repo-list-icon { 533 min-width: 16px; 534 margin-right: 2px; 535 } 536 537 /* octicon-mirror has no padding inside the SVG */ 538 .repo-list-icon.octicon-mirror { 539 width: 14px; 540 min-width: 14px; 541 margin-left: 1px; 542 margin-right: 3px; 543 } 544 545 .repo-owner-name-list li.active { 546 background: var(--color-hover); 547 } 548 </style>