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).
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
33
packages/server/src/core/project.ts
Normal file
33
packages/server/src/core/project.ts
Normal 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;
|
||||||
|
}
|
||||||
89
packages/server/src/routes/projects.ts
Normal file
89
packages/server/src/routes/projects.ts
Normal 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 } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
45
packages/server/test/project.test.ts
Normal file
45
packages/server/test/project.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
116
packages/server/test/projects-routes.test.ts
Normal file
116
packages/server/test/projects-routes.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
119
packages/web/src/components/NewProjectModal.vue
Normal file
119
packages/web/src/components/NewProjectModal.vue
Normal 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>
|
||||||
82
packages/web/src/components/SessionContextBar.vue
Normal file
82
packages/web/src/components/SessionContextBar.vue
Normal 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>
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user