5 Commits

Author SHA1 Message Date
7d618c30d5 release: @johanleroy/git-arboretum 1.10.0
All checks were successful
CI / Build & test (Node 22) (push) Successful in 10m6s
CI / Build & test (Node 24) (push) Successful in 9m58s
Deploy site (production) / build-and-deploy (push) Successful in 15s
Release / Publish to Gitea npm registry (push) Successful in 9m51s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m50s
2026-06-23 18:52:39 +02:00
529c136199 docs(site): carte feature « créer un projet » 2026-06-23 18:52:39 +02:00
7fc1f6f747 fix(ci): restreindre la release daemon aux tags v[0-9]*
Le trigger 'v*' capturait aussi les tags 'vscode-v*' de l'extension : le workflow strippait alors
'v' (→ scode-v0.1.0) et échouait contre la version du daemon. 'v[0-9]*' n'écoute que les tags vX.Y.Z.
2026-06-23 18:52:39 +02:00
8b9060e0c0 feat: création de projet (nouveau dossier + session) & en-tête de session enrichi
- POST /api/v1/projects : crée <root>/<name> (mkdir non récursif, 409 si existant),
  git init optionnel, puis lance une session dedans. Anti-traversal sur le nom (core/project.ts).
- Web : NewProjectModal (racine préremplie scanRoots[0], case git init) + bouton dans SessionsListView.
- SessionContextBar : badge du groupe (cliquable) + repos couverts avec leur branche dans l'en-tête
  de session ; session simple = repo+branche déduits du cwd.
- 14 tests (unitaires + route).
2026-06-23 18:52:22 +02:00
06a400acc7 ci(vscode): réutiliser NPM_TOKEN pour l'asset de release du VSIX
Some checks failed
CI / Build & test (Node 22) (push) Successful in 10m1s
CI / Build & test (Node 24) (push) Successful in 9m53s
Release / Publish to Gitea npm registry (push) Failing after 5m5s
VSCode Release / Package VSIX (push) Successful in 9m39s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m54s
Le token Gitea NPM_TOKEN (déjà utilisé pour publier le daemon) a les droits repository
suffisants pour l'API release → pas de secret RELEASE_TOKEN dédié à configurer.
2026-06-23 17:48:45 +02:00
21 changed files with 592 additions and 9 deletions

View File

@@ -4,7 +4,9 @@ name: Release
on: on:
push: push:
tags: ['v*'] # `v[0-9]*` (et non `v*`) : sinon ce workflow capture aussi les tags `vscode-v*` de l'extension
# — il stripperait alors `v` (→ `scode-v0.1.0`) et échouerait contre la version du daemon.
tags: ['v[0-9]*']
permissions: permissions:
contents: read contents: read

View File

