Compare commits
7 Commits
dcb6cd1ec7
...
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8036de2f0d | |||
| 08539f4ca6 | |||
| 915fdf185d | |||
| ea7b2fd278 | |||
| 4e1c5b2c33 | |||
| c6ea9f7930 | |||
| ca6700d6ce |
2
package-lock.json
generated
2
package-lock.json
generated
@@ -4854,7 +4854,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.4.1",
|
"version": "1.6.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.4.1",
|
"version": "1.6.0",
|
||||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -24,15 +24,20 @@ const check = (name, ok, detail = '') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-'));
|
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-'));
|
||||||
|
function initRepo(path) {
|
||||||
|
execFileSync('mkdir', ['-p', path]);
|
||||||
|
const git = (...args) => execFileSync('git', args, { cwd: path, stdio: 'pipe' });
|
||||||
|
git('init', '-b', 'main');
|
||||||
|
git('config', 'user.email', 'test@arboretum.dev');
|
||||||
|
git('config', 'user.name', 'Test');
|
||||||
|
execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: path });
|
||||||
|
git('add', '-A');
|
||||||
|
git('commit', '-m', 'init');
|
||||||
|
}
|
||||||
const repo = join(tmp, 'demo-repo');
|
const repo = join(tmp, 'demo-repo');
|
||||||
execFileSync('mkdir', ['-p', repo]);
|
const repo2 = join(tmp, 'demo-repo-2');
|
||||||
const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' });
|
initRepo(repo);
|
||||||
git('init', '-b', 'main');
|
initRepo(repo2);
|
||||||
git('config', 'user.email', 'test@arboretum.dev');
|
|
||||||
git('config', 'user.name', 'Test');
|
|
||||||
execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: repo });
|
|
||||||
git('add', '-A');
|
|
||||||
git('commit', '-m', 'init');
|
|
||||||
|
|
||||||
const srv = spawn(
|
const srv = spawn(
|
||||||
'node',
|
'node',
|
||||||
@@ -109,6 +114,41 @@ try {
|
|||||||
const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id));
|
const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id));
|
||||||
check('broadcast WS group_update (ajout repo)', !!pushedAdd);
|
check('broadcast WS group_update (ajout repo)', !!pushedAdd);
|
||||||
|
|
||||||
|
// ---- P6 : session de groupe multi-repo (UNE session couvrant tous les repos via --add-dir) ----
|
||||||
|
// Enregistre un 2e repo, l'ajoute au groupe, puis lance UNE session de groupe (bash, sans quota).
|
||||||
|
const addRepo2 = await j('/api/v1/repos', 'POST', cookie, { path: repo2 });
|
||||||
|
const repo2Summary = (await addRepo2.json()).repo;
|
||||||
|
check('POST /repos (2e repo) → 201', addRepo2.status === 201 && repo2Summary?.valid === true);
|
||||||
|
await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: repo2Summary.id });
|
||||||
|
|
||||||
|
// Mode « checkouts principaux » (pas de branch) : couvre le worktree principal de chaque repo.
|
||||||
|
const gsRes = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash' });
|
||||||
|
const gsBody = await gsRes.json();
|
||||||
|
const gsession = gsBody.session;
|
||||||
|
check('POST /groups/:id/session → 201', gsRes.status === 201 && !!gsession);
|
||||||
|
check('session de groupe : 2 répertoires couverts', Array.isArray(gsBody.dirs) && gsBody.dirs.length === 2);
|
||||||
|
check('session de groupe : addedDirs (1 dir supplémentaire)', (gsession?.addedDirs?.length ?? 0) === 1);
|
||||||
|
check('session de groupe : groupId posé', gsession?.groupId === group.id);
|
||||||
|
check('session de groupe : cwd primaire + addedDir = 2 repos', new Set([gsession?.cwd, ...(gsession?.addedDirs ?? [])]).size === 2);
|
||||||
|
|
||||||
|
// La session apparaît dans la liste globale avec son groupId.
|
||||||
|
const sessList = await (await j('/api/v1/sessions', 'GET', cookie)).json();
|
||||||
|
const listed = sessList.sessions?.find((s) => s.id === gsession.id);
|
||||||
|
check('GET /sessions : session de groupe présente avec groupId', listed?.groupId === group.id);
|
||||||
|
|
||||||
|
// broadcast WS session_update reçu pour la session de groupe.
|
||||||
|
const pushedSession = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === gsession.id);
|
||||||
|
check('broadcast WS session_update (session de groupe)', !!pushedSession);
|
||||||
|
|
||||||
|
// groupe sans worktree résolu (branche inexistante) → 400.
|
||||||
|
const gsBad = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash', branch: 'no/such/branch' });
|
||||||
|
check('POST /groups/:id/session branche absente → 400', gsBad.status === 400);
|
||||||
|
|
||||||
|
// Arrêt de la session de groupe + nettoyage du 2e repo (CASCADE retire repo2 de la membership).
|
||||||
|
const killSession = await j(`/api/v1/sessions/${gsession.id}`, 'DELETE', cookie);
|
||||||
|
check('DELETE session de groupe → 200', killSession.status === 200);
|
||||||
|
await j(`/api/v1/repos/${repo2Summary.id}`, 'DELETE', cookie);
|
||||||
|
|
||||||
// Renommage via PATCH.
|
// Renommage via PATCH.
|
||||||
const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' });
|
const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' });
|
||||||
check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed');
|
check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed');
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
||||||
registerSessionRoutes(app, manager, discovery);
|
registerSessionRoutes(app, manager, discovery);
|
||||||
registerRepoRoutes(app, worktrees, db);
|
registerRepoRoutes(app, worktrees, db);
|
||||||
registerGroupRoutes(app, groups, db);
|
registerGroupRoutes(app, groups, db, worktrees, manager);
|
||||||
registerWorktreeRoutes(app, worktrees);
|
registerWorktreeRoutes(app, worktrees);
|
||||||
registerPushRoutes(app, push, db);
|
registerPushRoutes(app, push, db);
|
||||||
registerSettingsRoutes(app, db, config, serverVersion, push);
|
registerSettingsRoutes(app, db, config, serverVersion, push);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface SpawnOptions {
|
|||||||
command: 'claude' | 'bash';
|
command: 'claude' | 'bash';
|
||||||
/** reprise d'une session existante (P2) : `--resume <id>`, `--fork-session` si fork. */
|
/** reprise d'une session existante (P2) : `--resume <id>`, `--fork-session` si fork. */
|
||||||
resume?: { claudeSessionId: string; fork?: boolean };
|
resume?: { claudeSessionId: string; fork?: boolean };
|
||||||
|
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
|
||||||
|
addDirs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedClaudeBin: string | null = null;
|
let cachedClaudeBin: string | null = null;
|
||||||
@@ -42,5 +44,7 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
|||||||
args.push('--resume', opts.resume.claudeSessionId);
|
args.push('--resume', opts.resume.claudeSessionId);
|
||||||
if (opts.resume.fork) args.push('--fork-session');
|
if (opts.resume.fork) args.push('--fork-session');
|
||||||
}
|
}
|
||||||
|
// Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6).
|
||||||
|
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
|
||||||
return { file: resolveClaudeBin(), args, env };
|
return { file: resolveClaudeBin(), args, env };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ export class GroupManager extends EventEmitter<GroupManagerEvents> {
|
|||||||
// CASCADE (PRAGMA foreign_keys = ON) purge group_repos.
|
// CASCADE (PRAGMA foreign_keys = ON) purge group_repos.
|
||||||
const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id);
|
const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id);
|
||||||
if (res.changes === 0) return false;
|
if (res.changes === 0) return false;
|
||||||
|
// Les sessions de groupe (P6) ne sont pas en FK : on désorpheline leur group_id manuellement.
|
||||||
|
this.db.prepare('UPDATE sessions SET group_id = NULL WHERE group_id = ?').run(id);
|
||||||
this.emit('group_removed', id);
|
this.emit('group_removed', id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ const NOTIFY_DEBOUNCE_MS = 1500;
|
|||||||
const CLAUDE_ID_POLL_MS = 400;
|
const CLAUDE_ID_POLL_MS = 400;
|
||||||
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
|
||||||
|
function parseAddedDirs(raw: string | null): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
|
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
|
||||||
export interface ClientBinding {
|
export interface ClientBinding {
|
||||||
channel: number;
|
channel: number;
|
||||||
@@ -48,6 +59,10 @@ interface ManagedSession {
|
|||||||
killTimer: NodeJS.Timeout | null;
|
killTimer: NodeJS.Timeout | null;
|
||||||
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
|
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
|
||||||
claudeSessionId: string | null;
|
claudeSessionId: string | null;
|
||||||
|
/** répertoires supplémentaires reliés dans la session (--add-dir) ; [] pour une session mono-repo (P6). */
|
||||||
|
addedDirs: string[];
|
||||||
|
/** groupe propriétaire d'une session de groupe multi-repo ; null sinon (P6). */
|
||||||
|
groupId: string | null;
|
||||||
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
||||||
tracker: SessionActivityTracker | null;
|
tracker: SessionActivityTracker | null;
|
||||||
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
|
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
|
||||||
@@ -72,14 +87,33 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
spawn(opts: { cwd: string; command?: 'claude' | 'bash'; resume?: { claudeSessionId: string; fork?: boolean } }): SessionSummary {
|
spawn(opts: {
|
||||||
|
cwd: string;
|
||||||
|
command?: 'claude' | 'bash';
|
||||||
|
resume?: { claudeSessionId: string; fork?: boolean };
|
||||||
|
/** répertoires supplémentaires à relier (session de groupe multi-repo, P6). */
|
||||||
|
addDirs?: string[];
|
||||||
|
/** groupe propriétaire (session de groupe, P6). */
|
||||||
|
groupId?: string;
|
||||||
|
}): SessionSummary {
|
||||||
const cwd = opts.cwd;
|
const cwd = opts.cwd;
|
||||||
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
||||||
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
|
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
// Dédoublonne et écarte le cwd primaire ; valide chaque répertoire supplémentaire (comme le cwd).
|
||||||
|
const addedDirs = [...new Set(opts.addDirs ?? [])].filter((d) => d !== cwd);
|
||||||
|
for (const dir of addedDirs) {
|
||||||
|
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
||||||
|
throw Object.assign(new Error(`Not a directory: ${dir}`), { statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
|
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
|
||||||
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
|
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
|
||||||
const spec = buildSpawnSpec({ command, ...(opts.resume ? { resume: opts.resume } : {}) });
|
const spec = buildSpawnSpec({
|
||||||
|
command,
|
||||||
|
...(opts.resume ? { resume: opts.resume } : {}),
|
||||||
|
...(addedDirs.length ? { addDirs: addedDirs } : {}),
|
||||||
|
});
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const proc = pty.spawn(spec.file, spec.args, {
|
const proc = pty.spawn(spec.file, spec.args, {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
@@ -101,6 +135,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
exited: null,
|
exited: null,
|
||||||
killTimer: null,
|
killTimer: null,
|
||||||
claudeSessionId: null,
|
claudeSessionId: null,
|
||||||
|
addedDirs,
|
||||||
|
groupId: opts.groupId ?? null,
|
||||||
tracker: null,
|
tracker: null,
|
||||||
prevActivity: null,
|
prevActivity: null,
|
||||||
notifyTimer: null,
|
notifyTimer: null,
|
||||||
@@ -116,8 +152,16 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
}
|
}
|
||||||
this.live.set(id, session);
|
this.live.set(id, session);
|
||||||
this.db
|
this.db
|
||||||
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
|
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from, added_dirs, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||||
.run(id, cwd, command, session.createdAt, opts.resume?.claudeSessionId ?? null);
|
.run(
|
||||||
|
id,
|
||||||
|
cwd,
|
||||||
|
command,
|
||||||
|
session.createdAt,
|
||||||
|
opts.resume?.claudeSessionId ?? null,
|
||||||
|
addedDirs.length ? JSON.stringify(addedDirs) : null,
|
||||||
|
session.groupId,
|
||||||
|
);
|
||||||
|
|
||||||
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
||||||
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
||||||
@@ -145,6 +189,18 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
|
setTimeout(tick, CLAUDE_ID_POLL_MS).unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexte de session de groupe (P6) à réinjecter au resume : derniers `added_dirs`/`group_id`
|
||||||
|
* persistés pour ce claudeSessionId. Permet à `--resume` de re-relier les mêmes répertoires.
|
||||||
|
*/
|
||||||
|
groupSessionContext(claudeSessionId: string): { addedDirs: string[]; groupId: string | null } | null {
|
||||||
|
const row = this.db
|
||||||
|
.prepare('SELECT added_dirs, group_id FROM sessions WHERE claude_session_id = ? AND added_dirs IS NOT NULL ORDER BY created_at DESC LIMIT 1')
|
||||||
|
.get(claudeSessionId) as { added_dirs: string | null; group_id: string | null } | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return { addedDirs: parseAddedDirs(row.added_dirs), groupId: row.group_id };
|
||||||
|
}
|
||||||
|
|
||||||
/** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
|
/** Session managée VIVANTE portant ce claudeSessionId (garde-fou anti-resume d'une session vivante). */
|
||||||
findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
|
findLiveByClaudeSessionId(claudeSessionId: string): SessionSummary | null {
|
||||||
for (const s of this.live.values()) {
|
for (const s of this.live.values()) {
|
||||||
@@ -170,29 +226,34 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
||||||
const liveIds = new Set(this.live.keys());
|
const liveIds = new Set(this.live.keys());
|
||||||
const rows = this.db
|
const rows = this.db
|
||||||
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id FROM sessions ORDER BY created_at DESC LIMIT 100')
|
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id FROM sessions ORDER BY created_at DESC LIMIT 100')
|
||||||
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null }>;
|
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null; claude_session_id: string | null; added_dirs: string | null; group_id: string | null }>;
|
||||||
const historical: SessionSummary[] = rows
|
const historical: SessionSummary[] = rows
|
||||||
.filter((r) => !liveIds.has(r.id))
|
.filter((r) => !liveIds.has(r.id))
|
||||||
.map((r) => ({
|
.map((r) => {
|
||||||
|
const addedDirs = parseAddedDirs(r.added_dirs);
|
||||||
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
cwd: r.cwd,
|
cwd: r.cwd,
|
||||||
command: r.command,
|
command: r.command,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
status: 'exited',
|
status: 'exited' as const,
|
||||||
live: false,
|
live: false,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
endedAt: r.ended_at,
|
endedAt: r.ended_at,
|
||||||
exitCode: r.exit_code,
|
exitCode: r.exit_code,
|
||||||
clients: 0,
|
clients: 0,
|
||||||
source: 'managed',
|
source: 'managed' as const,
|
||||||
claudeSessionId: r.claude_session_id,
|
claudeSessionId: r.claude_session_id,
|
||||||
pid: null,
|
pid: null,
|
||||||
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
|
// une session claude morte avec un claudeSessionId connu est reprenable (--resume direct).
|
||||||
resumable: r.command === 'claude' && r.claude_session_id != null,
|
resumable: r.command === 'claude' && r.claude_session_id != null,
|
||||||
attachable: false,
|
attachable: false,
|
||||||
registryStatus: null,
|
registryStatus: null,
|
||||||
}));
|
...(addedDirs.length ? { addedDirs } : {}),
|
||||||
|
groupId: r.group_id,
|
||||||
|
};
|
||||||
|
});
|
||||||
return [...liveSummaries, ...historical];
|
return [...liveSummaries, ...historical];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +488,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
activity: act?.activity ?? null,
|
activity: act?.activity ?? null,
|
||||||
waitingFor: act?.waitingFor ?? null,
|
waitingFor: act?.waitingFor ?? null,
|
||||||
dialog: act?.dialog ?? null,
|
dialog: act?.dialog ?? null,
|
||||||
|
...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}),
|
||||||
|
groupId: s.groupId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
CREATE INDEX idx_audit_ts ON audit_logs(ts);
|
CREATE INDEX idx_audit_ts ON audit_logs(ts);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// P6 — session de groupe multi-repo. Une session peut couvrir plusieurs répertoires (--add-dir)
|
||||||
|
// et appartenir à un groupe. `added_dirs` : JSON array de chemins absolus (NULL si mono-repo).
|
||||||
|
// `group_id` : pas de FK (ALTER ADD COLUMN sqlite n'en pose pas) ; nettoyé à la suppression du groupe.
|
||||||
|
id: 8,
|
||||||
|
sql: `
|
||||||
|
ALTER TABLE sessions ADD COLUMN added_dirs TEXT;
|
||||||
|
ALTER TABLE sessions ADD COLUMN group_id TEXT;
|
||||||
|
CREATE INDEX idx_sessions_group_id ON sessions(group_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Db = DatabaseSync;
|
export type Db = DatabaseSync;
|
||||||
|
|||||||
@@ -2,16 +2,26 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type {
|
import type {
|
||||||
AddRepoRequest,
|
AddRepoRequest,
|
||||||
CreateGroupRequest,
|
CreateGroupRequest,
|
||||||
|
CreateGroupSessionRequest,
|
||||||
GroupResponse,
|
GroupResponse,
|
||||||
|
GroupSessionResponse,
|
||||||
GroupsListResponse,
|
GroupsListResponse,
|
||||||
UpdateGroupRequest,
|
UpdateGroupRequest,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
import type { GroupManager } from '../core/group-manager.js';
|
import type { GroupManager } from '../core/group-manager.js';
|
||||||
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
import type { PtyManager } from '../core/pty-manager.js';
|
||||||
import type { Db } from '../db/index.js';
|
import type { Db } from '../db/index.js';
|
||||||
import { recordAudit } from '../core/audit-log.js';
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
import { sendManagerError } from './repos.js';
|
import { sendManagerError } from './repos.js';
|
||||||
|
|
||||||
export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db: Db): void {
|
export function registerGroupRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
gm: GroupManager,
|
||||||
|
db: Db,
|
||||||
|
wt: WorktreeManager,
|
||||||
|
manager: PtyManager,
|
||||||
|
): void {
|
||||||
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
|
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
|
||||||
|
|
||||||
app.get('/api/v1/groups/:id', async (req, reply) => {
|
app.get('/api/v1/groups/:id', async (req, reply) => {
|
||||||
@@ -88,4 +98,62 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db:
|
|||||||
return sendManagerError(reply, err);
|
return sendManagerError(reply, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Session de groupe (P6) : UNE session Claude couvrant tous les repos du groupe (--add-dir).
|
||||||
|
// Les répertoires sont résolus côté serveur depuis les propres worktrees du groupe — le client
|
||||||
|
// ne passe jamais de chemin brut. `branch` présent → worktree de cette branche par repo ;
|
||||||
|
// absent → le worktree principal de chaque repo.
|
||||||
|
app.post('/api/v1/groups/:id/session', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = (req.body as Partial<CreateGroupSessionRequest> | null) ?? {};
|
||||||
|
const command = body.command ?? 'claude';
|
||||||
|
if (command !== 'claude' && command !== 'bash') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
|
||||||
|
}
|
||||||
|
const branch = typeof body.branch === 'string' && body.branch.trim() !== '' ? body.branch.trim() : null;
|
||||||
|
|
||||||
|
let group;
|
||||||
|
try {
|
||||||
|
group = gm.getGroup(id);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
const skipped: Array<{ repoId: string; reason: string }> = [];
|
||||||
|
for (const repoId of group.repoIds) {
|
||||||
|
let worktrees;
|
||||||
|
try {
|
||||||
|
worktrees = await wt.listRepoWorktrees(repoId);
|
||||||
|
} catch (err) {
|
||||||
|
skipped.push({ repoId, reason: err instanceof Error ? err.message : String(err) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = branch ? worktrees.find((w) => w.branch === branch) : worktrees.find((w) => w.isMain);
|
||||||
|
if (!match) {
|
||||||
|
skipped.push({ repoId, reason: branch ? `no worktree on branch ${branch}` : 'no main worktree' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!dirs.includes(match.path)) dirs.push(match.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [primary, ...rest] = dirs;
|
||||||
|
if (primary === undefined) {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = manager.spawn({ cwd: primary, addDirs: rest, command, groupId: id });
|
||||||
|
recordAudit(db, {
|
||||||
|
actor: req.authContext?.tokenId ?? 'unknown',
|
||||||
|
action: 'group.session.create',
|
||||||
|
resourceId: id,
|
||||||
|
details: { command, dirs: dirs.length, ...(branch ? { branch } : {}) },
|
||||||
|
});
|
||||||
|
return reply.status(201).send({ session, dirs, skipped } satisfies GroupSessionResponse);
|
||||||
|
} catch (err) {
|
||||||
|
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
|
||||||
|
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,14 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
|
|||||||
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } });
|
return reply.status(409).send({ error: { code: 'SESSION_LIVE', message: 'Session is live — fork it instead' } });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const session = manager.spawn({ cwd: discovered.cwd, resume: { claudeSessionId: id } });
|
// Session de groupe (P6) : re-relie les mêmes répertoires (--add-dir) et son groupe au resume.
|
||||||
|
const ctx = manager.groupSessionContext(id);
|
||||||
|
const session = manager.spawn({
|
||||||
|
cwd: discovered.cwd,
|
||||||
|
resume: { claudeSessionId: id },
|
||||||
|
...(ctx?.addedDirs.length ? { addDirs: ctx.addedDirs } : {}),
|
||||||
|
...(ctx?.groupId ? { groupId: ctx.groupId } : {}),
|
||||||
|
});
|
||||||
const res: SessionResponse = { session };
|
const res: SessionResponse = { session };
|
||||||
return reply.status(201).send(res);
|
return reply.status(201).send(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
29
packages/server/test/claude-launcher.test.ts
Normal file
29
packages/server/test/claude-launcher.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildSpawnSpec } from '../src/core/claude-launcher.js';
|
||||||
|
|
||||||
|
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||||
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
|
|
||||||
|
describe('buildSpawnSpec — session de groupe multi-repo (P6)', () => {
|
||||||
|
it('émet un --add-dir par répertoire supplémentaire (claude)', () => {
|
||||||
|
const spec = buildSpawnSpec({ command: 'claude', addDirs: ['/a', '/b', '/c'] });
|
||||||
|
expect(spec.file).toBe('/usr/bin/claude');
|
||||||
|
expect(spec.args).toEqual(['--add-dir', '/a', '--add-dir', '/b', '--add-dir', '/c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combine --resume et --add-dir (reprise d’une session de groupe)', () => {
|
||||||
|
const spec = buildSpawnSpec({ command: 'claude', resume: { claudeSessionId: 'sid' }, addDirs: ['/x'] });
|
||||||
|
expect(spec.args).toEqual(['--resume', 'sid', '--add-dir', '/x']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aucun --add-dir quand addDirs est vide/absent', () => {
|
||||||
|
expect(buildSpawnSpec({ command: 'claude' }).args).toEqual([]);
|
||||||
|
expect(buildSpawnSpec({ command: 'claude', addDirs: [] }).args).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore addDirs pour bash (pas de --add-dir)', () => {
|
||||||
|
const spec = buildSpawnSpec({ command: 'bash', addDirs: ['/a', '/b'] });
|
||||||
|
expect(spec.file).toBe('bash');
|
||||||
|
expect(spec.args).toEqual(['--norc']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
|
|||||||
@@ -97,4 +97,15 @@ describe('GroupManager', () => {
|
|||||||
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
|
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
|
||||||
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
|
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deleteGroup : désorpheline le group_id des sessions de groupe (P6)', () => {
|
||||||
|
const g = gm.createGroup({ label: 'A' });
|
||||||
|
// session de groupe liée à g (insérée directement, comme le ferait PtyManager.spawn).
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO sessions (id, cwd, command, created_at, group_id) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
).run('sess-1', '/tmp/a', 'claude', new Date().toISOString(), g.id);
|
||||||
|
expect(gm.deleteGroup(g.id)).toBe(true);
|
||||||
|
const row = db.prepare('SELECT group_id FROM sessions WHERE id = ?').get('sess-1') as { group_id: string | null };
|
||||||
|
expect(row.group_id).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -574,4 +574,53 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
|
expect(manager.attach(summary.id, makeBinding('interactive'), 80, 24)).toEqual({ ok: false, code: 'NOT_FOUND' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('session de groupe multi-repo (P6)', () => {
|
||||||
|
it('spawn avec addDirs : --add-dir au pty, addedDirs + groupId résumés et persistés', () => {
|
||||||
|
const d1 = mkdtempSync(join(tmpdir(), 'arb-g1-'));
|
||||||
|
const d2 = mkdtempSync(join(tmpdir(), 'arb-g2-'));
|
||||||
|
try {
|
||||||
|
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d2], groupId: 'grp1' });
|
||||||
|
expect(lastPty().args).toEqual(['--add-dir', d1, '--add-dir', d2]);
|
||||||
|
expect(summary.addedDirs).toEqual([d1, d2]);
|
||||||
|
expect(summary.groupId).toBe('grp1');
|
||||||
|
const row = db.prepare('SELECT added_dirs, group_id FROM sessions WHERE id = ?').get(summary.id) as {
|
||||||
|
added_dirs: string | null;
|
||||||
|
group_id: string | null;
|
||||||
|
};
|
||||||
|
expect(JSON.parse(row.added_dirs as string)).toEqual([d1, d2]);
|
||||||
|
expect(row.group_id).toBe('grp1');
|
||||||
|
} finally {
|
||||||
|
rmSync(d1, { recursive: true, force: true });
|
||||||
|
rmSync(d2, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dédoublonne et écarte le cwd primaire des addDirs', () => {
|
||||||
|
const d1 = mkdtempSync(join(tmpdir(), 'arb-g4-'));
|
||||||
|
try {
|
||||||
|
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1, d1, cwd] });
|
||||||
|
expect(summary.addedDirs).toEqual([d1]);
|
||||||
|
} finally {
|
||||||
|
rmSync(d1, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette un addDir inexistant (400)', () => {
|
||||||
|
expect(() => manager.spawn({ cwd, command: 'bash', addDirs: ['/no/such/dir/xyz-arb'] })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groupSessionContext renvoie les addedDirs/groupId persistés (resume)', () => {
|
||||||
|
const d1 = mkdtempSync(join(tmpdir(), 'arb-g5-'));
|
||||||
|
try {
|
||||||
|
const summary = manager.spawn({ cwd, command: 'claude', addDirs: [d1], groupId: 'grpX' });
|
||||||
|
// simule la capture du claudeSessionId (normalement résolue via le registre)
|
||||||
|
db.prepare('UPDATE sessions SET claude_session_id = ? WHERE id = ?').run('cs-1', summary.id);
|
||||||
|
expect(manager.groupSessionContext('cs-1')).toEqual({ addedDirs: [d1], groupId: 'grpX' });
|
||||||
|
expect(manager.groupSessionContext('unknown')).toBeNull();
|
||||||
|
} finally {
|
||||||
|
rmSync(d1, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -152,6 +166,22 @@ export interface AddRepoRequest {
|
|||||||
repoId: string;
|
repoId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Session de groupe multi-repo (P6) ----
|
||||||
|
/** Lance UNE session Claude couvrant tous les repos du groupe (via `--add-dir`). */
|
||||||
|
export interface CreateGroupSessionRequest {
|
||||||
|
/** défaut : claude. */
|
||||||
|
command?: 'claude' | 'bash';
|
||||||
|
/** présent → couvre le worktree de cette branche dans chaque repo ; absent → les checkouts principaux. */
|
||||||
|
branch?: string;
|
||||||
|
}
|
||||||
|
export interface GroupSessionResponse {
|
||||||
|
session: SessionSummary;
|
||||||
|
/** répertoires effectivement couverts par la session (cwd primaire en tête). */
|
||||||
|
dirs: string[];
|
||||||
|
/** repos du groupe pour lesquels aucun worktree n'a pu être résolu. */
|
||||||
|
skipped: Array<{ repoId: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
|
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
|
||||||
export interface FsEntry {
|
export interface FsEntry {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ export interface SessionSummary {
|
|||||||
waitingFor?: string | null;
|
waitingFor?: string | null;
|
||||||
/** dialogue typé en cours (présent quand activity === 'waiting'). */
|
/** dialogue typé en cours (présent quand activity === 'waiting'). */
|
||||||
dialog?: SessionDialog | null;
|
dialog?: SessionDialog | null;
|
||||||
|
// ---- P6 : session de groupe multi-repo (additif) ----
|
||||||
|
/** répertoires supplémentaires couverts via `--add-dir` ; absent/[] pour une session mono-repo. */
|
||||||
|
addedDirs?: string[];
|
||||||
|
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
||||||
|
groupId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Worktrees & repos (P3) ----
|
// ---- Worktrees & repos (P3) ----
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
<noscript>
|
<noscript>
|
||||||
<div style="padding: 24px; font-family: system-ui, sans-serif; color: #f4f4f5; text-align: center">
|
<div style="padding: 24px; font-family: system-ui, sans-serif; color: #f4f4f5; text-align: center">
|
||||||
Arboretum — mission control for your AI coding agents.
|
Arboretum — mission control for your AI coding agents.
|
||||||
<a href="https://git.lidge.fr/johanleroy/git-arboretum" style="color: #34d399">View on Gitea</a>.
|
<a href="https://git.lidge.fr/johanleroy/arboretum" style="color: #34d399">View on Gitea</a>.
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
|||||||
16
packages/site/public/.htaccess
Normal file
16
packages/site/public/.htaccess
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# git-arboretum.com — site vitrine statique (Vue 3 / Vite).
|
||||||
|
#
|
||||||
|
# Google PageSpeed (mod_pagespeed) renvoie un corps HTML VIDE sur ce domaine Plesk
|
||||||
|
# (la home répond 200 mais 0 octet → page blanche), alors que les assets statiques
|
||||||
|
# passent. Les bundles Vite sont déjà minifiés, hashés et gzippés : PageSpeed
|
||||||
|
# n'apporte rien ici et casse le rendu. On le désactive pour ce vhost.
|
||||||
|
#
|
||||||
|
# NB : si l'hébergement interdit ces directives en .htaccess (AllowOverride
|
||||||
|
# restrictif), Apache renverra une 500 → dans ce cas, supprimer ce fichier et
|
||||||
|
# désactiver PageSpeed depuis Plesk (Apache & nginx Settings).
|
||||||
|
<IfModule pagespeed_module>
|
||||||
|
ModPagespeed off
|
||||||
|
</IfModule>
|
||||||
|
<IfModule ngx_pagespeed_module>
|
||||||
|
pagespeed off;
|
||||||
|
</IfModule>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||||
|
import { REPO, LICENSE, COFFEE } from '../lib/links';
|
||||||
import IconGitea from './icons/IconGitea.vue';
|
import IconGitea from './icons/IconGitea.vue';
|
||||||
|
import CopyButton from './CopyButton.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const REPO = 'https://git.lidge.fr/johanleroy/git-arboretum';
|
|
||||||
const LICENSE = 'https://git.lidge.fr/johanleroy/git-arboretum/src/branch/main/LICENSE';
|
|
||||||
const COFFEE = 'https://buymeacoffee.com/johanleroy';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -40,7 +40,10 @@ const COFFEE = 'https://buymeacoffee.com/johanleroy';
|
|||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2v2" /><path d="M14 2v2" /><path d="M5 8h13a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1Z" /><path d="M6 15a4 4 0 0 0 4 4h2a4 4 0 0 0 4-4" /><path d="M19 9h1.5a2.5 2.5 0 0 1 0 5H18" /></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2v2" /><path d="M14 2v2" /><path d="M5 8h13a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1Z" /><path d="M6 15a4 4 0 0 0 4 4h2a4 4 0 0 0 4-4" /><path d="M19 9h1.5a2.5 2.5 0 0 1 0 5H18" /></svg>
|
||||||
{{ t('coffee') }}
|
{{ t('coffee') }}
|
||||||
</a>
|
</a>
|
||||||
<span class="font-mono text-[12.5px] text-zinc-600">npx @johanleroy/git-arboretum</span>
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="font-mono text-[12.5px] text-zinc-600">{{ INSTALL_COMMAND }}</code>
|
||||||
|
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { REPO } from '../lib/links';
|
||||||
import LangToggle from './LangToggle.vue';
|
import LangToggle from './LangToggle.vue';
|
||||||
import IconGitea from './icons/IconGitea.vue';
|
import IconGitea from './icons/IconGitea.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const REPO = 'https://git.lidge.fr/johanleroy/git-arboretum';
|
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: '#features', key: 'navFeatures' },
|
{ href: '#features', key: 'navFeatures' },
|
||||||
|
|||||||
57
packages/site/src/components/CopyButton.vue
Normal file
57
packages/site/src/components/CopyButton.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useCopy } from '../composables/useCopy';
|
||||||
|
import IconCopy from './icons/IconCopy.vue';
|
||||||
|
import IconCheck from './icons/IconCheck.vue';
|
||||||
|
|
||||||
|
// Bouton « copier » réutilisable, avec retour visuel (icône ✓ + label) pendant 1800 ms.
|
||||||
|
// Deux variantes :
|
||||||
|
// - 'bar' : bouton étiqueté intégré aux barres de commande (hero, CTA final).
|
||||||
|
// - 'icon' : bouton compact icône seule, pour les blocs de code (étapes, footer).
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
text: string;
|
||||||
|
variant?: 'bar' | 'icon';
|
||||||
|
label?: string; // aria-label décrivant ce qui est copié
|
||||||
|
barClass?: string; // padding/ajustements de la variante 'bar'
|
||||||
|
}>(),
|
||||||
|
{ variant: 'bar', label: '', barClass: 'px-[15px]' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { copied, copy } = useCopy();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="variant === 'bar'"
|
||||||
|
type="button"
|
||||||
|
:aria-label="label || t('copy')"
|
||||||
|
:class="[
|
||||||
|
'inline-flex flex-none items-center gap-[7px] border-l border-zinc-800 bg-zinc-900 font-mono text-[13px] font-semibold transition-colors hover:bg-zinc-800 hover:text-emerald-400 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-500/70',
|
||||||
|
barClass,
|
||||||
|
copied ? 'text-emerald-400' : 'text-zinc-300',
|
||||||
|
]"
|
||||||
|
@click="copy(text)"
|
||||||
|
>
|
||||||
|
<component :is="copied ? IconCheck : IconCopy" :size="15" />
|
||||||
|
<span aria-live="polite">{{ copied ? t('copied') : t('copy') }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
:aria-label="label || t('copy')"
|
||||||
|
:title="copied ? t('copied') : label || t('copy')"
|
||||||
|
:class="[
|
||||||
|
'inline-flex flex-none items-center justify-center rounded-md border p-1.5 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70',
|
||||||
|
copied
|
||||||
|
? 'border-emerald-500/40 bg-zinc-800 text-emerald-400'
|
||||||
|
: 'border-zinc-800 bg-zinc-900/80 text-zinc-400 hover:border-emerald-500/40 hover:bg-zinc-800 hover:text-emerald-400',
|
||||||
|
]"
|
||||||
|
@click="copy(text)"
|
||||||
|
>
|
||||||
|
<component :is="copied ? IconCheck : IconCopy" :size="14" />
|
||||||
|
<span aria-live="polite" class="sr-only">{{ copied ? t('copied') : t('copy') }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useCopy, INSTALL_COMMAND } from '../composables/useCopy';
|
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||||
|
import { REPO } from '../lib/links';
|
||||||
import IconGitea from './icons/IconGitea.vue';
|
import IconGitea from './icons/IconGitea.vue';
|
||||||
import IconCopy from './icons/IconCopy.vue';
|
import CopyButton from './CopyButton.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { copied, copy } = useCopy();
|
|
||||||
const REPO = 'https://git.lidge.fr/johanleroy/git-arboretum';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -34,15 +33,7 @@ const REPO = 'https://git.lidge.fr/johanleroy/git-arboretum';
|
|||||||
<div class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto whitespace-nowrap px-4 font-mono text-sm">
|
<div class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto whitespace-nowrap px-4 font-mono text-sm">
|
||||||
<span class="text-emerald-400">$</span><span class="text-zinc-200">{{ INSTALL_COMMAND }}</span>
|
<span class="text-emerald-400">$</span><span class="text-zinc-200">{{ INSTALL_COMMAND }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<CopyButton variant="bar" :text="INSTALL_COMMAND" :label="t('copyCommand')" bar-class="px-4" />
|
||||||
type="button"
|
|
||||||
aria-label="Copy install command"
|
|
||||||
class="inline-flex flex-none items-center gap-[7px] border-l border-zinc-800 bg-zinc-900 px-4 font-mono text-[13px] font-semibold text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-emerald-400 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-500/70"
|
|
||||||
@click="copy()"
|
|
||||||
>
|
|
||||||
<IconCopy :size="15" />
|
|
||||||
<span aria-live="polite">{{ copied ? t('copied') : t('copy') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-[18px]">
|
<div class="mt-[18px]">
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const dlgOptions = computed(() =>
|
|||||||
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
||||||
<span class="ml-2 font-mono text-[11.5px] text-zinc-600">arboretum · localhost:7777</span>
|
<span class="ml-2 font-mono text-[11.5px] text-zinc-600">arboretum · localhost:7317</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-[360px]">
|
<div class="flex min-h-[360px]">
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useCopy, INSTALL_COMMAND } from '../composables/useCopy';
|
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||||
|
import { REPO } from '../lib/links';
|
||||||
import HeroMockup from './HeroMockup.vue';
|
import HeroMockup from './HeroMockup.vue';
|
||||||
import IconGitea from './icons/IconGitea.vue';
|
import IconGitea from './icons/IconGitea.vue';
|
||||||
import IconCopy from './icons/IconCopy.vue';
|
import CopyButton from './CopyButton.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { copied, copy } = useCopy();
|
|
||||||
const REPO = 'https://git.lidge.fr/johanleroy/git-arboretum';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,15 +43,7 @@ const REPO = 'https://git.lidge.fr/johanleroy/git-arboretum';
|
|||||||
>
|
>
|
||||||
<span class="text-emerald-400">$</span><span class="text-zinc-200">{{ INSTALL_COMMAND }}</span>
|
<span class="text-emerald-400">$</span><span class="text-zinc-200">{{ INSTALL_COMMAND }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<CopyButton variant="bar" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||||
type="button"
|
|
||||||
aria-label="Copy install command"
|
|
||||||
class="inline-flex flex-none items-center gap-[7px] border-l border-zinc-800 bg-zinc-900 px-[15px] font-mono text-[13px] font-semibold text-zinc-300 transition-colors hover:bg-zinc-800 hover:text-emerald-400 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-500/70"
|
|
||||||
@click="copy()"
|
|
||||||
>
|
|
||||||
<IconCopy :size="15" />
|
|
||||||
<span aria-live="polite">{{ copied ? t('copied') : t('copy') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap gap-[13px]">
|
<div class="mt-6 flex flex-wrap gap-[13px]">
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { INSTALL_COMMAND } from '../composables/useCopy';
|
||||||
|
import CopyButton from './CopyButton.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
// Port par défaut réel du daemon (cf. packages/server/src/config.ts).
|
||||||
|
const LOCAL_URL = 'http://localhost:7317';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -15,16 +20,22 @@ const { t } = useI18n();
|
|||||||
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">01</div>
|
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">01</div>
|
||||||
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step1Title') }}</h3>
|
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step1Title') }}</h3>
|
||||||
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step1Desc') }}</p>
|
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step1Desc') }}</p>
|
||||||
<div class="overflow-x-auto whitespace-nowrap rounded-lg border border-zinc-800 bg-zinc-950 p-[11px] font-mono text-[12.5px] text-zinc-200">
|
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950 p-[11px]">
|
||||||
<span class="text-emerald-400">$ </span>npx @johanleroy/git-arboretum
|
<code class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12.5px] text-zinc-200">
|
||||||
|
<span class="text-emerald-400">$ </span>{{ INSTALL_COMMAND }}
|
||||||
|
</code>
|
||||||
|
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
||||||
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">02</div>
|
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">02</div>
|
||||||
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step2Title') }}</h3>
|
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step2Title') }}</h3>
|
||||||
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step2Desc') }}</p>
|
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step2Desc') }}</p>
|
||||||
<div class="overflow-x-auto whitespace-nowrap rounded-lg border border-zinc-800 bg-zinc-950 p-[11px] font-mono text-[12.5px] text-sky-300">
|
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950 p-[11px]">
|
||||||
→ http://localhost:7777
|
<code class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12.5px] text-sky-300">
|
||||||
|
→ {{ LOCAL_URL }}
|
||||||
|
</code>
|
||||||
|
<CopyButton variant="icon" :text="LOCAL_URL" :label="t('copyUrl')" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const { t } = useI18n();
|
|||||||
>
|
>
|
||||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-mono text-[11px] text-zinc-300">payments</span>
|
<span class="font-mono text-[11px] text-zinc-300">{{ t('grpHub') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative h-[96px] flex-1">
|
<div class="relative h-[96px] flex-1">
|
||||||
<svg width="100%" height="100%" viewBox="0 0 100 96" preserveAspectRatio="none" class="absolute inset-0" aria-hidden="true">
|
<svg width="100%" height="100%" viewBox="0 0 100 96" preserveAspectRatio="none" class="absolute inset-0" aria-hidden="true">
|
||||||
|
|||||||
19
packages/site/src/components/icons/IconCheck.vue
Normal file
19
packages/site/src/components/icons/IconCheck.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{ size?: number }>(), { size: 15 });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -17,7 +17,7 @@ export default {
|
|||||||
featKicker: 'Everything in one view',
|
featKicker: 'Everything in one view',
|
||||||
featTitle: 'Built to supervise agents — not to be an IDE',
|
featTitle: 'Built to supervise agents — not to be an IDE',
|
||||||
feat1Title: 'Worktree-first',
|
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',
|
feat2Title: 'Many sessions, one view',
|
||||||
feat2Desc: 'Run parallel Claude Code agents side by side in a single live grid.',
|
feat2Desc: 'Run parallel Claude Code agents side by side in a single live grid.',
|
||||||
feat3Title: 'Fine-grained states',
|
feat3Title: 'Fine-grained states',
|
||||||
@@ -29,7 +29,7 @@ export default {
|
|||||||
feat6Title: 'Answer without a terminal',
|
feat6Title: 'Answer without a terminal',
|
||||||
feat6Desc: 'Respond to agent prompts and dialogs right from the dashboard.',
|
feat6Desc: 'Respond to agent prompts and dialogs right from the dashboard.',
|
||||||
feat7Title: 'Work groups',
|
feat7Title: 'Work groups',
|
||||||
feat7Desc: 'Group repos and launch the same feature across all of them at once.',
|
feat7Desc: 'Group repos and run one Claude session across all of them at once.',
|
||||||
feat8Title: 'Local-first & secure',
|
feat8Title: 'Local-first & secure',
|
||||||
feat8Desc: 'Binds to localhost, hashes tokens, remote access via Tailscale.',
|
feat8Desc: 'Binds to localhost, hashes tokens, remote access via Tailscale.',
|
||||||
scAKicker: 'Worktree-first dashboard',
|
scAKicker: 'Worktree-first dashboard',
|
||||||
@@ -44,10 +44,11 @@ export default {
|
|||||||
scCTitle: 'Unblock an agent from the kitchen',
|
scCTitle: 'Unblock an agent from the kitchen',
|
||||||
scCBody:
|
scCBody:
|
||||||
'Install the PWA and get a Web Push the second a session needs you. Tap the notification, read the prompt, answer the dialog — your agent keeps moving while you are away.',
|
'Install the PWA and get a Web Push the second a session needs you. Tap the notification, read the prompt, answer the dialog — your agent keeps moving while you are away.',
|
||||||
|
grpHub: '1 session',
|
||||||
grpKicker: 'Work groups · cross-repo',
|
grpKicker: 'Work groups · cross-repo',
|
||||||
grpTitle: 'Ship one feature across every repo at once',
|
grpTitle: 'Drive every repo from one Claude session',
|
||||||
grpBody:
|
grpBody:
|
||||||
'Group the repos that move together — a service, its client, its docs. Launch the same task to all of them and Arboretum spins up a worktree and an agent in each, then tracks them as one unit.',
|
'Group the repos that move together — a service, its client, its docs. Launch a single Claude session that spans all of them at once: one conversation, one shared context working across every repo — instead of juggling one agent per repo.',
|
||||||
howKicker: 'How it works',
|
howKicker: 'How it works',
|
||||||
howTitle: 'Zero install. Three steps.',
|
howTitle: 'Zero install. Three steps.',
|
||||||
step1Title: 'Launch the daemon',
|
step1Title: 'Launch the daemon',
|
||||||
@@ -55,7 +56,7 @@ export default {
|
|||||||
step2Title: 'Open the dashboard',
|
step2Title: 'Open the dashboard',
|
||||||
step2Desc: 'It serves a web dashboard on localhost. Install it as a PWA on any device.',
|
step2Desc: 'It serves a web dashboard on localhost. Install it as a PWA on any device.',
|
||||||
step3Title: 'Spawn & supervise',
|
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',
|
secKicker: 'Security',
|
||||||
secTitle: 'A web terminal you can actually trust',
|
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.',
|
secIntro: 'Built local-first. Nothing is exposed unless you choose to — and even then, on your terms.',
|
||||||
@@ -90,4 +91,6 @@ export default {
|
|||||||
dlgQ: 'Apply this change to login.ts?',
|
dlgQ: 'Apply this change to login.ts?',
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
copied: 'Copied',
|
copied: 'Copied',
|
||||||
|
copyCommand: 'Copy command',
|
||||||
|
copyUrl: 'Copy URL',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default {
|
|||||||
featKicker: 'Tout dans une seule vue',
|
featKicker: 'Tout dans une seule vue',
|
||||||
featTitle: 'Conçu pour superviser les agents — pas pour être un IDE',
|
featTitle: 'Conçu pour superviser les agents — pas pour être un IDE',
|
||||||
feat1Title: "Worktree d'abord",
|
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',
|
feat2Title: 'Plusieurs sessions, une vue',
|
||||||
feat2Desc: 'Lancez des agents Claude Code en parallèle, côte à côte dans une seule grille en direct.',
|
feat2Desc: 'Lancez des agents Claude Code en parallèle, côte à côte dans une seule grille en direct.',
|
||||||
feat3Title: 'États fins',
|
feat3Title: 'États fins',
|
||||||
@@ -29,7 +29,7 @@ export default {
|
|||||||
feat6Title: 'Répondre sans terminal',
|
feat6Title: 'Répondre sans terminal',
|
||||||
feat6Desc: 'Répondez aux questions et dialogues des agents directement depuis le dashboard.',
|
feat6Desc: 'Répondez aux questions et dialogues des agents directement depuis le dashboard.',
|
||||||
feat7Title: 'Work groups',
|
feat7Title: 'Work groups',
|
||||||
feat7Desc: 'Regroupez des repos et lancez la même feature dans tous à la fois.',
|
feat7Desc: 'Regroupez des repos et pilotez-les via une seule session Claude, tous à la fois.',
|
||||||
feat8Title: 'Local-first & sécurisé',
|
feat8Title: 'Local-first & sécurisé',
|
||||||
feat8Desc: 'Se lie à localhost, hashe les tokens, accès distant via Tailscale.',
|
feat8Desc: 'Se lie à localhost, hashe les tokens, accès distant via Tailscale.',
|
||||||
scAKicker: 'Dashboard worktree-first',
|
scAKicker: 'Dashboard worktree-first',
|
||||||
@@ -44,10 +44,11 @@ export default {
|
|||||||
scCTitle: 'Débloquez un agent depuis la cuisine',
|
scCTitle: 'Débloquez un agent depuis la cuisine',
|
||||||
scCBody:
|
scCBody:
|
||||||
"Installez la PWA et recevez un Web Push dès qu'une session a besoin de vous. Touchez la notification, lisez la question, répondez au dialogue — votre agent continue pendant que vous êtes loin du bureau.",
|
"Installez la PWA et recevez un Web Push dès qu'une session a besoin de vous. Touchez la notification, lisez la question, répondez au dialogue — votre agent continue pendant que vous êtes loin du bureau.",
|
||||||
|
grpHub: '1 session',
|
||||||
grpKicker: 'Work groups · cross-repo',
|
grpKicker: 'Work groups · cross-repo',
|
||||||
grpTitle: "Livrez une feature dans tous les repos d'un coup",
|
grpTitle: 'Pilotez tous vos repos depuis une seule session Claude',
|
||||||
grpBody:
|
grpBody:
|
||||||
'Regroupez les repos qui avancent ensemble — un service, son client, sa doc. Lancez la même tâche à tous ; Arboretum crée un worktree et un agent dans chacun, puis les suit comme un seul ensemble.',
|
'Regroupez les repos qui avancent ensemble — un service, son client, sa doc. Lancez une seule session Claude qui les couvre tous à la fois : une conversation, un contexte partagé travaillant à travers chaque repo — au lieu de jongler avec un agent par repo.',
|
||||||
howKicker: 'Comment ça marche',
|
howKicker: 'Comment ça marche',
|
||||||
howTitle: 'Zéro install. Trois étapes.',
|
howTitle: 'Zéro install. Trois étapes.',
|
||||||
step1Title: 'Lancez le daemon',
|
step1Title: 'Lancez le daemon',
|
||||||
@@ -55,7 +56,7 @@ export default {
|
|||||||
step2Title: 'Ouvrez le dashboard',
|
step2Title: 'Ouvrez le dashboard',
|
||||||
step2Desc: "Il sert un dashboard web sur localhost. Installez-le en PWA sur n'importe quel appareil.",
|
step2Desc: "Il sert un dashboard web sur localhost. Installez-le en PWA sur n'importe quel appareil.",
|
||||||
step3Title: 'Lancez & supervisez',
|
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é',
|
secKicker: 'Sécurité',
|
||||||
secTitle: 'Un terminal web en qui vous pouvez avoir confiance',
|
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.",
|
secIntro: "Conçu local-first. Rien n'est exposé sauf si vous le décidez — et toujours selon vos règles.",
|
||||||
@@ -90,4 +91,6 @@ export default {
|
|||||||
dlgQ: 'Appliquer ce changement à login.ts ?',
|
dlgQ: 'Appliquer ce changement à login.ts ?',
|
||||||
copy: 'Copier',
|
copy: 'Copier',
|
||||||
copied: 'Copié',
|
copied: 'Copié',
|
||||||
|
copyCommand: 'Copier la commande',
|
||||||
|
copyUrl: "Copier l'URL",
|
||||||
};
|
};
|
||||||
|
|||||||
6
packages/site/src/lib/links.ts
Normal file
6
packages/site/src/lib/links.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// URLs externes du site vitrine — source unique de vérité.
|
||||||
|
// Le dépôt Gitea est « arboretum » (et non « git-arboretum », qui est le nom du
|
||||||
|
// paquet npm `@johanleroy/git-arboretum`). Centralisé ici pour ne plus dériver.
|
||||||
|
export const REPO = 'https://git.lidge.fr/johanleroy/arboretum';
|
||||||
|
export const LICENSE = `${REPO}/src/branch/main/LICENSE`;
|
||||||
|
export const COFFEE = 'https://buymeacoffee.com/johanleroy';
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<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">
|
|
||||||
<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 />
|
|
||||||
</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>
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('crossRepo.startLabel') }}
|
|
||||||
<select v-model="startSession" class="input">
|
|
||||||
<option :value="null">{{ t('crossRepo.startNone') }}</option>
|
|
||||||
<option value="claude">claude</option>
|
|
||||||
<option value="bash">bash</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('crossRepo.reposLabel') }}
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" :disabled="running" />
|
|
||||||
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
|
||||||
<span v-if="results[repo.id]" class="text-[11px]" :class="statusClass(repo.id)">
|
|
||||||
{{ statusText(repo.id) }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button type="submit" class="btn-primary" :disabled="running || branch.trim() === '' || selectedRepoIds.length === 0">
|
|
||||||
{{ running ? t('crossRepo.creating') : t('crossRepo.create', { n: selectedRepoIds.length }) }}
|
|
||||||
</button>
|
|
||||||
<button v-if="hasFailures && !running" type="button" class="btn text-xs" @click="retryFailed">
|
|
||||||
{{ t('crossRepo.retryFailed') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import type { RepoSummary } from '@arboretum/shared';
|
|
||||||
import { useGroupsStore, type CrossRepoResult } from '../stores/groups';
|
|
||||||
|
|
||||||
const props = defineProps<{ repos: RepoSummary[] }>();
|
|
||||||
const emit = defineEmits<{ close: [] }>();
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const groups = useGroupsStore();
|
|
||||||
|
|
||||||
const branch = ref('');
|
|
||||||
const newBranch = ref(true);
|
|
||||||
const baseRef = ref('');
|
|
||||||
const startSession = ref<'claude' | 'bash' | null>(null);
|
|
||||||
const selectedRepoIds = ref<string[]>(props.repos.map((r) => r.id));
|
|
||||||
const results = ref<Record<string, CrossRepoResult>>({});
|
|
||||||
const running = ref(false);
|
|
||||||
|
|
||||||
const hasFailures = computed(() => Object.values(results.value).some((r) => r.status === 'error'));
|
|
||||||
|
|
||||||
function statusClass(repoId: string): string {
|
|
||||||
return results.value[repoId]?.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
|
||||||
}
|
|
||||||
function statusText(repoId: string): string {
|
|
||||||
const r = results.value[repoId];
|
|
||||||
if (!r) return '';
|
|
||||||
return r.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${r.message ?? ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(repoIds: string[]): Promise<void> {
|
|
||||||
if (repoIds.length === 0) return;
|
|
||||||
running.value = true;
|
|
||||||
for (const id of repoIds) delete results.value[id];
|
|
||||||
try {
|
|
||||||
await groups.createCrossRepoFeature(
|
|
||||||
repoIds,
|
|
||||||
{
|
|
||||||
branch: branch.value.trim(),
|
|
||||||
newBranch: newBranch.value,
|
|
||||||
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
|
|
||||||
startSession: startSession.value,
|
|
||||||
},
|
|
||||||
(result) => {
|
|
||||||
results.value = { ...results.value, [result.repoId]: result };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
running.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSubmit(): void {
|
|
||||||
void run([...selectedRepoIds.value]);
|
|
||||||
}
|
|
||||||
function retryFailed(): void {
|
|
||||||
void run(Object.values(results.value).filter((r) => r.status === 'error').map((r) => r.repoId));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
148
packages/web/src/components/GroupSessionModal.vue
Normal file
148
packages/web/src/components/GroupSessionModal.vue
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<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 { useI18n } from 'vue-i18n';
|
||||||
|
import type { RepoSummary } from '@arboretum/shared';
|
||||||
|
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
|
||||||
|
|
||||||
|
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
running.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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,7 +18,17 @@
|
|||||||
</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">
|
||||||
|
<!-- 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">
|
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
||||||
{{ t('worktrees.branch') }}
|
{{ t('worktrees.branch') }}
|
||||||
<input v-model="branch" class="input font-mono" placeholder="feature/…" required />
|
<input v-model="branch" class="input font-mono" placeholder="feature/…" required />
|
||||||
@@ -32,9 +42,34 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</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() === ''">
|
</template>
|
||||||
{{ t('worktrees.create') }}
|
|
||||||
|
<!-- 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>
|
</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 {
|
||||||
|
if (mode.value === 'worktree') {
|
||||||
await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value });
|
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 {
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
|
<header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
|
||||||
<SessionStateBadge :session="session" />
|
<SessionStateBadge :session="session" />
|
||||||
<span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span>
|
<span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span>
|
||||||
|
<span
|
||||||
|
v-if="spannedCount > 1"
|
||||||
|
class="shrink-0 rounded-full border border-sky-800/60 bg-sky-950/40 px-1.5 py-0.5 text-[10px] text-sky-300"
|
||||||
|
:title="spannedDirs.join('\n')"
|
||||||
|
>
|
||||||
|
{{ t('groups.spanBadge', spannedCount) }}
|
||||||
|
</span>
|
||||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
||||||
</header>
|
</header>
|
||||||
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
||||||
@@ -15,6 +22,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import SessionStateBadge from './SessionStateBadge.vue';
|
import SessionStateBadge from './SessionStateBadge.vue';
|
||||||
@@ -23,6 +31,11 @@ import TerminalView from './TerminalView.vue';
|
|||||||
|
|
||||||
const props = defineProps<{ session: SessionSummary }>();
|
const props = defineProps<{ session: SessionSummary }>();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// session de groupe (P6) : couvre plusieurs répertoires via --add-dir → badge « groupe · N dépôts ».
|
||||||
|
const spannedDirs = computed(() => [props.session.cwd, ...(props.session.addedDirs ?? [])]);
|
||||||
|
const spannedCount = computed(() => spannedDirs.value.length);
|
||||||
|
|
||||||
// libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
|
// libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
|
|||||||
@@ -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)}` : '—'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -96,25 +96,29 @@ export default {
|
|||||||
viewGrid: 'Terminals',
|
viewGrid: 'Terminals',
|
||||||
gridEmpty: 'No active session in this group.',
|
gridEmpty: 'No active session in this group.',
|
||||||
gridCapped: 'Showing {shown} of {total} sessions — close some terminals to see the rest.',
|
gridCapped: 'Showing {shown} of {total} sessions — close some terminals to see the rest.',
|
||||||
newFeature: 'New cross-repo feature',
|
newFeature: 'New group session',
|
||||||
|
spanBadge: 'group · {n} repo | group · {n} repos',
|
||||||
},
|
},
|
||||||
crossRepo: {
|
crossRepo: {
|
||||||
title: 'New cross-repo feature',
|
title: 'New group session',
|
||||||
intro: 'Create the same worktree across the selected repos, in one action.',
|
intro: 'Launch one Claude session that spans every repo in this group — a single conversation across all of them.',
|
||||||
|
modeLabel: 'Directories',
|
||||||
|
modeFeature: 'New branch in each repo',
|
||||||
|
modeMain: 'Main checkouts',
|
||||||
branchLabel: 'Branch name',
|
branchLabel: 'Branch name',
|
||||||
branchPlaceholder: 'feature/…',
|
branchPlaceholder: 'feature/…',
|
||||||
newBranch: 'create branch',
|
newBranch: 'create branch',
|
||||||
baseRefLabel: 'Base ref (optional)',
|
baseRefLabel: 'Base ref (optional)',
|
||||||
startLabel: 'Start session',
|
commandLabel: 'Session',
|
||||||
startNone: 'no session',
|
reposLabel: 'Repos in this session',
|
||||||
reposLabel: 'Apply to',
|
launch: 'Launch session',
|
||||||
create: 'Create across {n} repos',
|
launching: 'Launching…',
|
||||||
creating: 'Creating…',
|
|
||||||
retryFailed: 'Retry failed',
|
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
pending: 'pending…',
|
ok: 'worktree created',
|
||||||
ok: 'created',
|
|
||||||
error: 'failed',
|
error: 'failed',
|
||||||
|
skippedLabel: 'skipped',
|
||||||
|
done: 'Group session started.',
|
||||||
|
sessionFailed: 'Could not start the session.',
|
||||||
},
|
},
|
||||||
repos: {
|
repos: {
|
||||||
add: 'Add repo',
|
add: 'Add repo',
|
||||||
@@ -137,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',
|
||||||
|
|||||||
@@ -98,25 +98,29 @@ const fr: typeof en = {
|
|||||||
viewGrid: 'Terminaux',
|
viewGrid: 'Terminaux',
|
||||||
gridEmpty: 'Aucune session active dans ce groupe.',
|
gridEmpty: 'Aucune session active dans ce groupe.',
|
||||||
gridCapped: '{shown} sessions affichées sur {total} — fermez des terminaux pour voir le reste.',
|
gridCapped: '{shown} sessions affichées sur {total} — fermez des terminaux pour voir le reste.',
|
||||||
newFeature: 'Nouvelle feature cross-repo',
|
newFeature: 'Nouvelle session de groupe',
|
||||||
|
spanBadge: 'groupe · {n} dépôt | groupe · {n} dépôts',
|
||||||
},
|
},
|
||||||
crossRepo: {
|
crossRepo: {
|
||||||
title: 'Nouvelle feature cross-repo',
|
title: 'Nouvelle session de groupe',
|
||||||
intro: 'Crée le même worktree dans les dépôts sélectionnés, en une seule action.',
|
intro: 'Lance UNE session Claude couvrant tous les dépôts du groupe — une seule conversation reliant l’ensemble.',
|
||||||
|
modeLabel: 'Répertoires',
|
||||||
|
modeFeature: 'Nouvelle branche dans chaque dépôt',
|
||||||
|
modeMain: 'Checkouts principaux',
|
||||||
branchLabel: 'Nom de branche',
|
branchLabel: 'Nom de branche',
|
||||||
branchPlaceholder: 'feature/…',
|
branchPlaceholder: 'feature/…',
|
||||||
newBranch: 'créer la branche',
|
newBranch: 'créer la branche',
|
||||||
baseRefLabel: 'Réf. de base (optionnel)',
|
baseRefLabel: 'Réf. de base (optionnel)',
|
||||||
startLabel: 'Démarrer une session',
|
commandLabel: 'Session',
|
||||||
startNone: 'aucune session',
|
reposLabel: 'Dépôts de la session',
|
||||||
reposLabel: 'Appliquer à',
|
launch: 'Lancer la session',
|
||||||
create: 'Créer dans {n} dépôts',
|
launching: 'Lancement…',
|
||||||
creating: 'Création…',
|
|
||||||
retryFailed: 'Réessayer les échecs',
|
|
||||||
close: 'Fermer',
|
close: 'Fermer',
|
||||||
pending: 'en cours…',
|
ok: 'worktree créé',
|
||||||
ok: 'créé',
|
|
||||||
error: 'échec',
|
error: 'échec',
|
||||||
|
skippedLabel: 'ignoré',
|
||||||
|
done: 'Session de groupe démarrée.',
|
||||||
|
sessionFailed: 'Impossible de démarrer la session.',
|
||||||
},
|
},
|
||||||
repos: {
|
repos: {
|
||||||
add: 'Ajouter un repo',
|
add: 'Ajouter un repo',
|
||||||
@@ -139,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',
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { defineStore } from 'pinia';
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import type {
|
import type {
|
||||||
CreateGroupRequest,
|
CreateGroupRequest,
|
||||||
CreateWorktreeRequest,
|
CreateGroupSessionRequest,
|
||||||
GroupResponse,
|
GroupResponse,
|
||||||
|
GroupSessionResponse,
|
||||||
GroupsListResponse,
|
GroupsListResponse,
|
||||||
GroupSummary,
|
GroupSummary,
|
||||||
RepoSummary,
|
RepoSummary,
|
||||||
@@ -14,17 +15,32 @@ import type {
|
|||||||
import { api, ApiError } from '../lib/api';
|
import { api, ApiError } from '../lib/api';
|
||||||
import { wsClient, type GroupEvent } from '../lib/ws-client';
|
import { wsClient, type GroupEvent } from '../lib/ws-client';
|
||||||
import { useWorktreesStore } from './worktrees';
|
import { useWorktreesStore } from './worktrees';
|
||||||
|
import { useSessionsStore } from './sessions';
|
||||||
|
|
||||||
/** Requête « feature cross-repo » : un worktree (et éventuellement une session) par repo. */
|
/** Résultat par repo de la création de worktree (mode feature, tolérance aux échecs partiels). */
|
||||||
export type CrossRepoRequest = Omit<CreateWorktreeRequest, 'path'>;
|
|
||||||
|
|
||||||
/** Résultat par repo d'une action cross-repo (tolérance aux échecs partiels). */
|
|
||||||
export interface CrossRepoResult {
|
export interface CrossRepoResult {
|
||||||
repoId: string;
|
repoId: string;
|
||||||
status: 'ok' | 'error';
|
status: 'ok' | 'error';
|
||||||
message?: string;
|
message?: string;
|
||||||
worktreePath?: string;
|
worktreePath?: string;
|
||||||
sessionId?: string;
|
}
|
||||||
|
|
||||||
|
/** Options de lancement d'une session de groupe (P6). */
|
||||||
|
export interface GroupFeatureOptions {
|
||||||
|
command: 'claude' | 'bash';
|
||||||
|
/** présent → crée le worktree de cette branche dans chaque repo puis relie ; absent → checkouts principaux. */
|
||||||
|
branch?: string;
|
||||||
|
newBranch?: boolean;
|
||||||
|
baseRef?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bilan d'un lancement de session de groupe. */
|
||||||
|
export interface GroupFeatureOutcome {
|
||||||
|
/** résultats de création de worktree par repo ([] en mode checkouts principaux). */
|
||||||
|
worktreeResults: CrossRepoResult[];
|
||||||
|
session: SessionSummary | null;
|
||||||
|
dirs: string[];
|
||||||
|
skipped: Array<{ repoId: string; reason: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGroupsStore = defineStore('groups', () => {
|
export const useGroupsStore = defineStore('groups', () => {
|
||||||
@@ -68,7 +84,12 @@ export const useGroupsStore = defineStore('groups', () => {
|
|||||||
return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId));
|
return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId));
|
||||||
}
|
}
|
||||||
function sessionsInGroup(groupId: string): SessionSummary[] {
|
function sessionsInGroup(groupId: string): SessionSummary[] {
|
||||||
return worktreesInGroup(groupId).flatMap((w) => w.sessions);
|
// Union { sessions corrélées par cwd aux worktrees du groupe } ∪ { sessions de groupe (groupId) },
|
||||||
|
// dé-dupliquée par id : une session de groupe multi-repo ne doit apparaître qu'une fois.
|
||||||
|
const byId = new Map<string, SessionSummary>();
|
||||||
|
for (const w of worktreesInGroup(groupId)) for (const s of w.sessions) byId.set(s.id, s);
|
||||||
|
for (const s of useSessionsStore().sessions) if (s.groupId === groupId) byId.set(s.id, s);
|
||||||
|
return [...byId.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchGroups(): Promise<void> {
|
async function fetchGroups(): Promise<void> {
|
||||||
@@ -109,27 +130,38 @@ export const useGroupsStore = defineStore('groups', () => {
|
|||||||
return res.group;
|
return res.group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lance UNE session Claude couvrant tous les repos du groupe (--add-dir côté serveur). */
|
||||||
|
async function createGroupSession(groupId: string, req: CreateGroupSessionRequest): Promise<GroupSessionResponse> {
|
||||||
|
// La session est upsertée dans le store sessions par le broadcast WS `session_update`.
|
||||||
|
return api.post<GroupSessionResponse>(`/api/v1/groups/${groupId}/session`, req);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crée le même worktree (et éventuellement une session) dans chaque repo, en une action.
|
* Lance une session de groupe reliant tous les repos (P6). En mode « feature » (branch fournie),
|
||||||
* Orchestration côté client : les worktrees sont indépendants entre repos, donc on tolère
|
* crée d'abord le même worktree de branche dans chaque repo — orchestration côté client tolérante
|
||||||
* les échecs partiels (un repo en échec n'empêche pas les autres). Feedback par repo.
|
* aux échecs partiels, feedback par repo — PUIS une seule session couvrant ces worktrees. En mode
|
||||||
|
* « checkouts principaux » (sans branch), la session couvre directement le worktree principal de chaque repo.
|
||||||
*/
|
*/
|
||||||
async function createCrossRepoFeature(
|
async function createGroupFeature(
|
||||||
|
groupId: string,
|
||||||
repoIds: string[],
|
repoIds: string[],
|
||||||
req: CrossRepoRequest,
|
opts: GroupFeatureOptions,
|
||||||
onProgress?: (result: CrossRepoResult) => void,
|
onProgress?: (result: CrossRepoResult) => void,
|
||||||
): Promise<CrossRepoResult[]> {
|
): Promise<GroupFeatureOutcome> {
|
||||||
const wt = useWorktreesStore();
|
const wt = useWorktreesStore();
|
||||||
return Promise.all(
|
let worktreeResults: CrossRepoResult[] = [];
|
||||||
|
if (opts.branch) {
|
||||||
|
const branch = opts.branch;
|
||||||
|
worktreeResults = await Promise.all(
|
||||||
repoIds.map(async (repoId): Promise<CrossRepoResult> => {
|
repoIds.map(async (repoId): Promise<CrossRepoResult> => {
|
||||||
try {
|
try {
|
||||||
const res = await wt.createWorktree(repoId, req);
|
const res = await wt.createWorktree(repoId, {
|
||||||
const result: CrossRepoResult = {
|
branch,
|
||||||
repoId,
|
newBranch: opts.newBranch ?? true,
|
||||||
status: 'ok',
|
...(opts.baseRef ? { baseRef: opts.baseRef } : {}),
|
||||||
worktreePath: res.worktree.path,
|
startSession: null,
|
||||||
...(res.session ? { sessionId: res.session.id } : {}),
|
});
|
||||||
};
|
const result: CrossRepoResult = { repoId, status: 'ok', worktreePath: res.worktree.path };
|
||||||
onProgress?.(result);
|
onProgress?.(result);
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -144,6 +176,12 @@ export const useGroupsStore = defineStore('groups', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const res = await createGroupSession(groupId, {
|
||||||
|
command: opts.command,
|
||||||
|
...(opts.branch ? { branch: opts.branch } : {}),
|
||||||
|
});
|
||||||
|
return { worktreeResults, session: res.session, dirs: res.dirs, skipped: res.skipped };
|
||||||
|
}
|
||||||
|
|
||||||
function startRealtime(): void {
|
function startRealtime(): void {
|
||||||
unsubscribe ??= wsClient.subscribeGroups(onEvent);
|
unsubscribe ??= wsClient.subscribeGroups(onEvent);
|
||||||
@@ -168,7 +206,8 @@ export const useGroupsStore = defineStore('groups', () => {
|
|||||||
deleteGroup,
|
deleteGroup,
|
||||||
addRepoToGroup,
|
addRepoToGroup,
|
||||||
removeRepoFromGroup,
|
removeRepoFromGroup,
|
||||||
createCrossRepoFeature,
|
createGroupSession,
|
||||||
|
createGroupFeature,
|
||||||
startRealtime,
|
startRealtime,
|
||||||
stopRealtime,
|
stopRealtime,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<CrossRepoFeatureModal v-if="showFeatureModal && group" :repos="groupRepos" @close="showFeatureModal = false" />
|
<GroupSessionModal v-if="showFeatureModal && group" :group-id="group.id" :repos="groupRepos" @close="showFeatureModal = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -91,7 +91,7 @@ import SegmentedControl from '../components/ui/SegmentedControl.vue';
|
|||||||
import ListToolbar from '../components/ListToolbar.vue';
|
import ListToolbar from '../components/ListToolbar.vue';
|
||||||
import RepoSection from '../components/RepoSection.vue';
|
import RepoSection from '../components/RepoSection.vue';
|
||||||
import TerminalGrid from '../components/TerminalGrid.vue';
|
import TerminalGrid from '../components/TerminalGrid.vue';
|
||||||
import CrossRepoFeatureModal from '../components/CrossRepoFeatureModal.vue';
|
import GroupSessionModal from '../components/GroupSessionModal.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|||||||
Reference in New Issue
Block a user