Files
Lesstime/frontend/components/task/TaskGitSection.vue
2026-03-15 18:58:55 +01:00

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">{{ 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('')
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 = ''
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 (e: any) {
error.value = e?.data?.detail || e?.data?.['hydra:description'] || t('gitea.error')
} 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>