Files
arboretum/packages/web/src/components/RepoSection.vue
Johan LEROY f4fb6c3b52 feat(worktrees): création intelligente (auto) + commit/push/promotion + grille agrandissable
- addWorktree résout la branche par dépôt (branchExists) : checkout si présente, suivi de origin/<b> si seulement remote, sinon création (-b) depuis la branche par défaut. Corrige le 'fatal: invalid reference' sur les groupes hétérogènes ; newBranch -> mode (déprécié, mappé en route).
- Nouvelles opérations git : commit (add -A), push (upstream auto), promotion « en principal » (la branche du worktree devient le checkout principal, sans merge ni conflit, ancienne branche conservée). Routes /worktrees/{commit,push,promote} auditées + GET /repos/:id/branches.
- Web : actions Commit/Push/Promouvoir sur WorktreeCard, sélecteur de branche de base (datalist) dans RepoSection et GroupSessionModal, agrandissement d'un terminal au choix dans TerminalGrid.
- Court-circuite la session de groupe quand aucun worktree n'a pu être créé (fini le masquage par « no resolvable worktree »).
- Tests : git.ts (branchExists/listBranches/commitAll/auto), worktree-manager (commit/promote/branches) ; acceptance P5 étend le scénario worktree de groupe sur branche neuve + commit + promotion.
2026-06-22 19:55:45 +02:00

201 lines
8.2 KiB
Vue

