Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redesign repo list and include last pipeline #4386

Merged
merged 23 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions web/src/assets/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@
"success": "Repository enabled"
},
"open_in_forge": "Open repository in forge",
"visibility": {
"visibility": "Project visibility",
"public": {
"public": "Public",
"desc": "Every user can see your project without being logged in."
},
"private": {
"private": "Private",
"desc": "Only you and other owners of the repository can see this project."
},
"internal": {
"internal": "Internal",
"desc": "Only authenticated users of the Woodpecker instance can see this project."
}
},
"settings": {
"not_allowed": "You are not allowed to access this repository's settings",
"general": {
Expand Down Expand Up @@ -107,21 +122,6 @@
"desc": "Underlying pipeline containers get access to security privileges."
}
},
"visibility": {
"visibility": "Project visibility",
"public": {
"public": "Public",
"desc": "Every user can see your project without being logged in."
},
"private": {
"private": "Private",
"desc": "Only you and other owners of the repository can see this project."
},
"internal": {
"internal": "Internal",
"desc": "Only authenticated users of the Woodpecker instance can see this project."
}
},
"timeout": {
"timeout": "Timeout",
"minutes": "minutes"
Expand Down Expand Up @@ -509,5 +509,7 @@
"pull_requests": "All pull requests",
"all_events": "All events from forge",
"desc": "Prevent malicious pipelines from exposing secrets or running harmful tasks by approving them before execution."
}
},
"all_repositories": "All repositories",
"no_search_results": "No results found"
}
50 changes: 50 additions & 0 deletions web/src/components/repo/RepoItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<template>
<router-link
v-if="repo"
:to="{ name: 'repo', params: { repoId: repo.id } }"
class="flex flex-col border rounded-md bg-wp-background-100 overflow-hidden p-4 border-wp-background-400 dark:bg-wp-background-200 cursor-pointer hover:shadow-md hover:bg-wp-background-300 dark:hover:bg-wp-background-300"
>
<div class="grid grid-cols-[auto,1fr] gap-y-1 items-center">
<div class="text-wp-text-100 text-lg">{{ `${repo.owner} / ${repo.name}` }}</div>
<div class="ml-auto">
<Badge v-if="repo.visibility === RepoVisibility.Public" :label="$t('repo.visibility.public.public')" />
</div>

<div class="col-span-2 text-wp-text-100 mt-4 flex w-full">
<template v-if="lastPipeline">
<div class="flex gap-x-1 items-center">
<PipelineStatusIcon v-if="lastPipeline" :status="lastPipeline.status" />
<span class="whitespace-nowrap overflow-hidden overflow-ellipsis">{{ shortMessage }}</span>
</div>

<div class="flex flex-shrink-0 gap-x-1 items-center ml-auto">
<Icon name="since" size="20" />
<span>{{ since }}</span>
</div>
</template>

<div v-else class="flex gap-x-2">
<span>{{ $t('repo.pipeline.no_pipelines') }}</span>
</div>
</div>
</div>
</router-link>
</template>

<script lang="ts" setup>
import { computed } from 'vue';

import Badge from '~/components/atomic/Badge.vue';
import Icon from '~/components/atomic/Icon.vue';
import PipelineStatusIcon from '~/components/repo/pipeline/PipelineStatusIcon.vue';
import usePipeline from '~/compositions/usePipeline';
import type { Repo } from '~/lib/api/types';
import { RepoVisibility } from '~/lib/api/types';

const props = defineProps<{
repo: Repo;
}>();

const lastPipeline = computed(() => props.repo.last_pipeline_item);
const { since, shortMessage } = usePipeline(lastPipeline);
</script>
2 changes: 1 addition & 1 deletion web/src/components/repo/pipeline/PipelineLog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ const hasLogs = computed(
// we do not have logs for skipped steps
repo?.value && pipeline.value && step.value && step.value.state !== 'skipped',
);
const autoScroll = useStorage('log-auto-scroll', false);
const autoScroll = useStorage('woodpecker:log-auto-scroll', false);
const showActions = ref(false);
const downloadInProgress = ref(false);
const ansiUp = ref(new AnsiUp());
Expand Down
55 changes: 55 additions & 0 deletions web/src/compositions/useRepos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useStorage } from '@vueuse/core';
import { ref } from 'vue';

import type { Repo } from '~/lib/api/types';
import { usePipelineStore } from '~/store/pipelines';