@@ -46,15 +46,16 @@ jobs:
name: vsix name: vsix
path: packages/vscode/*.vsix path: packages/vscode/*.vsix
# Best-effort : attache le VSIX à la release Gitea du tag (crée la release si absente). # Best-effort : attache le VSIX à la release Gitea du tag (crée la release si absente).
# Nécessite un secret RELEASE_TOKEN (token Gitea avec write:repository) ; sans lui, l'étape est # Réutilise le secret NPM_TOKEN (même token Gitea que la publication du daemon — droits
# ignorée sans faire échouer le job (continue-on-error). # repository suffisants pour l'API release). Sans lui, l'étape est ignorée sans faire échouer
# le job (continue-on-error) ; le VSIX reste disponible en artefact.
- name: Attach VSIX to Gitea release - name: Attach VSIX to Gitea release
continue-on-error: true continue-on-error: true
env: env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
if [ -z "$RELEASE_TOKEN" ]; then if [ -z "$RELEASE_TOKEN" ]; then
echo "::notice::RELEASE_TOKEN absent — VSIX disponible en artefact uniquement." echo "::notice::NPM_TOKEN absent — VSIX disponible en artefact uniquement."
exit 0 exit 0
fi fi
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"

2
package-lock.json generated
View File

@@ -7864,7 +7864,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@johanleroy/git-arboretum", "name": "@johanleroy/git-arboretum",
"version": "1.9.0", "version": "1.10.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.0", "@fastify/cookie": "^11.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@johanleroy/git-arboretum", "name": "@johanleroy/git-arboretum",
"version": "1.9.0", "version": "1.10.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",

View File

@@ -17,6 +17,7 @@ import { PushService } from './core/push-service.js';
import { loadSecretBox } from './core/secret-box.js'; import { loadSecretBox } from './core/secret-box.js';
import { registerAuthRoutes } from './routes/auth.js'; import { registerAuthRoutes } from './routes/auth.js';
import { registerSessionRoutes } from './routes/sessions.js'; import { registerSessionRoutes } from './routes/sessions.js';
import { registerProjectRoutes } from './routes/projects.js';
import { registerRepoRoutes } from './routes/repos.js'; import { registerRepoRoutes } from './routes/repos.js';
import { registerGroupRoutes } from './routes/groups.js'; import { registerGroupRoutes } from './routes/groups.js';
import { registerWorktreeRoutes } from './routes/worktrees.js'; import { registerWorktreeRoutes } from './routes/worktrees.js';
@@ -170,6 +171,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, db); registerSessionRoutes(app, manager, discovery, db);
registerProjectRoutes(app, manager, db);
registerRepoRoutes(app, worktrees, db); registerRepoRoutes(app, worktrees, db);
registerGroupRoutes(app, groups, db, worktrees, manager); registerGroupRoutes(app, groups, db, worktrees, manager);
registerWorktreeRoutes(app, worktrees, db); registerWorktreeRoutes(app, worktrees, db);

View File

@@ -122,6 +122,11 @@ export function isSafeAbsolutePath(p: string): boolean {
return p.startsWith('/') && resolve(p) === p && !p.split(sep).includes('..'); return p.startsWith('/') && resolve(p) === p && !p.split(sep).includes('..');
} }
/** Initialise un dépôt git dans `dir` (déjà créé et validé par l'appelant). `git init` est idempotent. */
export async function gitInit(dir: string): Promise<void> {
await git(dir, ['init']);
}
/** true si `path` est la racine d'un dépôt git (main worktree) accessible. */ /** true si `path` est la racine d'un dépôt git (main worktree) accessible. */
export async function isRepo(path: string): Promise<boolean> { export async function isRepo(path: string): Promise<boolean> {
try { try {

View File

@@ -0,0 +1,33 @@
// Création d'un nouveau projet : un dossier `<root>/<name>` créé sous une racine existante, dans
// lequel on lance ensuite une session. Couche PURE (testable sans fs) — la création réelle du
// dossier, le `git init` et le spawn vivent dans `routes/projects.ts`.
import { join } from 'node:path';
import { isSafeAbsolutePath } from './git.js';
/**
* Valide un nom de projet : UN SEUL segment de dossier. Anti-traversal de base — refuse les noms
* vides, trop longs, `.`/`..`, et tout caractère de séparation (`/`, `\`) ou NUL. La résolution du
* chemin complet (et sa re-validation) est faite par {@link resolveProjectDir}.
*/
export function isSafeProjectName(name: string): boolean {
const n = name.trim();
if (n.length === 0 || n.length > 255) return false;
if (n === '.' || n === '..') return false;
return !/[/\\\0]/.test(n);
}
/**
* Résout le dossier de projet `<root>/<name>`. Suppose `root` déjà validé absolu et existant par
* l'appelant ; re-valide le chemin final via {@link isSafeAbsolutePath} (défense en profondeur).
* Lève si `name` est invalide ou si le chemin résultant échappe l'arborescence.
*/
export function resolveProjectDir(root: string, name: string): string {
if (!isSafeProjectName(name)) {
throw Object.assign(new Error(`Invalid project name: ${name}`), { statusCode: 400 });
}
const dir = join(root, name.trim());
if (!isSafeAbsolutePath(dir)) {
throw Object.assign(new Error(`Unsafe project path: ${dir}`), { statusCode: 400 });
}
return dir;
}

View File

@@ -0,0 +1,89 @@
import type { FastifyInstance } from 'fastify';
import { mkdir, stat } from 'node:fs/promises';
import type { CreateProjectRequest, CreateProjectResponse } from '@arboretum/shared';
import type { PtyManager } from '../core/pty-manager.js';
import { gitInit, isSafeAbsolutePath } from '../core/git.js';
import { resolveProjectDir } from '../core/project.js';
import { recordAudit } from '../core/audit-log.js';
import type { Db } from '../db/index.js';
/**
* Création d'un nouveau projet : crée le dossier `<root>/<name>` (la SEULE écriture fs autorisée
* côté serveur en dehors du dataDir) puis lance une session dedans. `git init` optionnel.
* Cohérent avec le modèle de sécurité : un client authentifié dispose déjà d'un terminal (RCE par
* conception). On valide tout de même strictement `name` (anti-traversal) et on exige une racine
* EXISTANTE (mkdir non récursif → pas de création d'arborescence arbitraire).
*
* POST /api/v1/projects { root, name, gitInit?, command? } → 201 { session, path, gitInitialized }
*/
export function registerProjectRoutes(app: FastifyInstance, manager: PtyManager, db: Db): void {
app.post('/api/v1/projects', async (req, reply) => {
const body = req.body as Partial<CreateProjectRequest> | null;
if (!body || typeof body.root !== 'string' || !isSafeAbsolutePath(body.root)) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'root (absolute path) is required' } });
}
if (typeof body.name !== 'string') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'name is required' } });
}
if (body.command !== undefined && body.command !== 'claude' && body.command !== 'bash') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
}
// La racine doit exister et être un répertoire (mkdir non récursif derrière).
try {
const st = await stat(body.root);
if (!st.isDirectory()) {
return reply.status(400).send({ error: { code: 'NOT_A_DIRECTORY', message: `Not a directory: ${body.root}` } });
}
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return reply.status(404).send({ error: { code: 'NOT_FOUND', message: `No such directory: ${body.root}` } });
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${body.root}` } });
return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } });
}
let dir: string;
try {
dir = resolveProjectDir(body.root, body.name);
} catch (err) {
return reply.status((err as { statusCode?: number }).statusCode ?? 400).send({ error: { code: 'BAD_REQUEST', message: (err as Error).message } });
}
// mkdir non récursif : EEXIST → 409 (on n'écrase jamais un dossier existant).
try {
await mkdir(dir);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'EEXIST') return reply.status(409).send({ error: { code: 'PROJECT_EXISTS', message: `Already exists: ${dir}` } });
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${dir}` } });
return reply.status(400).send({ error: { code: 'MKDIR_FAILED', message: (err as Error).message } });
}
const wantsGitInit = body.gitInit === true;
let gitInitialized = false;
if (wantsGitInit) {
try {
await gitInit(dir);
gitInitialized = true;
} catch {
// Le dossier est créé et la session démarrera quand même : un échec git n'annule pas le projet.
gitInitialized = false;
}
}
try {
const session = manager.spawn({ cwd: dir, ...(body.command ? { command: body.command } : {}) });
recordAudit(db, {
actor: req.authContext?.tokenId ?? 'unknown',
action: 'project.create',
resourceId: dir,
details: { gitInit: gitInitialized },
});
const res: CreateProjectResponse = { session, path: dir, gitInitialized };
return reply.status(201).send(res);
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
}
});
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { isSafeProjectName, resolveProjectDir } from '../src/core/project.js';
describe('isSafeProjectName', () => {
it('accepte un segment simple', () => {
expect(isSafeProjectName('mon-projet')).toBe(true);
expect(isSafeProjectName('Projet_42.v2')).toBe(true);
expect(isSafeProjectName(' espaces-trim ')).toBe(true); // trim interne
});
it('refuse vide / espaces seuls', () => {
expect(isSafeProjectName('')).toBe(false);
expect(isSafeProjectName(' ')).toBe(false);
});
it('refuse `.` et `..`', () => {
expect(isSafeProjectName('.')).toBe(false);
expect(isSafeProjectName('..')).toBe(false);
});
it('refuse les séparateurs et le NUL (anti-traversal)', () => {
expect(isSafeProjectName('a/b')).toBe(false);
expect(isSafeProjectName('../evil')).toBe(false);
expect(isSafeProjectName('a\\b')).toBe(false);
expect(isSafeProjectName('a\0b')).toBe(false);
});
it('refuse un nom trop long (> 255)', () => {
expect(isSafeProjectName('x'.repeat(256))).toBe(false);
expect(isSafeProjectName('x'.repeat(255))).toBe(true);
});
});
describe('resolveProjectDir', () => {
it('joint root + name (trim) en chemin absolu', () => {
expect(resolveProjectDir('/home/johan/dev', 'mon-projet')).toBe('/home/johan/dev/mon-projet');
expect(resolveProjectDir('/home/johan/dev', ' mon-projet ')).toBe('/home/johan/dev/mon-projet');
});
it('lève (400) sur un nom invalide', () => {
expect(() => resolveProjectDir('/home/johan/dev', '..')).toThrow();
expect(() => resolveProjectDir('/home/johan/dev', 'a/b')).toThrow();
expect(() => resolveProjectDir('/home/johan/dev', '')).toThrow();
});
});

