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.
This commit is contained in:
2026-06-22 11:30:40 +02:00
parent ea7b2fd278
commit 915fdf185d
12 changed files with 324 additions and 21 deletions

View File

@@ -3,7 +3,7 @@
// Vrai daemon + vrai repo git tmp. Couvre : enregistrement repo, création worktree avec hook // Vrai daemon + vrai repo git tmp. Couvre : enregistrement repo, création worktree avec hook
// post-create, broadcast WS worktree_update, corrélation worktree↔session (bash), suppression. // post-create, broadcast WS worktree_update, corrélation worktree↔session (bash), suppression.
import { spawn, execFileSync } from 'node:child_process'; import { spawn, execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, existsSync } from 'node:fs'; import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, dirname, basename } from 'node:path'; import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -119,6 +119,24 @@ try {
const feat = wlist1.worktrees.find((w) => w.branch === 'feat'); const feat = wlist1.worktrees.find((w) => w.branch === 'feat');
check('corrélation worktree ↔ session (cwd)', feat?.sessions?.some((s) => s.command === 'bash' && s.live)); check('corrélation worktree ↔ session (cwd)', feat?.sessions?.some((s) => s.command === 'bash' && s.live));
// Session sur le checkout principal (« bosser sur la branche principale ») avec création de branche.
const mainSess = await j(`/api/v1/repos/${repoSummary.id}/session`, 'POST', cookie, { command: 'bash', branch: 'mainfeat', newBranch: true });
const mainBody = await mainSess.json();
check('POST repo session → 201 (bash)', mainSess.status === 201 && mainBody.session?.command === 'bash');
check('session de branche principale : cwd = checkout principal', mainBody.session?.cwd === repoSummary.path);
const mainPush = await c.waitMsg((m) => m.type === 'worktree_update' && m.worktree?.isMain && m.worktree?.branch === 'mainfeat');
check('broadcast worktree_update (branche principale basculée)', !!mainPush);
await sleep(300);
const wlistM = await (await j('/api/v1/worktrees', 'GET', cookie)).json();
const mainWt = wlistM.worktrees.find((w) => w.isMain);
check('session corrélée au checkout principal', mainWt?.branch === 'mainfeat' && mainWt.sessions.some((s) => s.id === mainBody.session.id));
// Refus 409 quand le checkout principal est sale.
writeFileSync(join(repo, 'scratch.txt'), 'wip\n');
const dirtyRefused = await j(`/api/v1/repos/${repoSummary.id}/session`, 'POST', cookie, { command: 'bash', branch: 'other', newBranch: true });
check('repo session sur checkout sale → 409', dirtyRefused.status === 409);
rmSync(join(repo, 'scratch.txt'), { force: true });
// Suppression forcée (une session vit dans le worktree). // Suppression forcée (une session vit dans le worktree).
const refused = await j(`/api/v1/repos/${repoSummary.id}/worktrees?path=${encodeURIComponent(wtPath)}&force=false`, 'DELETE', cookie); const refused = await j(`/api/v1/repos/${repoSummary.id}/worktrees?path=${encodeURIComponent(wtPath)}&force=false`, 'DELETE', cookie);
check('delete sans force (session live) → 409', refused.status === 409); check('delete sans force (session live) → 409', refused.status === 409);

View File

@@ -190,6 +190,16 @@ export async function addWorktree(
await git(repoPath, args); await git(repoPath, args);
} }
/**
* Crée/bascule une branche dans le checkout (worktree) en `repoPath` — utilisé pour démarrer une
* session sur la branche principale sans worktree dédié. `create` → `git switch -c <branch>` (échoue
* si la branche existe) ; sinon `git switch <branch>` (branche existante). Pas de `--` : l'argument
* est une réf (pas un pathspec) et le nom est déjà filtré en amont par `isValidBranchName` (anti-flag).
*/
export async function switchBranch(repoPath: string, opts: { branch: string; create: boolean }): Promise<void> {
await git(repoPath, opts.create ? ['switch', '-c', opts.branch] : ['switch', opts.branch]);
}
export async function removeWorktree(repoPath: string, worktreePath: string, force: boolean): Promise<void> { export async function removeWorktree(repoPath: string, worktreePath: string, force: boolean): Promise<void> {
await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]); await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]);
} }

