Merge branch 'main' into feat/project-settings

This commit is contained in:
Patrick Schratz 2024-11-20 22:19:35 +01:00 committed by GitHub
commit 69c1d04016
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 289 additions and 158 deletions

View file

@ -41,9 +41,6 @@ variables:
when: when:
- event: [pull_request, tag] - event: [pull_request, tag]
- event: push
branch:
- renovate/*
- event: push - event: push
branch: ${CI_REPO_DEFAULT_BRANCH} branch: ${CI_REPO_DEFAULT_BRANCH}
path: *when_path path: *when_path

View file

@ -31,7 +31,6 @@ when:
- <<: *docker_path - <<: *docker_path
branch: branch:
- ${CI_REPO_DEFAULT_BRANCH} - ${CI_REPO_DEFAULT_BRANCH}
- renovate/*
- event: pull_request_closed - event: pull_request_closed
path: *when_path path: *when_path
- event: manual - event: manual

View file

@ -3,7 +3,6 @@ when:
- event: push - event: push
branch: branch:
- ${CI_REPO_DEFAULT_BRANCH} - ${CI_REPO_DEFAULT_BRANCH}
- renovate/*
variables: variables:
- &trivy_plugin docker.io/woodpeckerci/plugin-trivy:1.2.0 - &trivy_plugin docker.io/woodpeckerci/plugin-trivy:1.2.0

View file

@ -9,8 +9,6 @@ steps:
depends_on: [] depends_on: []
when: when:
- event: pull_request - event: pull_request
- event: push
branch: renovate/*
- name: spellcheck - name: spellcheck
image: docker.io/node:23-alpine image: docker.io/node:23-alpine

View file

@ -16,8 +16,6 @@ variables:
when: when:
- event: pull_request - event: pull_request
- event: push
branch: renovate/*
- event: push - event: push
branch: ${CI_REPO_DEFAULT_BRANCH} branch: ${CI_REPO_DEFAULT_BRANCH}
path: *when_path path: *when_path

View file

@ -3,7 +3,6 @@ when:
- event: push - event: push
branch: branch:
- release/* - release/*
- renovate/*
variables: variables:
- &node_image 'docker.io/node:23-alpine' - &node_image 'docker.io/node:23-alpine'

View file

@ -34,7 +34,7 @@ Install make on:
### Install Node.js & `pnpm` ### Install Node.js & `pnpm`
Install [Node.js (>=14)](https://nodejs.org/en/download/) if you want to build Woodpecker's UI or documentation. Install [Node.js (>=20)](https://nodejs.org/en/download/package-manager) if you want to build Woodpecker's UI or documentation.
For dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used. For dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used.
[This guide](https://pnpm.io/installation) describes the installation of `pnpm`. [This guide](https://pnpm.io/installation) describes the installation of `pnpm`.

View file

@ -5,7 +5,7 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=14" "node": ">=20"
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",

View file

@ -67,6 +67,21 @@
"success": "Repository enabled" "success": "Repository enabled"
}, },
"open_in_forge": "Open repository in forge", "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": { "settings": {
"not_allowed": "You are not allowed to access this repository's settings", "not_allowed": "You are not allowed to access this repository's settings",
"general": { "general": {
@ -107,21 +122,6 @@
"desc": "Pipeline containers get access to security privileges." "desc": "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": "Timeout", "timeout": "Timeout",
"minutes": "minutes" "minutes": "minutes"
@ -191,7 +191,7 @@
"pipeline": { "pipeline": {
"tasks": "Tasks", "tasks": "Tasks",
"config": "Config", "config": "Config",
"files": "Changed files ({files})", "files": "Changed files",
"no_pipelines": "No pipelines have been started yet.", "no_pipelines": "No pipelines have been started yet.",
"no_pipeline_steps": "No pipeline steps available!", "no_pipeline_steps": "No pipeline steps available!",
"step_not_started": "This step hasn't started yet.", "step_not_started": "This step hasn't started yet.",
@ -248,8 +248,8 @@
"failure": "failure", "failure": "failure",
"killed": "killed" "killed": "killed"
}, },
"errors": "Errors ({count})", "errors": "Errors",
"warnings": "Warnings ({count})", "warnings": "Warnings",
"show_errors": "Show errors", "show_errors": "Show errors",
"we_got_some_errors": "Oh no, we got some errors!", "we_got_some_errors": "Oh no, we got some errors!",
"duration": "Pipeline duration", "duration": "Pipeline duration",
@ -310,7 +310,7 @@
"placeholder": "Stop agent from taking new tasks" "placeholder": "Stop agent from taking new tasks"
}, },
"token": "Token", "token": "Token",
"platform": { "platform": {gg
"platform": "Platform", "platform": "Platform",
"badge": "platform" "badge": "platform"
}, },
@ -508,6 +508,8 @@
"none_desc": "Every event triggers pipelines, including pull requests. This setting can be dangerous and is only recommended for private instances.", "none_desc": "Every event triggers pipelines, including pull requests. This setting can be dangerous and is only recommended for private instances.",
"forks": "Pull request from forked repository", "forks": "Pull request from forked repository",
"pull_requests": "All pull requests", "pull_requests": "All pull requests",
"all_events": "Any event trigger" "all_events": "All events from forge"
} },
"all_repositories": "All repositories",
"no_search_results": "No results found"
} }

View file

@ -0,0 +1,13 @@
<template>
<span
class="text-xs font-bold inline-block min-w-5 leading-4 rounded-full bg-wp-background-300 dark:bg-wp-background-100 text-wp-text-100 text-center py-0.5 px-1.5"
>
{{ value }}
</span>
</template>
<script lang="ts" setup>
defineProps<{
value?: string | number;
}>();
</script>

View file

@ -13,45 +13,28 @@
</InputField> </InputField>
<InputField v-slot="{ id }" :label="$t('repo.deploy_pipeline.variables.title')"> <InputField v-slot="{ id }" :label="$t('repo.deploy_pipeline.variables.title')">
<span class="text-sm text-wp-text-alt-100 mb-2">{{ $t('repo.deploy_pipeline.variables.desc') }}</span> <span class="text-sm text-wp-text-alt-100 mb-2">{{ $t('repo.deploy_pipeline.variables.desc') }}</span>
<div class="flex flex-col gap-2"> <KeyValueEditor
<div v-for="(_, i) in payload.variables" :key="i" class="flex gap-4"> :id="id"
<TextField v-model="payload.variables"
:id="id" :key-placeholder="$t('repo.deploy_pipeline.variables.name')"
v-model="payload.variables[i].name" :value-placeholder="$t('repo.deploy_pipeline.variables.value')"
:placeholder="$t('repo.deploy_pipeline.variables.name')" :delete-title="$t('repo.deploy_pipeline.variables.delete')"
/> @update:is-valid="isVariablesValid = $event"
<TextField />
:id="id"
v-model="payload.variables[i].value"
:placeholder="$t('repo.deploy_pipeline.variables.value')"
/>
<div class="w-10 flex-shrink-0">
<Button
v-if="i !== payload.variables.length - 1"
color="red"
class="ml-auto"
:title="$t('repo.deploy_pipeline.variables.delete')"
@click="deleteVar(i)"
>
<Icon name="remove" />
</Button>
</div>
</div>
</div>
</InputField> </InputField>
<Button type="submit" :text="$t('repo.deploy_pipeline.trigger')" /> <Button type="submit" :text="$t('repo.deploy_pipeline.trigger')" :disabled="!isFormValid" />
</form> </form>
</Panel> </Panel>
</Popup> </Popup>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref, toRef, watch } from 'vue'; import { computed, onMounted, ref, toRef } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import Button from '~/components/atomic/Button.vue'; import Button from '~/components/atomic/Button.vue';
import Icon from '~/components/atomic/Icon.vue';
import InputField from '~/components/form/InputField.vue'; import InputField from '~/components/form/InputField.vue';
import KeyValueEditor from '~/components/form/KeyValueEditor.vue';
import TextField from '~/components/form/TextField.vue'; import TextField from '~/components/form/TextField.vue';
import Panel from '~/components/layout/Panel.vue'; import Panel from '~/components/layout/Panel.vue';
import Popup from '~/components/layout/Popup.vue'; import Popup from '~/components/layout/Popup.vue';
@ -68,55 +51,37 @@ const emit = defineEmits<{
}>(); }>();
const apiClient = useApiClient(); const apiClient = useApiClient();
const repo = inject('repo'); const repo = inject('repo');
const router = useRouter(); const router = useRouter();
const payload = ref<{ id: string; environment: string; task: string; variables: { name: string; value: string }[] }>({ const payload = ref<{
id: string;
environment: string;
task: string;
variables: Record<string, string>;
}>({
id: '', id: '',
environment: '', environment: '',
task: '', task: '',
variables: [ variables: {},
{
name: '',
value: '',
},
],
}); });
const pipelineOptions = computed(() => { const isVariablesValid = ref(true);
const variables = Object.fromEntries(
payload.value.variables.filter((e) => e.name !== '').map((item) => [item.name, item.value]), const isFormValid = computed(() => {
); return payload.value.environment !== '' && isVariablesValid.value;
return {
...payload.value,
variables,
};
}); });
const pipelineOptions = computed(() => ({
...payload.value,
variables: payload.value.variables,
}));
const loading = ref(true); const loading = ref(true);
onMounted(async () => { onMounted(async () => {
loading.value = false; loading.value = false;
}); });
watch(
payload,
() => {
if (payload.value.variables[payload.value.variables.length - 1].name !== '') {
payload.value.variables.push({
name: '',
value: '',
});
}
},
{ deep: true },
);
function deleteVar(index: number) {
payload.value.variables.splice(index, 1);
}
const pipelineNumber = toRef(props, 'pipelineNumber'); const pipelineNumber = toRef(props, 'pipelineNumber');
async function triggerDeployPipeline() { async function triggerDeployPipeline() {
loading.value = true; loading.value = true;

View file

@ -10,6 +10,7 @@ import { useTabsClient } from '~/compositions/useTabs';
const props = defineProps<{ const props = defineProps<{
to: RouteLocationRaw; to: RouteLocationRaw;
title: string; title: string;
count?: number;
icon?: IconNames; icon?: IconNames;
iconClass?: string; iconClass?: string;
matchChildren?: boolean; matchChildren?: boolean;
@ -32,6 +33,7 @@ onMounted(() => {
tabs.value.push({ tabs.value.push({
to: props.to, to: props.to,
title: props.title, title: props.title,
count: props.count,
icon: props.icon, icon: props.icon,
iconClass: props.iconClass, iconClass: props.iconClass,
matchChildren: props.matchChildren, matchChildren: props.matchChildren,

View file

@ -11,15 +11,17 @@
> >
<Icon v-if="isExactActive || (isActive && tab.matchChildren)" name="chevron-right" class="md:hidden" /> <Icon v-if="isExactActive || (isActive && tab.matchChildren)" name="chevron-right" class="md:hidden" />
<Icon v-else name="blank" class="md:hidden" /> <Icon v-else name="blank" class="md:hidden" />
<span class="flex gap-2 items-center flex-row-reverse md:flex-row"> <span class="flex gap-2 items-center flex-row">
<Icon v-if="tab.icon" :name="tab.icon" :class="tab.iconClass" /> <Icon v-if="tab.icon" :name="tab.icon" :class="tab.iconClass" />
<span>{{ tab.title }}</span> <span>{{ tab.title }}</span>
<CountBadge v-if="tab.count" :value="tab.count" />
</span> </span>
</router-link> </router-link>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import CountBadge from '~/components/atomic/CountBadge.vue';
import Icon from '~/components/atomic/Icon.vue'; import Icon from '~/components/atomic/Icon.vue';
import { useTabsClient } from '~/compositions/useTabs'; import { useTabsClient } from '~/compositions/useTabs';

View file

@ -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-4 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 flex w-full gap-x-4">
<template v-if="lastPipeline">
<div class="flex flex-1 min-w-0 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>

View file

@ -163,7 +163,7 @@ const hasLogs = computed(
// we do not have logs for skipped steps // we do not have logs for skipped steps
repo?.value && pipeline.value && step.value && step.value.state !== 'skipped', 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 showActions = ref(false);
const downloadInProgress = ref(false); const downloadInProgress = ref(false);
const ansiUp = ref(new AnsiUp()); const ansiUp = ref(new AnsiUp());

View file

@ -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,
};
}

View file

@ -8,6 +8,7 @@ import { inject, provide } from './useInjectProvide';
export interface Tab { export interface Tab {
to: RouteLocationRaw; to: RouteLocationRaw;
title: string; title: string;
count?: number;
icon?: IconNames; icon?: IconNames;
iconClass?: string; iconClass?: string;
matchChildren?: boolean; matchChildren?: boolean;

View file

@ -1,32 +1,19 @@
import { computed, ref } from 'vue'; import { useStorage } from '@vueuse/core';
import { computed } from 'vue';
const USER_CONFIG_KEY = 'woodpecker-user-config';
interface UserConfig { interface UserConfig {
isPipelineFeedOpen: boolean; isPipelineFeedOpen: boolean;
redirectUrl: string; redirectUrl: string;
} }
const defaultUserConfig: UserConfig = { const config = useStorage<UserConfig>('woodpecker:user-config', {
isPipelineFeedOpen: false, isPipelineFeedOpen: false,
redirectUrl: '', 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 () => ({ export default () => ({
setUserConfig<T extends keyof UserConfig>(key: T, value: UserConfig[T]): void { setUserConfig<T extends keyof UserConfig>(key: T, value: UserConfig[T]): void {
config.value = { ...config.value, [key]: value }; config.value = { ...config.value, [key]: value };
localStorage.setItem(USER_CONFIG_KEY, JSON.stringify(config.value));
}, },
userConfig: computed(() => config.value), userConfig: computed(() => config.value),
}); });

View file

@ -1,3 +1,5 @@
import type { Pipeline } from './pipeline';
// A version control repository. // A version control repository.
export interface Repo { export interface Repo {
// Is the repo currently active or not // Is the repo currently active or not
@ -65,7 +67,9 @@ export interface Repo {
visibility: RepoVisibility; visibility: RepoVisibility;
last_pipeline: number; last_pipeline?: number;
last_pipeline_item?: Pipeline;
require_approval: RepoRequireApproval; require_approval: RepoRequireApproval;

View file

@ -18,6 +18,14 @@ export const usePipelineStore = defineStore('pipelines', () => {
...(repoPipelines.get(pipeline.number) || {}), ...(repoPipelines.get(pipeline.number) || {}),
...pipeline, ...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); pipelines.set(repoId, repoPipelines);
} }
@ -25,10 +33,14 @@ export const usePipelineStore = defineStore('pipelines', () => {
return computed(() => Array.from(pipelines.get(repoId.value)?.values() || []).sort(comparePipelines)); 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(() => { return computed(() => {
const pipelineNumber = Number.parseInt(_pipelineNumber.value, 10); if (typeof _pipelineNumber.value === 'string') {
return pipelines.get(repoId.value)?.get(pipelineNumber); const pipelineNumber = Number.parseInt(_pipelineNumber.value, 10);
return pipelines.get(repoId.value)?.get(pipelineNumber);
}
return pipelines.get(repoId.value)?.get(_pipelineNumber.value);
}); });
} }

View file

@ -4,8 +4,11 @@ import { computed, reactive, ref, type Ref } from 'vue';
import useApiClient from '~/compositions/useApiClient'; import useApiClient from '~/compositions/useApiClient';
import type { Repo } from '~/lib/api/types'; import type { Repo } from '~/lib/api/types';
import { usePipelineStore } from './pipelines';
export const useRepoStore = defineStore('repos', () => { export const useRepoStore = defineStore('repos', () => {
const apiClient = useApiClient(); const apiClient = useApiClient();
const pipelineStore = usePipelineStore();
const repos: Map<number, Repo> = reactive(new Map()); const repos: Map<number, Repo> = reactive(new Map());
const ownedRepoIds = ref<number[]>([]); const ownedRepoIds = ref<number[]>([]);
@ -21,20 +24,30 @@ export const useRepoStore = defineStore('repos', () => {
} }
function setRepo(repo: Repo) { function setRepo(repo: Repo) {
repos.set(repo.id, repo); repos.set(repo.id, {
...repos.get(repo.id),
...repo,
});
} }
async function loadRepo(repoId: number) { async function loadRepo(repoId: number) {
const repo = await apiClient.getRepo(repoId); const repo = await apiClient.getRepo(repoId);
repos.set(repo.id, repo); setRepo(repo);
return repo; return repo;
} }
async function loadRepos() { async function loadRepos() {
const _ownedRepos = await apiClient.getRepoList(); const _ownedRepos = await apiClient.getRepoList();
_ownedRepos.forEach((repo) => { await Promise.all(
repos.set(repo.id, repo); _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); ownedRepoIds.value = _ownedRepos.map((repo) => repo.id);
} }

View file

@ -1,8 +1,8 @@
import { useLocalStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
export function getUserLanguage(): string { export function getUserLanguage(): string {
const browserLocale = navigator.language.split('-')[0]; const browserLocale = navigator.language.split('-')[0];
const selectedLocale = useLocalStorage('woodpecker:locale', browserLocale).value; const selectedLocale = useStorage('woodpecker:locale', browserLocale).value;
return selectedLocale; return selectedLocale;
} }

View file

@ -8,11 +8,26 @@
<Button :to="{ name: 'repo-add' }" start-icon="plus" :text="$t('repo.add')" /> <Button :to="{ name: 'repo-add' }" start-icon="plus" :text="$t('repo.add')" />
</template> </template>
<div class="space-y-4"> <Transition name="fade" mode="out-in">
<ListItem v-for="repo in searchedRepos" :key="repo.id" :to="{ name: 'repo', params: { repoId: repo.id } }"> <div v-if="search === '' && repos.length > 0" class="gap-8 grid">
<span class="text-wp-text-100">{{ `${repo.owner} / ${repo.name}` }}</span> <div v-if="reposLastAccess.length > 0" class="gap-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2">
</ListItem> <RepoItem v-for="repo in reposLastAccess" :key="repo.id" :repo="repo" />
</div> </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> </Scaffold>
</template> </template>
@ -20,18 +35,36 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import Button from '~/components/atomic/Button.vue'; import Button from '~/components/atomic/Button.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import Scaffold from '~/components/layout/scaffold/Scaffold.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 { useRepoSearch } from '~/compositions/useRepoSearch';
import { useRepoStore } from '~/store/repos'; import { useRepoStore } from '~/store/repos';
const repoStore = useRepoStore(); 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 { searchedRepos } = useRepoSearch(repos, search);
const reposLastActivity = computed(() => sortReposByLastActivity(searchedRepos.value || []));
onMounted(async () => { onMounted(async () => {
await repoStore.loadRepos(); await repoStore.loadRepos();
}); });
</script> </script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View file

@ -13,10 +13,8 @@
/> />
</template> </template>
<div class="space-y-4"> <div class="flex flex-col gap-4">
<ListItem v-for="repo in searchedRepos" :key="repo.id" :to="{ name: 'repo', params: { repoId: repo.id } }"> <RepoItem v-for="repo in searchedRepos" :key="repo.id" :repo="repo" />
<span class="text-wp-text-100">{{ `${repo.owner} / ${repo.name}` }}</span>
</ListItem>
</div> </div>
<div v-if="(searchedRepos || []).length <= 0" class="text-center"> <div v-if="(searchedRepos || []).length <= 0" class="text-center">
<span class="text-wp-text-100 m-auto">{{ $t('repo.user_none') }}</span> <span class="text-wp-text-100 m-auto">{{ $t('repo.user_none') }}</span>
@ -28,19 +26,25 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import IconButton from '~/components/atomic/IconButton.vue'; import IconButton from '~/components/atomic/IconButton.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import Scaffold from '~/components/layout/scaffold/Scaffold.vue'; import Scaffold from '~/components/layout/scaffold/Scaffold.vue';
import RepoItem from '~/components/repo/RepoItem.vue';
import { inject } from '~/compositions/useInjectProvide'; import { inject } from '~/compositions/useInjectProvide';
import useRepos from '~/compositions/useRepos';
import { useRepoSearch } from '~/compositions/useRepoSearch'; import { useRepoSearch } from '~/compositions/useRepoSearch';
import { useRepoStore } from '~/store/repos'; import { useRepoStore } from '~/store/repos';
const repoStore = useRepoStore(); const repoStore = useRepoStore();
const { repoWithLastPipeline } = useRepos();
const org = inject('org'); const org = inject('org');
const orgPermissions = inject('org-permissions'); const orgPermissions = inject('org-permissions');
const search = ref(''); const search = ref('');
const repos = computed(() => Array.from(repoStore.repos.values()).filter((repo) => repo.org_id === org.value?.id)); const repos = computed(() =>
Array.from(repoStore.repos.values())
.filter((repo) => repo.org_id === org.value?.id)
.map(repoWithLastPipeline),
);
const { searchedRepos } = useRepoSearch(repos, search); const { searchedRepos } = useRepoSearch(repos, search);
onMounted(async () => { onMounted(async () => {

View file

@ -67,6 +67,7 @@ import useAuthentication from '~/compositions/useAuthentication';
import useConfig from '~/compositions/useConfig'; import useConfig from '~/compositions/useConfig';
import { useForgeStore } from '~/compositions/useForgeStore'; import { useForgeStore } from '~/compositions/useForgeStore';
import useNotifications from '~/compositions/useNotifications'; import useNotifications from '~/compositions/useNotifications';
import useRepos from '~/compositions/useRepos';
import type { Forge, RepoPermissions } from '~/lib/api/types'; import type { Forge, RepoPermissions } from '~/lib/api/types';
import { usePipelineStore } from '~/store/pipelines'; import { usePipelineStore } from '~/store/pipelines';
import { useRepoStore } from '~/store/repos'; import { useRepoStore } from '~/store/repos';
@ -87,6 +88,7 @@ const router = useRouter();
const i18n = useI18n(); const i18n = useI18n();
const config = useConfig(); const config = useConfig();
const forgeStore = useForgeStore(); const forgeStore = useForgeStore();
const { updateLastAccess } = useRepos();
const repo = repoStore.getRepo(repositoryId); const repo = repoStore.getRepo(repositoryId);
const repoPermissions = ref<RepoPermissions>(); const repoPermissions = ref<RepoPermissions>();
@ -121,6 +123,7 @@ async function loadRepo() {
if (repo.value) { if (repo.value) {
forge.value = (await forgeStore.getForge(repo.value?.forge_id)).value; forge.value = (await forgeStore.getForge(repo.value?.forge_id)).value;
} }
updateLastAccess(repositoryId.value);
} }
onMounted(() => { onMounted(() => {

View file

@ -78,18 +78,16 @@
v-if="pipeline.errors && pipeline.errors.length > 0" v-if="pipeline.errors && pipeline.errors.length > 0"
:to="{ name: 'repo-pipeline-errors' }" :to="{ name: 'repo-pipeline-errors' }"
icon="attention" icon="attention"
:title=" :title="pipeline.errors.some((e) => !e.is_warning) ? $t('repo.pipeline.errors') : $t('repo.pipeline.warnings')"
pipeline.errors.some((e) => !e.is_warning) :count="pipeline.errors?.length"
? $t('repo.pipeline.errors', { count: pipeline.errors?.length })
: $t('repo.pipeline.warnings', { count: pipeline.errors?.length })
"
:icon-class="pipeline.errors.some((e) => !e.is_warning) ? 'text-wp-state-error-100' : 'text-wp-state-warn-100'" :icon-class="pipeline.errors.some((e) => !e.is_warning) ? 'text-wp-state-error-100' : 'text-wp-state-warn-100'"
/> />
<Tab :to="{ name: 'repo-pipeline-config' }" :title="$t('repo.pipeline.config')" /> <Tab :to="{ name: 'repo-pipeline-config' }" :title="$t('repo.pipeline.config')" />
<Tab <Tab
v-if="pipeline.changed_files && pipeline.changed_files.length > 0" v-if="pipeline.changed_files && pipeline.changed_files.length > 0"
:to="{ name: 'repo-pipeline-changed-files' }" :to="{ name: 'repo-pipeline-changed-files' }"
:title="$t('repo.pipeline.files', { files: pipeline.changed_files?.length })" :title="$t('repo.pipeline.files')"
:count="pipeline.changed_files?.length"
/> />
<Tab <Tab
v-if="repoPermissions && repoPermissions.push" v-if="repoPermissions && repoPermissions.push"

View file

@ -55,7 +55,7 @@ import type { Repo } from '~/lib/api/types';
const apiClient = useApiClient(); const apiClient = useApiClient();
const repo = inject<Ref<Repo>>('repo'); const repo = inject<Ref<Repo>>('repo');
const badgeType = useStorage('last-badge-type', 'markdown'); const badgeType = useStorage('woodpecker:last-badge-type', 'markdown');
if (!repo) { if (!repo) {
throw new Error('Unexpected: "repo" should be provided at this place'); throw new Error('Unexpected: "repo" should be provided at this place');

View file

@ -74,10 +74,7 @@
</template> </template>
</InputField> </InputField>
<InputField <InputField docs-url="docs/usage/project-settings#project-visibility" :label="$t('repo.visibility.visibility')">
docs-url="docs/usage/project-settings#project-visibility"
:label="$t('repo.settings.general.visibility.visibility')"
>
<RadioField v-model="repoSettings.visibility" :options="projectVisibilityOptions" /> <RadioField v-model="repoSettings.visibility" :options="projectVisibilityOptions" />
</InputField> </InputField>
@ -215,18 +212,18 @@ onMounted(() => {
const projectVisibilityOptions: RadioOption[] = [ const projectVisibilityOptions: RadioOption[] = [
{ {
value: RepoVisibility.Public, value: RepoVisibility.Public,
text: i18n.t('repo.settings.general.visibility.public.public'), text: i18n.t('repo.visibility.public.public'),
description: i18n.t('repo.settings.general.visibility.public.desc'), description: i18n.t('repo.visibility.public.desc'),
}, },
{ {
value: RepoVisibility.Internal, value: RepoVisibility.Internal,
text: i18n.t('repo.settings.general.visibility.internal.internal'), text: i18n.t('repo.visibility.internal.internal'),
description: i18n.t('repo.settings.general.visibility.internal.desc'), description: i18n.t('repo.visibility.internal.desc'),
}, },
{ {
value: RepoVisibility.Private, value: RepoVisibility.Private,
text: i18n.t('repo.settings.general.visibility.private.private'), text: i18n.t('repo.visibility.private.private'),
description: i18n.t('repo.settings.general.visibility.private.desc'), description: i18n.t('repo.visibility.private.desc'),
}, },
]; ];

View file

@ -18,7 +18,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useLocalStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { SUPPORTED_LOCALES } from 'virtual:vue-i18n-supported-locales'; import { SUPPORTED_LOCALES } from 'virtual:vue-i18n-supported-locales';
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@ -39,7 +39,7 @@ const localeOptions = computed(() =>
})), })),
); );
const storedLocale = useLocalStorage('woodpecker:locale', locale.value); const storedLocale = useStorage('woodpecker:locale', locale.value);
const selectedLocale = computed<string>({ const selectedLocale = computed<string>({
async set(_selectedLocale) { async set(_selectedLocale) {
await setI18nLanguage(_selectedLocale); await setI18nLanguage(_selectedLocale);