export default function useRepos() {
const pipelineStore = usePipelineStore();
const lastAccess = useStorage('woodpecker:repo-last-access', new Map<number, number>());

function repoWithLastPipeline(repo: Repo): Repo {
if (repo.last_pipeline === undefined) {
return repo;
}

if (repo.last_pipeline_item?.number === repo.last_pipeline) {
return repo;
}

const lastPipeline = pipelineStore.getPipeline(ref(repo.id), ref(repo.last_pipeline)).value;

return {
...repo,
last_pipeline_item: lastPipeline,
};
}

function sortReposByLastAccess(repos: Repo[]): Repo[] {
return repos.sort((a, b) => {
const aLastAccess = lastAccess.value.get(a.id) ?? 0;
const bLastAccess = lastAccess.value.get(b.id) ?? 0;

return bLastAccess - aLastAccess;
});
}

function sortReposByLastActivity(repos: Repo[]): Repo[] {
return repos.sort((a, b) => {
const aLastActivity = a.last_pipeline_item?.created ?? 0;
const bLastActivity = b.last_pipeline_item?.created ?? 0;
return bLastActivity - aLastActivity;
});
}

function updateLastAccess(repoId: number) {
lastAccess.value.set(repoId, Date.now());
}

return {
sortReposByLastAccess,
sortReposByLastActivity,
repoWithLastPipeline,
updateLastAccess,
};
}
21 changes: 4 additions & 17 deletions web/src/compositions/useUserConfig.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,19 @@
import { computed, ref } from 'vue';

const USER_CONFIG_KEY = 'woodpecker-user-config';
import { useStorage } from '@vueuse/core';
import { computed } from 'vue';

interface UserConfig {
isPipelineFeedOpen: boolean;
redirectUrl: string;
}

const defaultUserConfig: UserConfig = {
const config = useStorage<UserConfig>('woodpecker:user-config', {
isPipelineFeedOpen: false,
redirectUrl: '',
};

function loadUserConfig(): UserConfig {
const lsData = localStorage.getItem(USER_CONFIG_KEY);
if (lsData === null) {
return defaultUserConfig;
}

return JSON.parse(lsData) as UserConfig;
}

const config = ref<UserConfig>(loadUserConfig());
});

export default () => ({
setUserConfig<T extends keyof UserConfig>(key: T, value: UserConfig[T]): void {
config.value = { ...config.value, [key]: value };
localStorage.setItem(USER_CONFIG_KEY, JSON.stringify(config.value));
},
userConfig: computed(() => config.value),
});
6 changes: 5 additions & 1 deletion web/src/lib/api/types/repo.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Pipeline } from './pipeline';

