Files
arboretum/packages/web/src/components/WorktreeCard.vue
Johan LEROY 915fdf185d feat(worktrees): démarrer une session sur la branche principale ou tout worktree
Ajoute le choix « créer un worktree OU bosser sur la branche principale » au
moment de démarrer le travail, sur deux surfaces :
- bouton « Démarrer » (claude/bash) sur chaque WorktreeCard, y compris le
  checkout principal (badge « principal ») — comblait un manque : impossible
  jusqu'ici de lancer une session sur un worktree existant depuis le dashboard ;
- toggle de mode (Worktree / Branche principale) dans le formulaire d'entête
  de RepoSection.

Option git « créer & basculer » : nouvel endpoint POST /api/v1/repos/:id/session
qui lance UNE session dans le checkout principal (repo.path), avec création ou
bascule de branche optionnelle (git switch[-c]) — refusée en 409 si l'arbre est
sale. Pas de hooks ni de pré-trust (c'est le dépôt réel de l'utilisateur).

- shared : type additif StartRepoSessionRequest.
- server : git.switchBranch + WorktreeManager.startMainSession + route.
- web : store worktrees.startMainSession, UI WorktreeCard/RepoSection, i18n fr/en.
- tests : +5 unitaires (switchBranch, startMainSession) ; acceptance-p3 étendue.
2026-06-22 11:30:40 +02:00

162 lines
7.4 KiB
Vue