View File

@@ -0,0 +1,116 @@
import { mkdtempSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { buildApp, type AppBundle } from '../src/app.js';
import { openDb, type Db } from '../src/db/index.js';
import type { Config } from '../src/config.js';
import type { CreateProjectResponse } from '@arboretum/shared';
// On NE mocke PAS node:child_process : on veut un vrai `git init` (testé via command:'bash', qui
// n'a pas besoin de résoudre le binaire claude). Seul le PTY est simulé (pas de vrai process en CI).
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
class FakePty {
pid = 424242;
write = vi.fn();
resize = vi.fn();
pause = vi.fn();
resume = vi.fn();
kill = vi.fn();
onData(): { dispose: () => void } {
return { dispose: () => {} };
}
onExit(): { dispose: () => void } {
return { dispose: () => {} };
}
}
return { default: { spawn: (): FakePty => new FakePty() } };
});
process.env.ARBORETUM_LOG = 'silent';
let dir: string;
let root: string;
let bundle: AppBundle;
let db: Db;
let token: string;
const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` });
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'arboretum-projects-'));
root = dir; // la racine existe déjà
const dbPath = join(dir, 'projects.db');
db = openDb(dbPath);
const config: Config = {
port: 7317,
bind: '127.0.0.1',
dbPath,
dataDir: dir,
allowedOrigins: [],
printToken: false,
claudeProjectsDir: join(dir, 'claude', 'projects'),
claudeSessionsDir: join(dir, 'claude', 'sessions'),
vapidContact: 'mailto:test@localhost',
};
bundle = buildApp(config, db, '0.0.0-test');
const t = bundle.auth.ensureBootstrapToken();
if (!t) throw new Error('bootstrap token attendu sur une base vierge');
token = t;
});
afterAll(async () => {
await bundle.app.close();
db.close();
rmSync(dir, { recursive: true, force: true });
});
describe('POST /api/v1/projects', () => {
it('crée le dossier et lance une session (sans git)', async () => {
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'plain', command: 'bash' } });
expect(res.statusCode).toBe(201);
const body = res.json() as CreateProjectResponse;
expect(body.path).toBe(join(root, 'plain'));
expect(body.gitInitialized).toBe(false);
expect(existsSync(join(root, 'plain'))).toBe(true);
expect(existsSync(join(root, 'plain', '.git'))).toBe(false);
expect(body.session.cwd).toBe(join(root, 'plain'));
});
it('gitInit:true crée un dépôt git', async () => {
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'withgit', gitInit: true, command: 'bash' } });
expect(res.statusCode).toBe(201);
const body = res.json() as CreateProjectResponse;
expect(body.gitInitialized).toBe(true);
expect(existsSync(join(root, 'withgit', '.git'))).toBe(true);
});
it('nom avec `..` ou séparateur → 400 (anti-traversal)', async () => {
for (const name of ['..', 'a/b', '../evil']) {
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name, command: 'bash' } });
expect(res.statusCode).toBe(400);
}
});
it('dossier déjà existant → 409 PROJECT_EXISTS', async () => {
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root, name: 'plain', command: 'bash' } });
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ error: { code: 'PROJECT_EXISTS' } });
});
it('racine inexistante → 404', async () => {
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root: join(root, 'nope-xyz'), name: 'x', command: 'bash' } });
expect(res.statusCode).toBe(404);
});
it('root relatif → 400', async () => {
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', headers: auth(), payload: { root: 'relatif/x', name: 'x', command: 'bash' } });
expect(res.statusCode).toBe(400);
});
it('sans authentification → 401', async () => {
const res = await bundle.app.inject({ method: 'POST', url: '/api/v1/projects', payload: { root, name: 'noauth', command: 'bash' } });
expect(res.statusCode).toBe(401);
});
});

View File

@@ -47,6 +47,29 @@ export interface CreateSessionRequest {
/** binaire à lancer — défaut "claude" ; "bash" sert aux tests d'acceptation sans quota */ /** binaire à lancer — défaut "claude" ; "bash" sert aux tests d'acceptation sans quota */
command?: 'claude' | 'bash'; command?: 'claude' | 'bash';
} }
/**
* POST /api/v1/projects — crée un NOUVEAU dossier de projet `<root>/<name>` (le serveur ne crée
* jamais de dossier ailleurs) puis y lance une session. Le `git init` est optionnel (case à cocher).
* Réponse : `CreateProjectResponse`.
*/
export interface CreateProjectRequest {
/** racine absolue où créer le projet (doit déjà exister). */
root: string;
/** nom du dossier de projet : un seul segment, sans séparateur ni `..`. */
name: string;
/** true → `git init` dans le nouveau dossier avant la session (défaut false). */
gitInit?: boolean;
/** binaire à lancer — défaut "claude" ; "bash" sert aux tests sans quota. */
command?: 'claude' | 'bash';
}
export interface CreateProjectResponse {
session: SessionSummary;
/** chemin absolu du dossier de projet créé. */
path: string;
/** true si `git init` a réellement été exécuté. */
gitInitialized: boolean;
}
export interface SessionsListResponse { export interface SessionsListResponse {
sessions: SessionSummary[]; sessions: SessionSummary[];
} }

View File

@@ -78,6 +78,13 @@ const { t } = useI18n();
</template> </template>
{{ t('feat9Desc') }} {{ t('feat9Desc') }}
</FeatureCard> </FeatureCard>
<FeatureCard :title="t('feat10Title')">
<template #icon>
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 10v6" /><path d="M9 13h6" /><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" /></svg>
</template>
{{ t('feat10Desc') }}
</FeatureCard>
</div> </div>
</section> </section>
</template> </template>

View File

@@ -34,6 +34,8 @@ export default {
feat8Desc: 'Binds to localhost, hashes tokens, remote access via Tailscale.', feat8Desc: 'Binds to localhost, hashes tokens, remote access via Tailscale.',
feat9Title: 'Native VS Code extension', feat9Title: 'Native VS Code extension',
feat9Desc: 'Drive worktrees and attach sessions in native terminals, right inside your editor.', feat9Desc: 'Drive worktrees and attach sessions in native terminals, right inside your editor.',
feat10Title: 'New project in one click',
feat10Desc: 'Create a project folder anywhere (optional git init) and start a Claude session in it, from any device.',
scAKicker: 'Worktree-first dashboard', scAKicker: 'Worktree-first dashboard',
scATitle: 'See what needs you — before anything stalls', scATitle: 'See what needs you — before anything stalls',
scABody: scABody:

View File

@@ -34,6 +34,8 @@ export default {
feat8Desc: 'Se lie à localhost, hashe les tokens, accès distant via Tailscale.', feat8Desc: 'Se lie à localhost, hashe les tokens, accès distant via Tailscale.',
feat9Title: 'Extension VS Code native', feat9Title: 'Extension VS Code native',
feat9Desc: 'Pilotez vos worktrees et attachez vos sessions dans des terminaux natifs, directement dans votre éditeur.', feat9Desc: 'Pilotez vos worktrees et attachez vos sessions dans des terminaux natifs, directement dans votre éditeur.',
feat10Title: 'Nouveau projet en un clic',
feat10Desc: "Créez un dossier de projet où vous voulez (git init optionnel) et lancez-y une session Claude, depuis n'importe quel appareil.",
scAKicker: 'Dashboard worktree-first', scAKicker: 'Dashboard worktree-first',
scATitle: 'Voyez ce qui vous attend — avant que ça ne bloque', scATitle: 'Voyez ce qui vous attend — avant que ça ne bloque',
scABody: scABody:

View File

@@ -0,0 +1,119 @@
<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('project.title') }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<p class="text-xs text-zinc-500">{{ t('project.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('project.nameLabel') }}
<input v-model="name" class="input font-mono" :placeholder="t('project.namePlaceholder')" required :disabled="creating" />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('project.rootLabel') }}
<input v-model="root" class="input font-mono" :placeholder="t('project.rootPlaceholder')" required :disabled="creating" />
</label>
<div>
<button type="button" class="btn text-xs" :disabled="creating" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
</div>
<DirectoryPicker v-if="showPicker" mode="dir" :initial-path="root" @select="onPickRoot" @close="showPicker = false" />
<label class="flex items-center gap-2 text-xs text-zinc-300">
<input v-model="gitInit" type="checkbox" :disabled="creating" /> {{ t('project.gitInit') }}
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('project.commandLabel') }}
<select v-model="command" class="input" :disabled="creating">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
<p class="font-mono text-[11px] text-zinc-500">{{ previewPath }}</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="creating || name.trim() === '' || root.trim() === ''">
{{ creating ? t('project.creating') : t('project.create') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useSessionsStore } from '../stores/sessions';
import { useSettingsStore } from '../stores/settings';
import { useToastsStore } from '../stores/toasts';
import DirectoryPicker from './DirectoryPicker.vue';
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const router = useRouter();
const sessions = useSessionsStore();
const settings = useSettingsStore();
const toasts = useToastsStore();
const name = ref('');
// Racine par défaut : la 1ère racine de scan (réglage Découverte) ; modifiable via le picker.
const root = ref('');
const gitInit = ref(false);
const command = ref<'claude' | 'bash'>('claude');
const showPicker = ref(false);
const creating = ref(false);
const errorMsg = ref<string | null>(null);
// Aperçu du chemin final (indicatif ; jointure naïve suffisante pour l'affichage).
const previewPath = computed(() => {
const r = root.value.trim().replace(/\/+$/, '');
const n = name.value.trim();
return r && n ? `${r}/${n}` : '';
});
onMounted(async () => {
if (!settings.loaded) {
try {
await settings.fetch();
} catch {
/* le préremplissage de la racine est facultatif */
}
}
root.value = settings.scanRoots[0] ?? '';
});
function onPickRoot(path: string): void {
root.value = path;
showPicker.value = false;
}
async function onSubmit(): Promise<void> {
if (creating.value || name.value.trim() === '' || root.value.trim() === '') return;
creating.value = true;
errorMsg.value = null;
try {
const res = await sessions.createProject({
root: root.value.trim(),
name: name.value.trim(),
gitInit: gitInit.value,
command: command.value,
});
toasts.success(t('toast.projectCreated'));
emit('close');
await router.push({ name: 'session', params: { id: res.session.id } });
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
creating.value = false;
}
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<!-- Barre de contexte sous l'en-tête : groupe + repos couverts (avec branche). Masquée s'il n'y a
rien d'utile à montrer (session simple dans un dossier non reconnu le cwd du header suffit). -->
<div v-if="shouldShow" class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-zinc-800 bg-zinc-900/40 px-3 py-1.5 text-xs">
<RouterLink
v-if="group"
:to="{ name: 'group', params: { id: group.id } }"
class="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-zinc-200 transition-colors hover:bg-zinc-800"
:title="t('terminal.group')"
>
<span class="h-2.5 w-2.5 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
<span class="font-medium">{{ group.label }}</span>
<span class="text-zinc-500"></span>
</RouterLink>
<span v-if="group" class="text-zinc-600">{{ t('terminal.covers') }}</span>
<span
v-for="e in entries"
:key="e.dir"
class="flex items-center gap-1.5 rounded bg-zinc-950/60 px-1.5 py-0.5"
:title="e.dir"
>
<span class="font-mono text-zinc-300">{{ e.label }}</span>
<span v-if="e.branch" class="badge bg-zinc-800 font-mono text-zinc-400">{{ e.branch }}</span>
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared';
import { useGroupsStore } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
const props = defineProps<{ session: SessionSummary }>();
const { t } = useI18n();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const group = computed(() => (props.session.groupId ? (groups.byId(props.session.groupId) ?? null) : null));
function basename(dir: string): string {
return dir.split('/').filter(Boolean).pop() ?? dir;
}
interface CoverEntry {
dir: string;
label: string;
branch: string | null;
resolved: boolean;
}
// Résout un répertoire couvert vers repo + branche : worktree exact d'abord (branche connue),
// sinon checkout principal d'un repo, sinon dernier segment du chemin (non résolu).
function resolve(dir: string): CoverEntry {
const wt = worktrees.worktrees.find((w) => w.path === dir);
if (wt) {
const repo = worktrees.repos.find((r) => r.id === wt.repoId);
return { dir, label: repo?.label ?? basename(dir), branch: wt.branch, resolved: true };
}
const repo = worktrees.repos.find((r) => r.path === dir);
if (repo) return { dir, label: repo.label, branch: null, resolved: true };
return { dir, label: basename(dir), branch: null, resolved: false };
}
const entries = computed<CoverEntry[]>(() => {
const dirs = [props.session.cwd, ...(props.session.addedDirs ?? [])];
const seen = new Set<string>();
const out: CoverEntry[] = [];
for (const d of dirs) {
if (seen.has(d)) continue;
seen.add(d);
out.push(resolve(d));
}
return out;
});
// Afficher si on est dans un groupe, ou si au moins un répertoire correspond à un repo connu.
const shouldShow = computed(() => group.value !== null || entries.value.some((e) => e.resolved));
</script>

View File

@@ -184,6 +184,19 @@ export default {
externalSessions: 'external', externalSessions: 'external',
showExternalSessions: 'Show external sessions ({n})', showExternalSessions: 'Show external sessions ({n})',
}, },
project: {
new: 'New project',
title: 'New project',
intro: 'Creates a new folder under the chosen root, then starts a session in it.',
nameLabel: 'Project name',
namePlaceholder: 'my-project',
rootLabel: 'Root folder',
rootPlaceholder: '/absolute/path/root',
gitInit: 'Initialize a git repository (git init)',
commandLabel: 'Command',
create: 'Create',
creating: 'Creating…',
},
terminal: { terminal: {
observer: 'observer (read-only)', observer: 'observer (read-only)',
sessionEnded: 'Session ended', sessionEnded: 'Session ended',
@@ -194,6 +207,8 @@ export default {
maximize: 'Maximize', maximize: 'Maximize',
restore: 'Restore', restore: 'Restore',
openFullscreen: 'Open fullscreen', openFullscreen: 'Open fullscreen',
group: 'Group',
covers: 'covers',
}, },
push: { push: {
enable: 'Enable notifications', enable: 'Enable notifications',
@@ -337,6 +352,7 @@ export default {
groupDeleted: 'Group deleted', groupDeleted: 'Group deleted',
groupSessionLaunched: 'Group session started', groupSessionLaunched: 'Group session started',
groupSessionPartial: 'Group session started ({n} repo(s) skipped)', groupSessionPartial: 'Group session started ({n} repo(s) skipped)',
projectCreated: 'Project created',
genericError: 'Something went wrong', genericError: 'Something went wrong',
dismiss: 'Dismiss', dismiss: 'Dismiss',
}, },

View File

@@ -186,6 +186,19 @@ const fr: typeof en = {
externalSessions: 'externes', externalSessions: 'externes',
showExternalSessions: 'Afficher les sessions externes ({n})', showExternalSessions: 'Afficher les sessions externes ({n})',
}, },
project: {
new: 'Nouveau projet',
title: 'Nouveau projet',
intro: 'Crée un nouveau dossier sous la racine choisie, puis y lance une session.',
nameLabel: 'Nom du projet',
namePlaceholder: 'mon-projet',
rootLabel: 'Dossier racine',
rootPlaceholder: '/chemin/absolu/racine',
gitInit: 'Initialiser un dépôt git (git init)',
commandLabel: 'Commande',
create: 'Créer',
creating: 'Création…',
},
terminal: { terminal: {
observer: 'observateur (lecture seule)', observer: 'observateur (lecture seule)',
sessionEnded: 'Session terminée', sessionEnded: 'Session terminée',
@@ -197,6 +210,8 @@ const fr: typeof en = {
maximize: 'Agrandir', maximize: 'Agrandir',
restore: 'Réduire', restore: 'Réduire',
openFullscreen: 'Ouvrir en plein écran', openFullscreen: 'Ouvrir en plein écran',
group: 'Groupe',
covers: 'couvre',
}, },
push: { push: {
enable: 'Activer les notifications', enable: 'Activer les notifications',
@@ -340,6 +355,7 @@ const fr: typeof en = {
groupDeleted: 'Groupe supprimé', groupDeleted: 'Groupe supprimé',
groupSessionLaunched: 'Session de groupe démarrée', groupSessionLaunched: 'Session de groupe démarrée',
groupSessionPartial: 'Session de groupe démarrée ({n} dépôt(s) ignoré(s))', groupSessionPartial: 'Session de groupe démarrée ({n} dépôt(s) ignoré(s))',
projectCreated: 'Projet créé',
genericError: 'Une erreur est survenue', genericError: 'Une erreur est survenue',
dismiss: 'Fermer', dismiss: 'Fermer',
}, },

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import type { CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared'; import type { CreateProjectRequest, CreateProjectResponse, CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { wsClient, type SessionEvent } from '../lib/ws-client'; import { wsClient, type SessionEvent } from '../lib/ws-client';
@@ -106,6 +106,13 @@ export const useSessionsStore = defineStore('sessions', () => {
return res.session; return res.session;
} }
// Crée un nouveau dossier de projet `<root>/<name>` côté serveur puis y lance une session.
async function createProject(req: CreateProjectRequest): Promise<CreateProjectResponse> {
const res = await api.post<CreateProjectResponse>('/api/v1/projects', req);
upsert(res.session);
return res;
}
async function killSession(id: string): Promise<void> { async function killSession(id: string): Promise<void> {
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`); await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`);
} }
@@ -134,6 +141,7 @@ export const useSessionsStore = defineStore('sessions', () => {
startRealtime, startRealtime,
stopRealtime, stopRealtime,
createSession, createSession,
createProject,
killSession, killSession,
resumeSession, resumeSession,
forkSession, forkSession,

View File

@@ -18,6 +18,7 @@
<component :is="isFullscreen ? Minimize : Maximize" class="h-4 w-4" /> <component :is="isFullscreen ? Minimize : Maximize" class="h-4 w-4" />
</button> </button>
</header> </header>
<SessionContextBar v-if="session" :session="session" />
<DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" /> <DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" />
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" /> <TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
<!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). --> <!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). -->
@@ -33,12 +34,17 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Maximize, Minimize } from '@lucide/vue'; import { Maximize, Minimize } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions'; import { useSessionsStore } from '../stores/sessions';
import { useGroupsStore } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
import TerminalView from '../components/TerminalView.vue'; import TerminalView from '../components/TerminalView.vue';
import DialogPrompt from '../components/DialogPrompt.vue'; import DialogPrompt from '../components/DialogPrompt.vue';
import SessionContextBar from '../components/SessionContextBar.vue';
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const store = useSessionsStore(); const store = useSessionsStore();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const sessionId = computed(() => String(route.params.id)); const sessionId = computed(() => String(route.params.id));
const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive')); const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive'));
@@ -61,6 +67,9 @@ function toggleFullscreen(): void {
onMounted(() => { onMounted(() => {
// navigation directe : garantit l'en-tête (cwd/commande) même si l'AppShell n'a pas encore chargé. // navigation directe : garantit l'en-tête (cwd/commande) même si l'AppShell n'a pas encore chargé.
if (store.sessions.length === 0) void store.fetchSessions(); if (store.sessions.length === 0) void store.fetchSessions();
// best-effort : la barre de contexte a besoin des groupes + worktrees pour résoudre repos/branches.
if (groups.groups.length === 0) void groups.fetchGroups();
if (worktrees.repos.length === 0) void worktrees.fetchAll();
document.addEventListener('fullscreenchange', syncFullscreen); document.addEventListener('fullscreenchange', syncFullscreen);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {

View File

@@ -1,5 +1,8 @@
<template> <template>
<PageHeader :title="t('nav.sessions')"> <PageHeader :title="t('nav.sessions')">
<BaseButton variant="primary" size="sm" :icon="FolderPlus" @click="showNewProject = true">
{{ t('project.new') }}
</BaseButton>
<BaseButton v-if="hasDiscovered" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingAll" @click="onHideDiscovered"> <BaseButton v-if="hasDiscovered" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingAll" @click="onHideDiscovered">
{{ t('sessions.hideDiscovered') }} {{ t('sessions.hideDiscovered') }}
</BaseButton> </BaseButton>
@@ -37,6 +40,7 @@
@select="onPickCwd" @select="onPickCwd"
@close="showPicker = false" @close="showPicker = false"
/> />
<NewProjectModal v-if="showNewProject" @close="showNewProject = false" />
<SkeletonRow v-if="store.loading && store.sessions.length === 0" /> <SkeletonRow v-if="store.loading && store.sessions.length === 0" />
<EmptyState v-else-if="store.sessions.length === 0" :icon="TerminalSquare" :title="t('sessions.empty')" /> <EmptyState v-else-if="store.sessions.length === 0" :icon="TerminalSquare" :title="t('sessions.empty')" />
@@ -102,7 +106,7 @@ import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared'; import type { SessionSummary } from '@arboretum/shared';
import { RefreshCw, FolderOpen, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search } from '@lucide/vue'; import { RefreshCw, FolderOpen, FolderPlus, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions'; import { useSessionsStore } from '../stores/sessions';
import { useWorktreesStore } from '../stores/worktrees'; import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts'; import { useToastsStore } from '../stores/toasts';
@@ -118,6 +122,7 @@ import Pagination from '../components/Pagination.vue';
import SessionStateBadge from '../components/SessionStateBadge.vue'; import SessionStateBadge from '../components/SessionStateBadge.vue';
import DialogPrompt from '../components/DialogPrompt.vue'; import DialogPrompt from '../components/DialogPrompt.vue';
import DirectoryPicker from '../components/DirectoryPicker.vue'; import DirectoryPicker from '../components/DirectoryPicker.vue';
import NewProjectModal from '../components/NewProjectModal.vue';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
@@ -136,6 +141,7 @@ const controls = useListControls<SessionSummary>({
const newCwd = ref(''); const newCwd = ref('');
const newCommand = ref<'claude' | 'bash'>('claude'); const newCommand = ref<'claude' | 'bash'>('claude');
const showPicker = ref(false); const showPicker = ref(false);
const showNewProject = ref(false);
const creating = ref(false); const creating = ref(false);
const killArmedId = ref<string | null>(null); const killArmedId = ref<string | null>(null);
const killing = ref(false); const killing = ref(false);