Ajoute la notion de groupe — collection nommée de repos (membership légère persistée, many-to-many) — pour piloter plusieurs repos en simultané : - DB : migration #5 (tables groups + group_repos, FK ON DELETE CASCADE). - GroupManager (synchrone, EventEmitter) + routes REST /api/v1/groups ; ne persiste que la membership, worktrees/sessions filtrés par repoId côté client. - Protocole : GroupSummary, DTOs, topic WS « groups », events group_update/removed (additifs, PROTOCOL_VERSION inchangé). - Web : store groups, GroupsListView, GroupView (réutilise RepoSection), grille multi-terminaux (TerminalGrid/TerminalCell), action « feature cross-repo » (orchestration client, tolérante aux échecs partiels), routes + i18n EN/FR. - Tests : group-manager.test.ts + extension protocol.test.ts ; acceptance-p5.mjs.
123 lines
4.8 KiB
Vue
123 lines
4.8 KiB
Vue
<template>
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
|
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
|
<header class="flex items-center gap-2">
|
|
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
|
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
|
</header>
|
|
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
|
|
|
|
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('crossRepo.branchLabel') }}
|
|
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" required />
|
|
</label>
|
|
<div class="flex flex-wrap items-end gap-3">
|
|
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
|
|
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('crossRepo.baseRefLabel') }}
|
|
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('crossRepo.startLabel') }}
|
|
<select v-model="startSession" class="input">
|
|
<option :value="null">{{ t('crossRepo.startNone') }}</option>
|
|
<option value="claude">claude</option>
|
|
<option value="bash">bash</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('crossRepo.reposLabel') }}
|
|
<div class="flex flex-col gap-1">
|
|
<label
|
|
v-for="repo in repos"
|
|
:key="repo.id"
|
|
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
|
>
|
|
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" :disabled="running" />
|
|
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
|
<span v-if="results[repo.id]" class="text-[11px]" :class="statusClass(repo.id)">
|
|
{{ statusText(repo.id) }}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<button type="submit" class="btn-primary" :disabled="running || branch.trim() === '' || selectedRepoIds.length === 0">
|
|
{{ running ? t('crossRepo.creating') : t('crossRepo.create', { n: selectedRepoIds.length }) }}
|
|
</button>
|
|
<button v-if="hasFailures && !running" type="button" class="btn text-xs" @click="retryFailed">
|
|
{{ t('crossRepo.retryFailed') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import type { RepoSummary } from '@arboretum/shared';
|
|
import { useGroupsStore, type CrossRepoResult } from '../stores/groups';
|
|
|
|
const props = defineProps<{ repos: RepoSummary[] }>();
|
|
const emit = defineEmits<{ close: [] }>();
|
|
|
|
const { t } = useI18n();
|
|
const groups = useGroupsStore();
|
|
|
|
const branch = ref('');
|
|
const newBranch = ref(true);
|
|
const baseRef = ref('');
|
|
const startSession = ref<'claude' | 'bash' | null>(null);
|
|
const selectedRepoIds = ref<string[]>(props.repos.map((r) => r.id));
|
|
const results = ref<Record<string, CrossRepoResult>>({});
|
|
const running = ref(false);
|
|
|
|
const hasFailures = computed(() => Object.values(results.value).some((r) => r.status === 'error'));
|
|
|
|
function statusClass(repoId: string): string {
|
|
return results.value[repoId]?.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
|
}
|
|
function statusText(repoId: string): string {
|
|
const r = results.value[repoId];
|
|
if (!r) return '';
|
|
return r.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${r.message ?? ''}`;
|
|
}
|
|
|
|
async function run(repoIds: string[]): Promise<void> {
|
|
if (repoIds.length === 0) return;
|
|
running.value = true;
|
|
for (const id of repoIds) delete results.value[id];
|
|
try {
|
|
await groups.createCrossRepoFeature(
|
|
repoIds,
|
|
{
|
|
branch: branch.value.trim(),
|
|
newBranch: newBranch.value,
|
|
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
|
|
startSession: startSession.value,
|
|
},
|
|
(result) => {
|
|
results.value = { ...results.value, [result.repoId]: result };
|
|
},
|
|
);
|
|
} finally {
|
|
running.value = false;
|
|
}
|
|
}
|
|
|
|
function onSubmit(): void {
|
|
void run([...selectedRepoIds.value]);
|
|
}
|
|
function retryFailed(): void {
|
|
void run(Object.values(results.value).filter((r) => r.status === 'error').map((r) => r.repoId));
|
|
}
|
|
</script>
|