<template>
<div class="rounded border border-zinc-800 bg-zinc-900/40 p-2">
<div class="flex items-center gap-2">
<span class="font-mono text-sm text-zinc-200">{{ branchLabel }}</span>
<span v-if="worktree.isMain" class="badge bg-zinc-800 text-zinc-400">{{ t('worktrees.main') }}</span>
<span v-if="worktree.locked" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.locked') }}</span>
<span v-if="worktree.prunable" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.prunable') }}</span>
<span class="ml-auto flex items-center gap-2 text-xs text-zinc-500">
<span v-if="worktree.git.ahead">{{ worktree.git.ahead }}</span>
<span v-if="worktree.git.behind">{{ worktree.git.behind }}</span>
<span v-if="worktree.git.dirtyCount" class="text-amber-400"> {{ t('worktrees.dirty', { n: worktree.git.dirtyCount }) }}</span>
<span v-else class="text-zinc-600">{{ t('worktrees.clean') }}</span>
</span>
</div>
<p class="truncate font-mono text-xs text-zinc-500" :title="worktree.path">{{ worktree.path }}</p>
<div class="mt-1 flex flex-wrap items-center gap-2">
<RouterLink
v-for="s in worktree.sessions"
:key="s.id"
:to="{ name: 'session', params: { id: s.id } }"
class="flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5 transition-colors hover:bg-zinc-800"
>
<SessionStateBadge :session="liveSession(s)" />
<span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span>
</RouterLink>
<span v-if="worktree.sessions.length === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span>
<!-- lancement d'une session sur ce worktree ; carte principale = bosser sur la branche principale -->
<button v-if="hasLiveSession && !showLauncher" class="btn text-xs" @click="showLauncher = true">+ {{ t('worktrees.start') }}</button>
<form v-if="!hasLiveSession || showLauncher" class="flex flex-wrap items-center gap-1" @submit.prevent="onStart">
<template v-if="worktree.isMain">
<select v-model="branchMode" class="input text-xs" :aria-label="t('worktrees.branchModeLabel')">
<option value="current">{{ t('worktrees.currentBranch') }}</option>
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
</select>
<input v-if="branchMode === 'new'" v-model="newBranchName" class="input w-32 font-mono text-xs" placeholder="feature/…" />
</template>
<select v-model="startCommand" class="input text-xs" :aria-label="t('worktrees.commandLabel')">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
<button
type="submit"
class="btn-primary text-xs"
:disabled="starting || (worktree.isMain && branchMode === 'new' && newBranchName.trim() === '')"
>
{{ starting ? t('worktrees.starting') : t('worktrees.start') }}
</button>
</form>
<span v-if="startError" class="text-xs text-amber-400">{{ startError }}</span>
<div v-if="!worktree.isMain" class="ml-auto flex items-center gap-2">
<template v-if="confirming">
<span v-if="error" class="text-xs text-amber-400">{{ error }}</span>
<button class="btn-danger text-xs" :disabled="busy" @click="onDelete">
{{ forceNeeded ? t('worktrees.forceDelete') : t('worktrees.confirmDelete') }}
</button>
<button class="btn text-xs" @click="reset">{{ t('common.cancel') }}</button>
</template>
<button v-else class="btn-danger text-xs" @click="confirming = true">{{ t('worktrees.delete') }}</button>
</div>
</div>
<!-- réponse rapide aux dialogues des sessions corrélées en attente (sans ouvrir le terminal) -->
<DialogPrompt v-for="ws in waitingSessions" :key="`dlg-${ws.id}`" :session="ws" class="mt-2" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { SessionSummary, WorktreeSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees';
import { useSessionsStore } from '../stores/sessions';
import { ApiError } from '../lib/api';
import SessionStateBadge from './SessionStateBadge.vue';
import DialogPrompt from './DialogPrompt.vue';
const props = defineProps<{ worktree: WorktreeSummary }>();
const { t } = useI18n();
const store = useWorktreesStore();
const sessions = useSessionsStore();
// L'état embarqué dans le worktree est un instantané ; on privilégie la version live du store
// des sessions (mise à jour en continu) pour que busy/waiting/idle reste temps réel.
function liveSession(s: SessionSummary): SessionSummary {
return sessions.sessions.find((x) => x.id === s.id) ?? s;
}
// sessions corrélées actuellement bloquées sur un dialogue → réponse rapide inline.
const waitingSessions = computed(() =>
props.worktree.sessions.map(liveSession).filter((s) => s.live && s.activity === 'waiting' && s.dialog),
);
const confirming = ref(false);
const forceNeeded = ref(false);
const error = ref<string | null>(null);
const busy = ref(false);
// --- lancement de session sur ce worktree ---
const hasLiveSession = computed(() => props.worktree.sessions.some((s) => liveSession(s).live));
const showLauncher = ref(false);
const startCommand = ref<'claude' | 'bash'>('claude');
const branchMode = ref<'current' | 'new'>('current'); // carte principale uniquement
const newBranchName = ref('');
const starting = ref(false);
const startError = ref<string | null>(null);
async function onStart(): Promise<void> {
if (starting.value) return;
starting.value = true;
startError.value = null;
try {
if (props.worktree.isMain && branchMode.value === 'new') {
// bosser sur la branche principale en créant d'abord une branche dans le checkout principal.
await store.startMainSession(props.worktree.repoId, { command: startCommand.value, branch: newBranchName.value.trim(), newBranch: true });
} else {
// worktree existant (ou branche principale actuelle) : session directe dans son cwd.
await sessions.createSession(props.worktree.path, startCommand.value);
}
// resynchronise les worktrees du repo pour que la nouvelle session apparaisse sur la carte.
await store.refreshRepoWorktrees(props.worktree.repoId);
showLauncher.value = false;
newBranchName.value = '';
branchMode.value = 'current';
} catch (err) {
startError.value = err instanceof Error ? err.message : String(err);
} finally {
starting.value = false;
}
}
const branchLabel = computed(() =>
props.worktree.branch ?? (props.worktree.detached ? `${t('worktrees.detached')} ${props.worktree.head.slice(0, 7)}` : '—'),
);
function reset(): void {
confirming.value = false;
forceNeeded.value = false;
error.value = null;
}
async function onDelete(): Promise<void> {
busy.value = true;
error.value = null;
try {
await store.deleteWorktree(props.worktree.repoId, props.worktree.path, forceNeeded.value);
// succès : l'événement worktree_removed (ou le retrait local) démonte la carte
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
forceNeeded.value = true; // dirty ou session vivante → proposer la suppression forcée
error.value = err.message;
} else {
error.value = err instanceof Error ? err.message : String(err);
}
} finally {
busy.value = false;
}
}
</script>