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>