Compare commits
3 Commits
ea7b2fd278
...
8036de2f0d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8036de2f0d | |||
| 08539f4ca6 | |||
| 915fdf185d |
2
package-lock.json
generated
2
package-lock.json
generated
@@ -4854,7 +4854,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@johanleroy/git-arboretum",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@johanleroy/git-arboretum",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 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.
|
||||
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 { join, dirname, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -119,6 +119,24 @@ try {
|
||||
const feat = wlist1.worktrees.find((w) => w.branch === 'feat');
|
||||
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).
|
||||
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);
|
||||
|
||||
@@ -190,6 +190,16 @@ export async function addWorktree(
|
||||
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> {
|
||||
await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
listWorktrees,
|
||||
pruneWorktrees,
|
||||
removeWorktree,
|
||||
switchBranch,
|
||||
worktreeStatus,
|
||||
type ParsedWorktree,
|
||||
} 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[] }> {
|
||||
const row = this.getRepoRow(repoId);
|
||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
AdoptWorktreeRequest,
|
||||
CreateWorktreeRequest,
|
||||
CreateWorktreeResponse,
|
||||
SessionResponse,
|
||||
StartRepoSessionRequest,
|
||||
WorktreeResponse,
|
||||
WorktreesListResponse,
|
||||
} 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) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Partial<AdoptWorktreeRequest> | null;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
addWorktree,
|
||||
removeWorktree,
|
||||
pruneWorktrees,
|
||||
switchBranch,
|
||||
isDirtyWorktreeError,
|
||||
} 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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const repo = makeTmpRepo();
|
||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`);
|
||||
|
||||
@@ -116,6 +116,46 @@ describe('WorktreeManager', () => {
|
||||
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 () => {
|
||||
const repo = makeTmpRepo();
|
||||
const r = await wt.addRepo({ path: repo });
|
||||
|
||||
@@ -128,6 +128,20 @@ export interface AdoptWorktreeRequest {
|
||||
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) ----
|
||||
export interface GroupsListResponse {
|
||||
groups: GroupSummary[];
|
||||
|
||||
@@ -17,7 +17,7 @@ export default {
|
||||
featKicker: 'Everything in one view',
|
||||
featTitle: 'Built to supervise agents — not to be an IDE',
|
||||
feat1Title: 'Worktree-first',
|
||||
feat1Desc: 'Every session is pinned to a git worktree. You see branches, not chaos.',
|
||||
feat1Desc: 'Work on your main branch or spin up an isolated worktree — every session stays pinned to a branch. Branches, not chaos.',
|
||||
feat2Title: 'Many sessions, one view',
|
||||
feat2Desc: 'Run parallel Claude Code agents side by side in a single live grid.',
|
||||
feat3Title: 'Fine-grained states',
|
||||
@@ -56,7 +56,7 @@ export default {
|
||||
step2Title: 'Open the dashboard',
|
||||
step2Desc: 'It serves a web dashboard on localhost. Install it as a PWA on any device.',
|
||||
step3Title: 'Spawn & supervise',
|
||||
step3Desc: 'Start Claude Code sessions on any worktree and watch them from anywhere.',
|
||||
step3Desc: 'Start Claude Code sessions on your main branch or any worktree, and watch them from anywhere.',
|
||||
secKicker: 'Security',
|
||||
secTitle: 'A web terminal you can actually trust',
|
||||
secIntro: 'Built local-first. Nothing is exposed unless you choose to — and even then, on your terms.',
|
||||
|
||||
@@ -17,7 +17,7 @@ export default {
|
||||
featKicker: 'Tout dans une seule vue',
|
||||
featTitle: 'Conçu pour superviser les agents — pas pour être un IDE',
|
||||
feat1Title: "Worktree d'abord",
|
||||
feat1Desc: 'Chaque session est rattachée à un worktree git. Vous voyez des branches, pas du chaos.',
|
||||
feat1Desc: 'Travaillez sur votre branche principale ou créez un worktree isolé — chaque session reste rattachée à une branche. Des branches, pas du chaos.',
|
||||
feat2Title: 'Plusieurs sessions, une vue',
|
||||
feat2Desc: 'Lancez des agents Claude Code en parallèle, côte à côte dans une seule grille en direct.',
|
||||
feat3Title: 'États fins',
|
||||
@@ -56,7 +56,7 @@ export default {
|
||||
step2Title: 'Ouvrez le dashboard',
|
||||
step2Desc: "Il sert un dashboard web sur localhost. Installez-le en PWA sur n'importe quel appareil.",
|
||||
step3Title: 'Lancez & supervisez',
|
||||
step3Desc: "Démarrez des sessions Claude Code sur n'importe quel worktree et surveillez-les de partout.",
|
||||
step3Desc: "Démarrez des sessions Claude Code sur votre branche principale ou n'importe quel worktree, et surveillez-les de partout.",
|
||||
secKicker: 'Sécurité',
|
||||
secTitle: 'Un terminal web en qui vous pouvez avoir confiance',
|
||||
secIntro: "Conçu local-first. Rien n'est exposé sauf si vous le décidez — et toujours selon vos règles.",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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.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"
|
||||
@@ -18,7 +18,17 @@
|
||||
</div>
|
||||
</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">
|
||||
<!-- 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éer/existante -->
|
||||
<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 />
|
||||
@@ -32,9 +42,34 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</label>
|
||||
<BaseButton type="submit" variant="primary" size="sm" :loading="busy" :disabled="branch.trim() === ''">
|
||||
{{ t('worktrees.create') }}
|
||||
</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>
|
||||
|
||||
@@ -66,20 +101,37 @@ 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 newBranch = ref(true);
|
||||
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);
|
||||
|
||||
// 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(), 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 = '';
|
||||
creating.value = false;
|
||||
toasts.success(t('toast.worktreeCreated'));
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
|
||||
@@ -26,6 +26,30 @@
|
||||
</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>
|
||||
@@ -74,6 +98,39 @@ 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)}` : '—'),
|
||||
);
|
||||
|
||||
@@ -141,7 +141,16 @@ export default {
|
||||
branch: 'Branch',
|
||||
newBranch: 'create branch',
|
||||
start: 'Start',
|
||||
starting: 'Starting…',
|
||||
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',
|
||||
confirmDelete: 'Confirm delete',
|
||||
forceDelete: 'Force delete',
|
||||
|
||||
@@ -143,7 +143,16 @@ const fr: typeof en = {
|
||||
branch: 'Branche',
|
||||
newBranch: 'créer la branche',
|
||||
start: 'Démarrer',
|
||||
starting: 'Démarrage…',
|
||||
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',
|
||||
confirmDelete: 'Confirmer',
|
||||
forceDelete: 'Forcer la suppression',
|
||||
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
RepoResponse,
|
||||
ReposListResponse,
|
||||
RepoSummary,
|
||||
SessionResponse,
|
||||
SessionSummary,
|
||||
StartRepoSessionRequest,
|
||||
WorktreeSummary,
|
||||
WorktreesListResponse,
|
||||
} from '@arboretum/shared';
|
||||
@@ -104,6 +107,16 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
||||
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> {
|
||||
await api.delete<{ ok: true }>(`/api/v1/repos/${repoId}/worktrees?path=${encodeURIComponent(path)}&force=${force}`);
|
||||
removeWorktreeLocal(path);
|
||||
@@ -135,6 +148,7 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
||||
discover,
|
||||
refreshRepoWorktrees,
|
||||
createWorktree,
|
||||
startMainSession,
|
||||
deleteWorktree,
|
||||
prune,
|
||||
startRealtime,
|
||||
|
||||
Reference in New Issue
Block a user