<template>
<section class="card flex flex-col gap-2">
<header class="flex flex-wrap items-center gap-2">
<h2 class="font-semibold text-zinc-100">{{ repo.label }}</h2>
<BaseBadge v-if="!repo.valid" tone="red">{{ t('repos.invalid') }}</BaseBadge>
<span class="truncate font-mono text-xs text-zinc-500" :title="repo.path">{{ repo.path }}</span>
<div class="ml-auto flex items-center gap-2">
<BaseButton size="sm" :icon="Plus" @click="creating = !creating">{{ t('worktrees.startWork') }}</BaseButton>
<BaseButton size="sm" :icon="Scissors" :loading="busy" @click="onPrune">{{ t('worktrees.prune') }}</BaseButton>
<BaseButton
size="sm"
icon-only
:icon="repo.hidden ? Eye : EyeOff"
:aria-label="repo.hidden ? t('repos.show') : t('repos.hide')"
@click="onToggleHidden"
/>
<BaseButton variant="danger" size="sm" icon-only :icon="Trash2" :aria-label="t('repos.remove')" @click="onRemove" />
</div>
</header>
<form v-if="creating" class="card-inset flex flex-col gap-2" @submit.prevent="onCreate">
<!-- mode : créer un worktree dédié, ou bosser directement sur le checkout principal -->
<div class="flex flex-wrap items-center gap-3 text-xs text-zinc-400">
{{ t('worktrees.modeLabel') }}
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="worktree" /> {{ t('worktrees.modeWorktree') }}</label>
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="main" /> {{ t('worktrees.modeMain') }}</label>
</div>
<div class="flex flex-wrap items-end gap-2">
<!-- mode worktree : branche (créée si absente, réutilisée si présente) + branche de base + session -->
<template v-if="mode === 'worktree'">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" placeholder="feature/…" required />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" :list="datalistId" :placeholder="t('worktrees.baseRefPlaceholder')" />
<datalist :id="datalistId">
<option v-for="b in baseBranchOptions" :key="b" :value="b" />
</datalist>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.start') }}
<select v-model="startSession" class="input">
<option :value="null">{{ t('worktrees.startNone') }}</option>
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
</template>
<!-- mode branche principale : option git (branche actuelle ou nouvelle branche) + commande -->
<template v-else>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.branchModeLabel') }}
<select v-model="mainBranchMode" class="input">
<option value="current">{{ t('worktrees.currentBranch') }}</option>
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
</select>
</label>
<label v-if="mainBranchMode === 'new'" class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" placeholder="feature/…" />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.commandLabel') }}
<select v-model="mainCommand" class="input">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
</template>
<BaseButton type="submit" variant="primary" size="sm" :loading="busy" :disabled="!canSubmit">
{{ mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}
</BaseButton>
</div>
</form>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<div class="flex flex-col gap-1.5">
<WorktreeCard v-for="w in worktrees" :key="w.path" :worktree="w" />
<p v-if="worktrees.length === 0" class="px-1 text-xs text-zinc-600">{{ t('worktrees.noSession') }}</p>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Eye, EyeOff, Plus, Scissors, Trash2 } from '@lucide/vue';
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees';
import { useWorktreeView } from '../composables/useWorktreeView';
import { useToastsStore } from '../stores/toasts';
import WorktreeCard from './WorktreeCard.vue';
import BaseButton from './ui/BaseButton.vue';
import BaseBadge from './ui/BaseBadge.vue';
const props = defineProps<{ repo: RepoSummary }>();
const { t } = useI18n();
const store = useWorktreesStore();
const view = useWorktreeView();
const toasts = useToastsStore();
// applique le tri/filtre/recherche partagé (toolbar du dashboard) aux worktrees de ce repo.
const worktrees = computed(() => view.apply(store.worktreesForRepo(props.repo.id)));
const creating = ref(false);
const mode = ref<'worktree' | 'main'>('worktree');
const branch = ref('');
const baseRef = ref('');
const startSession = ref<'claude' | 'bash' | null>(null);
const mainBranchMode = ref<'current' | 'new'>('current');
const mainCommand = ref<'claude' | 'bash'>('claude');
const busy = ref(false);
const error = ref<string | null>(null);
// Sélecteur de branche de base (chargé paresseusement à l'ouverture du formulaire).
const branches = ref<RepoBranchesResponse | null>(null);
const datalistId = computed(() => `wt-base-branches-${props.repo.id}`);
const baseBranchOptions = computed<string[]>(() => {
const b = branches.value;
if (!b) return [];
return Array.from(new Set([...(b.default ? [b.default] : []), ...b.local, ...b.remote]));
});
watch(creating, async (open) => {
if (!open || branches.value) return;
try {
branches.value = await store.fetchBranches(props.repo.id);
} catch {
/* le sélecteur de base est facultatif */
}
});
// worktree : branche obligatoire ; branche principale + nouvelle branche : branche obligatoire ; sinon OK.
const canSubmit = computed(() =>
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
);
async function onCreate(): Promise<void> {
busy.value = true;
error.value = null;
try {
if (mode.value === 'worktree') {
await store.createWorktree(props.repo.id, {
branch: branch.value.trim(),
mode: 'auto', // détecte créer / checkout / suivi remote
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
startSession: startSession.value,
});
toasts.success(t('toast.worktreeCreated'));
} else {
// bosser sur la branche principale : session dans le checkout principal, avec création de branche optionnelle.
await store.startMainSession(props.repo.id, {
command: mainCommand.value,
...(mainBranchMode.value === 'new' ? { branch: branch.value.trim(), newBranch: true } : {}),
});
toasts.success(t('toast.sessionCreated'));
}
branch.value = '';
baseRef.value = '';
creating.value = false;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function onPrune(): Promise<void> {
busy.value = true;
error.value = null;
try {
await store.prune(props.repo.id);
toasts.success(t('toast.pruned'));
} catch (err) {
toasts.error(err);
} finally {
busy.value = false;
}
}
async function onRemove(): Promise<void> {
try {
await store.removeRepo(props.repo.id);
toasts.success(t('toast.repoRemoved'));
} catch (err) {
toasts.error(err);
}
}
async function onToggleHidden(): Promise<void> {
try {
await store.setHidden(props.repo.id, !props.repo.hidden);
} catch (err) {
toasts.error(err);
}
}
</script>