426 lines
17 KiB
Vue
426 lines
17 KiB
Vue
<template>
|
|
<div class="mt-5 rounded-lg border border-neutral-200 bg-neutral-50">
|
|
<!-- Header with tabs -->
|
|
<div class="flex items-center justify-between border-b border-neutral-200 bg-neutral-100/60 px-4 py-2">
|
|
<div class="flex gap-1">
|
|
<button
|
|
type="button"
|
|
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
|
|
:class="activeTab === 'branches'
|
|
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
|
: 'text-neutral-500 hover:text-neutral-700'"
|
|
@click="activeTab = 'branches'"
|
|
>
|
|
<Icon name="mdi:source-branch" size="14" class="mr-1 inline-block align-[-2px]" />
|
|
{{ $t('gitea.branch.title') }}
|
|
<span
|
|
v-if="branches.length"
|
|
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-neutral-200 px-1 text-[10px] font-bold text-neutral-600"
|
|
>{{ branches.length }}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
|
|
:class="activeTab === 'prs'
|
|
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
|
: 'text-neutral-500 hover:text-neutral-700'"
|
|
@click="activeTab = 'prs'"
|
|
>
|
|
<Icon name="mdi:source-pull" size="14" class="mr-1 inline-block align-[-2px]" />
|
|
{{ $t('gitea.pr.title') }}
|
|
<span
|
|
v-if="pullRequests.length"
|
|
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold"
|
|
:class="hasOpenPr ? 'bg-green-100 text-green-700' : 'bg-neutral-200 text-neutral-600'"
|
|
>{{ pullRequests.length }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex gap-1">
|
|
<button
|
|
v-if="activeTab === 'branches'"
|
|
type="button"
|
|
class="rounded-md px-2.5 py-1.5 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-neutral-700"
|
|
:title="$t('gitea.branch.copy')"
|
|
@click="handleCopy"
|
|
>
|
|
<Icon name="mdi:content-copy" size="14" />
|
|
</button>
|
|
<button
|
|
v-if="activeTab === 'branches'"
|
|
type="button"
|
|
class="rounded-md bg-primary-500 px-2.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-secondary-500"
|
|
@click="showCreateForm = !showCreateForm"
|
|
>
|
|
<Icon name="mdi:plus" size="14" class="mr-0.5 inline-block align-[-2px]" />
|
|
{{ $t('gitea.branch.create') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error state -->
|
|
<div v-if="error" class="px-4 py-3">
|
|
<p class="text-xs text-red-500">{{ $t('gitea.error') }}</p>
|
|
</div>
|
|
|
|
<!-- Create branch form (inline) -->
|
|
<Transition name="slide-down">
|
|
<div v-if="showCreateForm && activeTab === 'branches'" class="relative z-20 border-b border-neutral-200 bg-white px-4 py-3">
|
|
<div class="grid grid-cols-[1fr_1fr_auto] items-end gap-3">
|
|
<MalioSelect
|
|
v-model="branchForm.type"
|
|
:options="typeOptions"
|
|
:label="$t('gitea.branch.type')"
|
|
min-width="w-full"
|
|
/>
|
|
<MalioInputText
|
|
v-model="branchForm.baseBranch"
|
|
:label="$t('gitea.branch.baseBranch')"
|
|
input-class="w-full"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="mb-[2px] rounded-md bg-primary-500 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-secondary-500 disabled:opacity-50"
|
|
:disabled="isCreating"
|
|
@click="handleCreate"
|
|
>
|
|
{{ isCreating ? '...' : $t('gitea.branch.create') }}
|
|
</button>
|
|
</div>
|
|
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
|
|
{{ branchPreview }}
|
|
</code>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Content area with scroll -->
|
|
<div class="max-h-64 overflow-y-auto overscroll-contain">
|
|
<!-- Loading -->
|
|
<div v-if="(activeTab === 'branches' && isLoading) || (activeTab === 'prs' && isLoadingPrs)" class="flex items-center justify-center py-8">
|
|
<Icon name="mdi:loading" size="20" class="animate-spin text-neutral-300" />
|
|
</div>
|
|
|
|
<!-- BRANCHES TAB -->
|
|
<template v-if="activeTab === 'branches' && !isLoading">
|
|
<div v-if="branches.length" class="divide-y divide-neutral-100">
|
|
<div
|
|
v-for="branch in branches"
|
|
:key="branch.name"
|
|
class="group"
|
|
>
|
|
<!-- Branch header (clickable to expand) -->
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-white"
|
|
@click="toggleBranch(branch.name)"
|
|
>
|
|
<Icon
|
|
name="mdi:chevron-right"
|
|
size="14"
|
|
class="shrink-0 text-neutral-400 transition-transform"
|
|
:class="{ 'rotate-90': expandedBranches.has(branch.name) }"
|
|
/>
|
|
<Icon name="mdi:source-branch" size="14" class="shrink-0 text-primary-500" />
|
|
<span class="min-w-0 truncate text-xs font-medium text-primary-600">
|
|
{{ branch.name }}
|
|
</span>
|
|
<span
|
|
v-if="branch.commits.length"
|
|
class="ml-auto shrink-0 rounded bg-neutral-200/60 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500"
|
|
>
|
|
{{ branch.commits.length }} commit{{ branch.commits.length > 1 ? 's' : '' }}
|
|
</span>
|
|
<a
|
|
:href="branchUrl(branch.name)"
|
|
target="_blank"
|
|
class="shrink-0 text-neutral-400 opacity-0 transition-opacity hover:text-primary-500 group-hover:opacity-100"
|
|
@click.stop
|
|
>
|
|
<Icon name="mdi:open-in-new" size="12" />
|
|
</a>
|
|
</button>
|
|
|
|
<!-- Commits (collapsible) -->
|
|
<Transition name="expand">
|
|
<div v-if="expandedBranches.has(branch.name) && branch.commits.length" class="border-t border-neutral-100 bg-white">
|
|
<div
|
|
v-for="(commit, idx) in branch.commits.slice(0, 10)"
|
|
:key="commit.sha"
|
|
class="flex items-center gap-2 px-4 py-1.5"
|
|
:class="idx !== Math.min(branch.commits.length, 10) - 1 ? 'border-b border-neutral-50' : ''"
|
|
>
|
|
<span class="shrink-0 pl-5 font-mono text-[10px] text-primary-400">{{ commit.sha.slice(0, 7) }}</span>
|
|
<span class="min-w-0 truncate text-[11px] text-neutral-700">{{ commitFirstLine(commit.message) }}</span>
|
|
<span class="ml-auto shrink-0 text-[10px] text-neutral-400">{{ commit.author }}</span>
|
|
<span class="shrink-0 text-[10px] text-neutral-300">{{ formatDate(commit.date) }}</span>
|
|
</div>
|
|
<div
|
|
v-if="branch.commits.length > 10"
|
|
class="border-t border-neutral-50 px-4 py-1.5 text-center text-[10px] text-neutral-400"
|
|
>
|
|
+{{ branch.commits.length - 10 }} commits
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-else-if="!error" class="py-6 text-center text-xs text-neutral-400">
|
|
{{ $t('gitea.branch.noBranches') }}
|
|
</p>
|
|
</template>
|
|
|
|
<!-- PULL REQUESTS TAB -->
|
|
<template v-if="activeTab === 'prs' && !isLoadingPrs">
|
|
<div v-if="pullRequests.length" class="divide-y divide-neutral-100">
|
|
<div
|
|
v-for="pr in pullRequests"
|
|
:key="pr.number"
|
|
class="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-white"
|
|
>
|
|
<!-- Status pill -->
|
|
<span
|
|
class="mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white"
|
|
:class="prStatusClass(pr)"
|
|
>
|
|
{{ prStatusLabel(pr) }}
|
|
</span>
|
|
|
|
<!-- PR content -->
|
|
<div class="min-w-0 flex-1">
|
|
<a
|
|
:href="pr.url"
|
|
target="_blank"
|
|
class="text-xs font-medium text-neutral-800 hover:text-primary-500 hover:underline"
|
|
>
|
|
<span class="text-neutral-400">#{{ pr.number }}</span>
|
|
{{ pr.title }}
|
|
</a>
|
|
<div class="mt-1 flex items-center gap-2">
|
|
<span class="text-[10px] text-neutral-400">{{ pr.author }}</span>
|
|
<span v-if="pr.headBranch" class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500">
|
|
{{ pr.headBranch }}
|
|
</span>
|
|
<!-- CI statuses -->
|
|
<template v-if="pr.ciStatuses.length">
|
|
<a
|
|
v-for="ci in pr.ciStatuses"
|
|
:key="ci.context"
|
|
:href="ci.target_url"
|
|
target="_blank"
|
|
class="inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium transition-opacity hover:opacity-80"
|
|
:class="ciStatusClass(ci.status)"
|
|
>
|
|
<Icon :name="ciStatusIcon(ci.status)" size="10" />
|
|
{{ ci.context }}
|
|
</a>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-else-if="branches.length && !error" class="py-6 text-center text-xs text-neutral-400">
|
|
{{ $t('gitea.pr.noPrs') }}
|
|
</p>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Task } from '~/services/dto/task'
|
|
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
|
|
import { useGiteaService } from '~/services/gitea'
|
|
|
|
const { t } = useI18n()
|
|
const props = defineProps<{
|
|
task: Task
|
|
giteaUrl: string
|
|
}>()
|
|
|
|
const { listBranches, createBranch, listPullRequests, getBranchName } = useGiteaService()
|
|
|
|
const activeTab = ref<'branches' | 'prs'>('branches')
|
|
const branches = ref<GiteaBranch[]>([])
|
|
const pullRequests = ref<GiteaPullRequest[]>([])
|
|
const isLoading = ref(true)
|
|
const isLoadingPrs = ref(true)
|
|
const isCreating = ref(false)
|
|
const error = ref(false)
|
|
const showCreateForm = ref(false)
|
|
const expandedBranches = ref(new Set<string>())
|
|
|
|
const branchForm = reactive({
|
|
type: 'feature',
|
|
baseBranch: 'develop',
|
|
})
|
|
|
|
const typeOptions = [
|
|
{ label: t('gitea.branch.types.feature'), value: 'feature' },
|
|
{ label: t('gitea.branch.types.fix'), value: 'fix' },
|
|
{ label: t('gitea.branch.types.refactor'), value: 'refactor' },
|
|
{ label: t('gitea.branch.types.hotfix'), value: 'hotfix' },
|
|
{ label: t('gitea.branch.types.chore'), value: 'chore' },
|
|
]
|
|
|
|
const hasOpenPr = computed(() => pullRequests.value.some(pr => pr.state === 'open' && !pr.merged))
|
|
|
|
const branchPreview = computed(() => {
|
|
if (!props.task.project?.code || !props.task.number) return ''
|
|
const slug = props.task.title
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
.slice(0, 50)
|
|
return `${branchForm.type}/${props.task.project.code}-${props.task.number}-${slug}`
|
|
})
|
|
|
|
function toggleBranch(name: string) {
|
|
if (expandedBranches.value.has(name)) {
|
|
expandedBranches.value.delete(name)
|
|
} else {
|
|
expandedBranches.value.add(name)
|
|
}
|
|
}
|
|
|
|
function branchUrl(name: string): string {
|
|
const project = props.task.project
|
|
if (!project?.giteaOwner || !project?.giteaRepo) return '#'
|
|
return `${props.giteaUrl}/${project.giteaOwner}/${project.giteaRepo}/src/branch/${encodeURIComponent(name)}`
|
|
}
|
|
|
|
function commitFirstLine(message: string): string {
|
|
return message.split('\n')[0]
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
const d = new Date(dateStr)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - d.getTime()
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
|
|
if (diffDays === 0) return "aujourd'hui"
|
|
if (diffDays === 1) return 'hier'
|
|
if (diffDays < 7) return `il y a ${diffDays}j`
|
|
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
|
}
|
|
|
|
function prStatusClass(pr: GiteaPullRequest): string {
|
|
if (pr.merged) return 'bg-purple-500'
|
|
if (pr.state === 'open') return 'bg-green-500'
|
|
return 'bg-red-500'
|
|
}
|
|
|
|
function prStatusLabel(pr: GiteaPullRequest): string {
|
|
if (pr.merged) return t('gitea.pr.merged')
|
|
if (pr.state === 'open') return t('gitea.pr.open')
|
|
return t('gitea.pr.closed')
|
|
}
|
|
|
|
function ciStatusClass(status: string): string {
|
|
if (status === 'success') return 'bg-green-100 text-green-700'
|
|
if (status === 'failure' || status === 'error') return 'bg-red-100 text-red-700'
|
|
return 'bg-yellow-100 text-yellow-700'
|
|
}
|
|
|
|
function ciStatusIcon(status: string): string {
|
|
if (status === 'success') return 'mdi:check-circle'
|
|
if (status === 'failure' || status === 'error') return 'mdi:close-circle'
|
|
return 'mdi:clock-outline'
|
|
}
|
|
|
|
async function loadData() {
|
|
if (!props.task.id) return
|
|
|
|
isLoading.value = true
|
|
isLoadingPrs.value = true
|
|
error.value = false
|
|
|
|
try {
|
|
branches.value = await listBranches(props.task.id)
|
|
// Auto-expand first branch
|
|
if (branches.value.length === 1) {
|
|
expandedBranches.value.add(branches.value[0].name)
|
|
}
|
|
} catch {
|
|
error.value = true
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
|
|
try {
|
|
pullRequests.value = await listPullRequests(props.task.id)
|
|
} catch {
|
|
// PR errors don't block branch display
|
|
} finally {
|
|
isLoadingPrs.value = false
|
|
}
|
|
}
|
|
|
|
async function handleCreate() {
|
|
isCreating.value = true
|
|
try {
|
|
await createBranch(props.task.id, {
|
|
type: branchForm.type,
|
|
baseBranch: branchForm.baseBranch,
|
|
})
|
|
showCreateForm.value = false
|
|
await loadData()
|
|
} finally {
|
|
isCreating.value = false
|
|
}
|
|
}
|
|
|
|
async function handleCopy() {
|
|
try {
|
|
const result = await getBranchName(props.task.id, branchForm.type)
|
|
await navigator.clipboard.writeText(result.name)
|
|
const { success } = useToast()
|
|
success(t('gitea.branch.copied'))
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadData()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.slide-down-enter-active {
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
|
|
.slide-down-leave-active {
|
|
transition: opacity 0.1s ease;
|
|
}
|
|
|
|
.slide-down-enter-from,
|
|
.slide-down-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.expand-enter-active,
|
|
.expand-leave-active {
|
|
transition: all 0.15s ease;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.expand-enter-from,
|
|
.expand-leave-to {
|
|
max-height: 0;
|
|
opacity: 0;
|
|
}
|
|
|
|
.expand-enter-to,
|
|
.expand-leave-from {
|
|
max-height: 500px;
|
|
opacity: 1;
|
|
}
|
|
</style>
|