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