Le cwd d'une session de groupe devient le PARENT COMMUN des répertoires couverts (racine neutre, chemins relatifs cross-repo naturels) au lieu du premier repo arbitraire ; chaque repo reste relié en --add-dir. Garde-fou : si le parent commun est la racine du FS, on retombe sur le premier répertoire. Nouveau module pur resolveGroupCwd (core/group-session.ts). Côté web, un lancement réussi redirige vers le terminal plein écran de la session, avec un toast (succès ou couverture partielle).
161 lines
6.7 KiB
Vue
161 lines
6.7 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">
|
|
<!-- mode : couvrir une nouvelle branche par repo, ou les checkouts principaux -->
|
|
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('crossRepo.modeLabel') }}
|
|
<div class="flex flex-wrap gap-3">
|
|
<label class="flex items-center gap-1.5">
|
|
<input v-model="mode" type="radio" value="feature" :disabled="running" /> {{ t('crossRepo.modeFeature') }}
|
|
</label>
|
|
<label class="flex items-center gap-1.5">
|
|
<input v-model="mode" type="radio" value="main" :disabled="running" /> {{ t('crossRepo.modeMain') }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- mode feature : branche commune créée dans chaque repo -->
|
|
<template v-if="mode === 'feature'">
|
|
<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="mode === 'feature'" />
|
|
</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>
|
|
</div>
|
|
</template>
|
|
|
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('crossRepo.commandLabel') }}
|
|
<select v-model="command" class="input">
|
|
<option value="claude">claude</option>
|
|
<option value="bash">bash</option>
|
|
</select>
|
|
</label>
|
|
|
|
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('crossRepo.reposLabel') }}
|
|
<div class="flex flex-col gap-1">
|
|
<div
|
|
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"
|
|
>
|
|
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
|
<span v-if="repoStatus(repo.id)" class="text-[11px]" :class="repoStatusClass(repo.id)">
|
|
{{ repoStatus(repo.id) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="outcome" class="text-xs" :class="outcome.session ? 'text-emerald-400' : 'text-red-400'">
|
|
{{ outcome.session ? t('crossRepo.done') : t('crossRepo.sessionFailed') }}
|
|
</p>
|
|
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<button type="submit" class="btn-primary" :disabled="running || (mode === 'feature' && branch.trim() === '') || repos.length === 0">
|
|
{{ running ? t('crossRepo.launching') : t('crossRepo.launch') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { useI18n } from 'vue-i18n';
|
|
import type { RepoSummary } from '@arboretum/shared';
|
|
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
|
|
import { useToastsStore } from '../stores/toasts';
|
|
|
|
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
|
|
const emit = defineEmits<{ close: [] }>();
|
|
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const groups = useGroupsStore();
|
|
const toasts = useToastsStore();
|
|
|
|
const mode = ref<'feature' | 'main'>('feature');
|
|
const branch = ref('');
|
|
const newBranch = ref(true);
|
|
const baseRef = ref('');
|
|
const command = ref<'claude' | 'bash'>('claude');
|
|
const wtResults = ref<Record<string, CrossRepoResult>>({});
|
|
const outcome = ref<GroupFeatureOutcome | null>(null);
|
|
const errorMsg = ref<string | null>(null);
|
|
const running = ref(false);
|
|
|
|
const skippedById = computed<Record<string, string>>(() => {
|
|
const m: Record<string, string> = {};
|
|
for (const s of outcome.value?.skipped ?? []) m[s.repoId] = s.reason;
|
|
return m;
|
|
});
|
|
|
|
function repoStatus(repoId: string): string {
|
|
const wt = wtResults.value[repoId];
|
|
if (wt) return wt.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${wt.message ?? ''}`;
|
|
const skip = skippedById.value[repoId];
|
|
if (skip) return `${t('crossRepo.skippedLabel')}: ${skip}`;
|
|
return '';
|
|
}
|
|
function repoStatusClass(repoId: string): string {
|
|
const wt = wtResults.value[repoId];
|
|
if (wt) return wt.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
|
return skippedById.value[repoId] ? 'text-amber-400' : '';
|
|
}
|
|
|
|
async function onSubmit(): Promise<void> {
|
|
if (running.value || props.repos.length === 0) return;
|
|
if (mode.value === 'feature' && branch.value.trim() === '') return;
|
|
running.value = true;
|
|
wtResults.value = {};
|
|
outcome.value = null;
|
|
errorMsg.value = null;
|
|
try {
|
|
outcome.value = await groups.createGroupFeature(
|
|
props.groupId,
|
|
props.repos.map((r) => r.id),
|
|
{
|
|
command: command.value,
|
|
...(mode.value === 'feature'
|
|
? { branch: branch.value.trim(), newBranch: newBranch.value, ...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}) }
|
|
: {}),
|
|
},
|
|
(result) => {
|
|
wtResults.value = { ...wtResults.value, [result.repoId]: result };
|
|
},
|
|
);
|
|
// Session lancée → on redirige vers son terminal plein écran (comme une session individuelle).
|
|
// En cas d'échec serveur (session null), on garde le modal ouvert avec le statut d'erreur.
|
|
if (outcome.value.session) {
|
|
const failed = outcome.value.skipped.length + outcome.value.worktreeResults.filter((r) => r.status !== 'ok').length;
|
|
if (failed > 0) toasts.info(t('toast.groupSessionPartial', { n: failed }));
|
|
else toasts.success(t('toast.groupSessionLaunched'));
|
|
await router.push({ name: 'session', params: { id: outcome.value.session.id } });
|
|
}
|
|
} catch (err) {
|
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
running.value = false;
|
|
}
|
|
}
|
|
</script>
|