View File

@@ -30,6 +30,7 @@ import {
listWorktrees, listWorktrees,
pruneWorktrees, pruneWorktrees,
removeWorktree, removeWorktree,
switchBranch,
worktreeStatus, worktreeStatus,
type ParsedWorktree, type ParsedWorktree,
} from './git.js'; } from './git.js';
@@ -355,6 +356,41 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
}); });
} }
/**
* Lance UNE session dans le checkout principal du repo (`repo.path`) — pour « bosser sur la branche
* principale » sans créer de worktree. Si `branch` est fourni, crée/bascule d'abord cette branche
* dans ce checkout (`git switch[-c]`), refusé si l'arbre est sale (on n'écrase pas un HEAD modifié).
* Volontairement SANS hooks ni pré-trust (contraste avec createWorktree) : le checkout principal
* est le dépôt réel de l'utilisateur, déjà configuré/approuvé. Sérialisé par repo (withLock).
*/
async startMainSession(
repoId: string,
req: { command?: 'claude' | 'bash'; branch?: string; newBranch?: boolean },
): Promise<{ session: SessionSummary; worktree: WorktreeSummary | null }> {
const row = this.getRepoRow(repoId);
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
const branch = req.branch?.trim() || undefined;
if (branch !== undefined && !isValidBranchName(branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${branch}`);
return this.withLock(repoId, async () => {
if (branch !== undefined) {
// garde-fou : ne pas basculer le HEAD du checkout principal s'il a des changements non sauvegardés.
if ((await worktreeStatus(row.path)).dirtyCount > 0) {
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes — commit or stash before switching branch');
}
try {
await switchBranch(row.path, { branch, create: req.newBranch ?? true });
} catch (err) {
throw httpError(400, 'SWITCH_FAILED', (err as Error).message);
}
this.factsCache.delete(repoId);
}
const session = this.ptyManager.spawn({ cwd: row.path, command: req.command ?? 'claude' });
const worktree = await this.emitWorktree(row, row.path);
return { session, worktree };
});
}
async adoptWorktree(repoId: string, req: { path: string; runHooks?: boolean; preTrust?: boolean }): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[] }> { async adoptWorktree(repoId: string, req: { path: string; runHooks?: boolean; preTrust?: boolean }): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[] }> {
const row = this.getRepoRow(repoId); const row = this.getRepoRow(repoId);
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id'); if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');

View File

@@ -3,6 +3,8 @@ import type {
AdoptWorktreeRequest, AdoptWorktreeRequest,
CreateWorktreeRequest, CreateWorktreeRequest,
CreateWorktreeResponse, CreateWorktreeResponse,
SessionResponse,
StartRepoSessionRequest,
WorktreeResponse, WorktreeResponse,
WorktreesListResponse, WorktreesListResponse,
} from '@arboretum/shared'; } from '@arboretum/shared';
@@ -43,6 +45,33 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
} }
}); });
// Session sur le checkout principal (« bosser sur la branche principale » sans worktree),
// avec création/bascule de branche optionnelle côté serveur.
app.post('/api/v1/repos/:id/session', async (req, reply) => {
const { id } = req.params as { id: string };
const body = (req.body as Partial<StartRepoSessionRequest> | null) ?? {};
if (body.command != null && body.command !== 'claude' && body.command !== 'bash') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
}
if (body.branch != null && typeof body.branch !== 'string') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'branch must be a string' } });
}
if (body.newBranch != null && typeof body.newBranch !== 'boolean') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'newBranch must be a boolean' } });
}
try {
const out = await wt.startMainSession(id, {
...(body.command !== undefined ? { command: body.command } : {}),
...(body.branch !== undefined ? { branch: body.branch } : {}),
...(body.newBranch !== undefined ? { newBranch: body.newBranch } : {}),
});
const res: SessionResponse = { session: out.session };
return reply.status(201).send(res);
} catch (err) {
return sendManagerError(reply, err);
}
});
app.post('/api/v1/repos/:id/worktrees/adopt', async (req, reply) => { app.post('/api/v1/repos/:id/worktrees/adopt', async (req, reply) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const body = req.body as Partial<AdoptWorktreeRequest> | null; const body = req.body as Partial<AdoptWorktreeRequest> | null;

View File

@@ -13,6 +13,7 @@ import {
addWorktree, addWorktree,
removeWorktree, removeWorktree,
pruneWorktrees, pruneWorktrees,
switchBranch,
isDirtyWorktreeError, isDirtyWorktreeError,
} from '../src/core/git.js'; } from '../src/core/git.js';
@@ -118,6 +119,20 @@ describe('opérations git (repo tmp réel)', () => {
expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false); expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
}); });
it('switchBranch : create=true crée une branche, create=false bascule sur une existante', async () => {
const repo = makeTmpRepo();
const cur = (): string => execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim();
await switchBranch(repo, { branch: 'feature/new', create: true });
expect(cur()).toBe('feature/new');
await switchBranch(repo, { branch: 'main', create: false });
expect(cur()).toBe('main');
// créer une branche déjà existante échoue (git refuse).
await expect(switchBranch(repo, { branch: 'feature/new', create: true })).rejects.toBeDefined();
});
it('prune retire un worktree dont le dossier a disparu', async () => { it('prune retire un worktree dont le dossier a disparu', async () => {
const repo = makeTmpRepo(); const repo = makeTmpRepo();
const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`); const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`);

View File

@@ -116,6 +116,46 @@ describe('WorktreeManager', () => {
await expect(wt.createWorktree(r.id, { branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 }); await expect(wt.createWorktree(r.id, { branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 });
}); });
it('startMainSession : session dans le checkout principal (branche actuelle, sans mutation git)', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
const out = await wt.startMainSession(r.id, { command: 'bash' });
expect(resolve(out.session.cwd)).toBe(resolve(repo));
expect(out.session).toMatchObject({ command: 'bash', live: true });
// pas de bascule de branche → toujours sur main.
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('main');
const main = (await wt.listRepoWorktrees(r.id, true)).find((w) => w.isMain);
expect(main?.sessions.some((s) => s.id === out.session.id)).toBe(true);
});
it('startMainSession : newBranch crée et bascule la branche dans le checkout principal + event', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
const events: Array<{ worktree: WorktreeSummary }> = [];
wt.on('worktree_update', (e) => events.push(e));
const out = await wt.startMainSession(r.id, { command: 'bash', branch: 'feature/x', newBranch: true });
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('feature/x');
expect(out.worktree?.branch).toBe('feature/x');
expect(events.some((e) => e.worktree.isMain && e.worktree.branch === 'feature/x')).toBe(true);
});
it('startMainSession : checkout principal sale → 409 DIRTY_TREE (pas de bascule)', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
writeFileSync(join(repo, 'scratch.txt'), 'wip\n'); // arbre sale
await expect(wt.startMainSession(r.id, { command: 'bash', branch: 'feature/y', newBranch: true })).rejects.toMatchObject({
statusCode: 409,
code: 'DIRTY_TREE',
});
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('main');
});
it('startMainSession : branche invalide → 400', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
await expect(wt.startMainSession(r.id, { command: 'bash', branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 });
});
it('deleteWorktree : main refusé (400), dirty refusé (409) puis force OK', async () => { it('deleteWorktree : main refusé (400), dirty refusé (409) puis force OK', async () => {
const repo = makeTmpRepo(); const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo }); const r = await wt.addRepo({ path: repo });

View File

@@ -128,6 +128,20 @@ export interface AdoptWorktreeRequest {
preTrust?: boolean; preTrust?: boolean;
} }
/**
* POST /api/v1/repos/:id/session — lance UNE session dans le checkout principal du repo
* (`repo.path`) pour « bosser sur la branche principale » sans créer de worktree.
* Réponse : `SessionResponse`.
*/
export interface StartRepoSessionRequest {
/** binaire à lancer — défaut "claude" ; "bash" sert aux tests sans quota. */
command?: 'claude' | 'bash';
/** si fourni : crée/bascule cette branche dans le checkout principal avant la session. */
branch?: string;
/** true (défaut quand `branch` fourni) : `git switch -c` ; false : bascule sur une branche existante. */
newBranch?: boolean;
}
// ---- Groupes de travail (P5) ---- // ---- Groupes de travail (P5) ----
export interface GroupsListResponse { export interface GroupsListResponse {
groups: GroupSummary[]; groups: GroupSummary[];

View File

@@ -5,7 +5,7 @@
<BaseBadge v-if="!repo.valid" tone="red">{{ t('repos.invalid') }}</BaseBadge> <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> <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"> <div class="ml-auto flex items-center gap-2">
<BaseButton size="sm" :icon="Plus" @click="creating = !creating">{{ t('worktrees.new') }}</BaseButton> <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="Scissors" :loading="busy" @click="onPrune">{{ t('worktrees.prune') }}</BaseButton>
<BaseButton <BaseButton
size="sm" size="sm"
@@ -18,23 +18,58 @@
</div> </div>
</header> </header>
<form v-if="creating" class="card-inset flex flex-wrap items-end gap-2" @submit.prevent="onCreate"> <form v-if="creating" class="card-inset flex flex-col gap-2" @submit.prevent="onCreate">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400"> <!-- mode : créer un worktree dédié, ou bosser directement sur le checkout principal -->
{{ t('worktrees.branch') }} <div class="flex flex-wrap items-center gap-3 text-xs text-zinc-400">
<input v-model="branch" class="input font-mono" placeholder="feature/…" required /> {{ t('worktrees.modeLabel') }}
</label> <label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="worktree" /> {{ t('worktrees.modeWorktree') }}</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400"> <label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="main" /> {{ t('worktrees.modeMain') }}</label>
{{ t('worktrees.start') }} </div>
<select v-model="startSession" class="input">
<option :value="null">{{ t('worktrees.startNone') }}</option> <div class="flex flex-wrap items-end gap-2">
<option value="claude">claude</option> <!-- mode worktree : branche + créer/existante -->
<option value="bash">bash</option> <template v-if="mode === 'worktree'">
</select> <label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
</label> {{ t('worktrees.branch') }}
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</label> <input v-model="branch" class="input font-mono" placeholder="feature/…" required />
<BaseButton type="submit" variant="primary" size="sm" :loading="busy" :disabled="branch.trim() === ''"> </label>
{{ t('worktrees.create') }} <label class="flex flex-col gap-1 text-xs text-zinc-400">
</BaseButton> {{ 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>
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</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> </form>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p> <p v-if="error" class="text-sm text-red-400">{{ error }}</p>
@@ -66,20 +101,37 @@ const toasts = useToastsStore();
// applique le tri/filtre/recherche partagé (toolbar du dashboard) aux worktrees de ce repo. // 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 worktrees = computed(() => view.apply(store.worktreesForRepo(props.repo.id)));
const creating = ref(false); const creating = ref(false);
const mode = ref<'worktree' | 'main'>('worktree');
const branch = ref(''); const branch = ref('');
const newBranch = ref(true); const newBranch = ref(true);
const startSession = ref<'claude' | 'bash' | null>(null); const startSession = ref<'claude' | 'bash' | null>(null);
const mainBranchMode = ref<'current' | 'new'>('current');
const mainCommand = ref<'claude' | 'bash'>('claude');
const busy = ref(false); const busy = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
// 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> { async function onCreate(): Promise<void> {
busy.value = true; busy.value = true;
error.value = null; error.value = null;
try { try {
await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value }); if (mode.value === 'worktree') {
await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, 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 = ''; branch.value = '';
creating.value = false; creating.value = false;
toasts.success(t('toast.worktreeCreated'));
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : String(err); error.value = err instanceof Error ? err.message : String(err);
} finally { } finally {

View File

@@ -26,6 +26,30 @@
</RouterLink> </RouterLink>
<span v-if="worktree.sessions.length === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span> <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"> <div v-if="!worktree.isMain" class="ml-auto flex items-center gap-2">
<template v-if="confirming"> <template v-if="confirming">
<span v-if="error" class="text-xs text-amber-400">{{ error }}</span> <span v-if="error" class="text-xs text-amber-400">{{ error }}</span>
@@ -74,6 +98,39 @@ const forceNeeded = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const busy = ref(false); 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(() => const branchLabel = computed(() =>
props.worktree.branch ?? (props.worktree.detached ? `${t('worktrees.detached')} ${props.worktree.head.slice(0, 7)}` : '—'), props.worktree.branch ?? (props.worktree.detached ? `${t('worktrees.detached')} ${props.worktree.head.slice(0, 7)}` : '—'),
); );

View File

@@ -141,7 +141,16 @@ export default {
branch: 'Branch', branch: 'Branch',
newBranch: 'create branch', newBranch: 'create branch',
start: 'Start', start: 'Start',
starting: 'Starting…',
startNone: 'no session', startNone: 'no session',
startWork: 'Start work',
modeLabel: 'Mode',
modeWorktree: 'Worktree',
modeMain: 'Main branch',
branchModeLabel: 'Branch',
currentBranch: 'current branch',
newBranchOpt: 'new branch',
commandLabel: 'Command',
delete: 'Delete', delete: 'Delete',
confirmDelete: 'Confirm delete', confirmDelete: 'Confirm delete',
forceDelete: 'Force delete', forceDelete: 'Force delete',

View File

@@ -143,7 +143,16 @@ const fr: typeof en = {
branch: 'Branche', branch: 'Branche',
newBranch: 'créer la branche', newBranch: 'créer la branche',
start: 'Démarrer', start: 'Démarrer',
starting: 'Démarrage…',
startNone: 'aucune session', startNone: 'aucune session',
startWork: 'Démarrer le travail',
modeLabel: 'Mode',
modeWorktree: 'Worktree',
modeMain: 'Branche principale',
branchModeLabel: 'Branche',
currentBranch: 'branche actuelle',
newBranchOpt: 'nouvelle branche',
commandLabel: 'Commande',
delete: 'Supprimer', delete: 'Supprimer',
confirmDelete: 'Confirmer', confirmDelete: 'Confirmer',
forceDelete: 'Forcer la suppression', forceDelete: 'Forcer la suppression',

View File

@@ -7,6 +7,9 @@ import type {
RepoResponse, RepoResponse,
ReposListResponse, ReposListResponse,
RepoSummary, RepoSummary,
SessionResponse,
SessionSummary,
StartRepoSessionRequest,
WorktreeSummary, WorktreeSummary,
WorktreesListResponse, WorktreesListResponse,
} from '@arboretum/shared'; } from '@arboretum/shared';
@@ -104,6 +107,16 @@ export const useWorktreesStore = defineStore('worktrees', () => {
return res; return res;
} }
/**
* Lance une session sur le checkout principal du repo (« bosser sur la branche principale » sans
* worktree). Avec `branch`, le serveur crée/bascule la branche d'abord. La session et le worktree
* mis à jour arrivent aussi par WS ; on retourne la session pour permettre la navigation immédiate.
*/
async function startMainSession(repoId: string, req: StartRepoSessionRequest = {}): Promise<SessionSummary> {
const res = await api.post<SessionResponse>(`/api/v1/repos/${repoId}/session`, req);
return res.session;
}
async function deleteWorktree(repoId: string, path: string, force: boolean): Promise<void> { async function deleteWorktree(repoId: string, path: string, force: boolean): Promise<void> {
await api.delete<{ ok: true }>(`/api/v1/repos/${repoId}/worktrees?path=${encodeURIComponent(path)}&force=${force}`); await api.delete<{ ok: true }>(`/api/v1/repos/${repoId}/worktrees?path=${encodeURIComponent(path)}&force=${force}`);
removeWorktreeLocal(path); removeWorktreeLocal(path);
@@ -135,6 +148,7 @@ export const useWorktreesStore = defineStore('worktrees', () => {
discover, discover,
refreshRepoWorktrees, refreshRepoWorktrees,
createWorktree, createWorktree,
startMainSession,
deleteWorktree, deleteWorktree,
prune, prune,
startRealtime, startRealtime,