// A version control repository.
export interface Repo {
// Is the repo currently active or not
Expand Down Expand Up @@ -65,7 +67,9 @@ export interface Repo {

visibility: RepoVisibility;

last_pipeline: number;
last_pipeline?: number;

last_pipeline_item?: Pipeline;

require_approval: RepoRequireApproval;

Expand Down
18 changes: 15 additions & 3 deletions web/src/store/pipelines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,29 @@ export const usePipelineStore = defineStore('pipelines', () => {
...(repoPipelines.get(pipeline.number) || {}),
...pipeline,
});

// Update last pipeline number for the repo
const repo = repoStore.repos.get(repoId);
if (repo?.last_pipeline !== undefined && repo.last_pipeline < pipeline.number) {
repo.last_pipeline = pipeline.number;
repoStore.setRepo(repo);
}

pipelines.set(repoId, repoPipelines);
}

function getRepoPipelines(repoId: Ref<number>) {
return computed(() => Array.from(pipelines.get(repoId.value)?.values() || []).sort(comparePipelines));
}

function getPipeline(repoId: Ref<number>, _pipelineNumber: Ref<string>) {
function getPipeline(repoId: Ref<number>, _pipelineNumber: Ref<string | number>) {
return computed(() => {
const pipelineNumber = Number.parseInt(_pipelineNumber.value, 10);
return pipelines.get(repoId.value)?.get(pipelineNumber);
if (typeof _pipelineNumber.value === 'string') {
const pipelineNumber = Number.parseInt(_pipelineNumber.value, 10);
return pipelines.get(repoId.value)?.get(pipelineNumber);
}

return pipelines.get(repoId.value)?.get(_pipelineNumber.value);
});
}

Expand Down
23 changes: 18 additions & 5 deletions web/src/store/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { computed, reactive, ref, type Ref } from 'vue';
import useApiClient from '~/compositions/useApiClient';
import type { Repo } from '~/lib/api/types';

import { usePipelineStore } from './pipelines';

export const useRepoStore = defineStore('repos', () => {
const apiClient = useApiClient();
const pipelineStore = usePipelineStore();

const repos: Map<number, Repo> = reactive(new Map());
const ownedRepoIds = ref<number[]>([]);
Expand All @@ -21,20 +24,30 @@ export const useRepoStore = defineStore('repos', () => {
}

function setRepo(repo: Repo) {
repos.set(repo.id, repo);
repos.set(repo.id, {
...repos.get(repo.id),
...repo,
});
}

async function loadRepo(repoId: number) {
const repo = await apiClient.getRepo(repoId);
repos.set(repo.id, repo);
setRepo(repo);
return repo;
}

async function loadRepos() {
const _ownedRepos = await apiClient.getRepoList();
_ownedRepos.forEach((repo) => {
repos.set(repo.id, repo);
});
await Promise.all(
_ownedRepos.map(async (repo) => {
const lastPipeline = await apiClient.getPipelineList(repo.id, { page: 1, perPage: 1 });
if (lastPipeline.length === 1) {
pipelineStore.setPipeline(repo.id, lastPipeline[0]);
repo.last_pipeline = lastPipeline[0].number;
}
setRepo(repo);
}),
);
ownedRepoIds.value = _ownedRepos.map((repo) => repo.id);
}

Expand Down
4 changes: 2 additions & 2 deletions web/src/utils/locale.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useLocalStorage } from '@vueuse/core';
import { useStorage } from '@vueuse/core';

export function getUserLanguage(): string {
const browserLocale = navigator.language.split('-')[0];
const selectedLocale = useLocalStorage('woodpecker:locale', browserLocale).value;
const selectedLocale = useStorage('woodpecker:locale', browserLocale).value;

return selectedLocale;
}
Expand Down
49 changes: 41 additions & 8 deletions web/src/views/Repos.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,63 @@
<Button :to="{ name: 'repo-add' }" start-icon="plus" :text="$t('repo.add')" />
</template>

<div class="space-y-4">
<ListItem v-for="repo in searchedRepos" :key="repo.id" :to="{ name: 'repo', params: { repoId: repo.id } }">
<span class="text-wp-text-100">{{ `${repo.owner} / ${repo.name}` }}</span>
</ListItem>
</div>
<Transition name="fade" mode="out-in">
<div v-if="search === '' && repos.length > 0" class="gap-8 grid">
<div v-if="reposLastAccess.length > 0" class="gap-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2">
<RepoItem v-for="repo in reposLastAccess" :key="repo.id" :repo="repo" />
</div>

<div class="flex flex-col gap-4">
<h2 class="text-wp-text-100 text-lg">{{ $t('all_repositories') }}</h2>
<div class="flex flex-col gap-4">
<RepoItem v-for="repo in reposLastActivity" :key="repo.id" :repo="repo" />
</div>
</div>
</div>
<div v-else class="flex flex-col">
<div v-if="reposLastActivity.length > 0" class="flex flex-col gap-4">
<RepoItem v-for="repo in reposLastActivity" :key="repo.id" :repo="repo" />
</div>
<span v-else class="text-wp-text-100 text-lg text-center">{{ $t('no_search_results') }}</span>
</div>
</Transition>
</Scaffold>
</template>

<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';

import Button from '~/components/atomic/Button.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import Scaffold from '~/components/layout/scaffold/Scaffold.vue';
import RepoItem from '~/components/repo/RepoItem.vue';
import useRepos from '~/compositions/useRepos';
import { useRepoSearch } from '~/compositions/useRepoSearch';
import { useRepoStore } from '~/store/repos';

const repoStore = useRepoStore();
const repos = computed(() => Object.values(repoStore.ownedRepos));
const search = ref('');

const { sortReposByLastAccess, sortReposByLastActivity, repoWithLastPipeline } = useRepos();
const repos = computed(() => Object.values(repoStore.ownedRepos).map((r) => repoWithLastPipeline(r)));

const reposLastAccess = computed(() => sortReposByLastAccess(repos.value || []).slice(0, 4));

const search = ref('');
const { searchedRepos } = useRepoSearch(repos, search);
const reposLastActivity = computed(() => sortReposByLastActivity(searchedRepos.value || []));

onMounted(async () => {
await repoStore.loadRepos();
});
</script>

<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
Loading