Compare commits
5 Commits
bf9c6ba8f3
...
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ebe3a0f37 | |||
| eb843be9c1 | |||
| f4fb6c3b52 | |||
| e84c6a7f6d | |||
| dacff2b98f |
@@ -28,8 +28,8 @@ Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanle
|
|||||||
|
|
||||||
- **Dashboard worktree-first, multi-repo** — chaque worktree de chaque repo enregistré, avec son état git (branche, ahead/behind, fichiers modifiés) *et* l'état de sa session Claude Code (busy / en attente d'entrée / idle / reprenable).
|
- **Dashboard worktree-first, multi-repo** — chaque worktree de chaque repo enregistré, avec son état git (branche, ahead/behind, fichiers modifiés) *et* l'état de sa session Claude Code (busy / en attente d'entrée / idle / reprenable).
|
||||||
- **Cycle de vie complet des worktrees** — créer (avec des hooks post-création par repo : `npm ci`, copie de `.env`…), adopter des worktrees créés à la main, supprimer avec garde-fous, élaguer les orphelins.
|
- **Cycle de vie complet des worktrees** — créer (avec des hooks post-création par repo : `npm ci`, copie de `.env`…), adopter des worktrees créés à la main, supprimer avec garde-fous, élaguer les orphelins.
|
||||||
- **Découverte & reprise de sessions** — les sessions lancées dans votre propre terminal apparaissent automatiquement ; reprenez les sessions mortes, observez ou forkez les vivantes. Ne corrompt jamais une session vivante.
|
- **Découverte & reprise de sessions** — les sessions lancées dans votre propre terminal apparaissent automatiquement ; reprenez les sessions mortes, observez ou forkez les vivantes. Ne corrompt jamais une session vivante. Masquez les anciennes qui encombrent la liste (un clic efface tout l'historique externe ; elles restent reprenables).
|
||||||
- **Terminal web** — terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur.
|
- **Terminal web** — terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur ; vraiment plein écran, avec l'invite ancrée en bas et tout l'historique défilable au-dessus.
|
||||||
- **Supervision depuis votre téléphone** — PWA installable avec notifications push quand une session vous attend ; répondez à une demande (ses options, ou refusez) sans ouvrir de terminal.
|
- **Supervision depuis votre téléphone** — PWA installable avec notifications push quand une session vous attend ; répondez à une demande (ses options, ou refusez) sans ouvrir de terminal.
|
||||||
- **Groupes de travail** — regroupez des repos liés (ex. une API, son frontend web et sa doc) dans un groupe nommé, puis lancez **une seule session Claude qui les couvre tous à la fois** (via le flag `--add-dir` du CLI) : une conversation unique avec un contexte partagé travaillant à travers chaque repo, plus une vue unifiée de tous leurs worktrees et une grille multi-terminaux côte à côte. Une session de groupe peut d'abord créer le même worktree de branche dans chaque repo, ou tourner directement sur les checkouts principaux.
|
- **Groupes de travail** — regroupez des repos liés (ex. une API, son frontend web et sa doc) dans un groupe nommé, puis lancez **une seule session Claude qui les couvre tous à la fois** (via le flag `--add-dir` du CLI) : une conversation unique avec un contexte partagé travaillant à travers chaque repo, plus une vue unifiée de tous leurs worktrees et une grille multi-terminaux côte à côte. Une session de groupe peut d'abord créer le même worktree de branche dans chaque repo, ou tourner directement sur les checkouts principaux.
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arbore
|
|||||||
|
|
||||||
- **Worktree-first, multi-repo dashboard** — every worktree of every registered repo, with its git state (branch, ahead/behind, dirty files) *and* the state of its Claude Code session (busy / waiting for input / idle / resumable).
|
- **Worktree-first, multi-repo dashboard** — every worktree of every registered repo, with its git state (branch, ahead/behind, dirty files) *and* the state of its Claude Code session (busy / waiting for input / idle / resumable).
|
||||||
- **Full worktree lifecycle** — create (with per-repo post-create hooks: `npm ci`, copy `.env`…), adopt worktrees created by hand, delete with guardrails, prune orphans.
|
- **Full worktree lifecycle** — create (with per-repo post-create hooks: `npm ci`, copy `.env`…), adopt worktrees created by hand, delete with guardrails, prune orphans.
|
||||||
- **Session discovery & resume** — sessions you launched in your own terminal show up automatically; resume dead ones, observe or fork live ones. Never corrupts a live session.
|
- **Session discovery & resume** — sessions you launched in your own terminal show up automatically; resume dead ones, observe or fork live ones. Never corrupts a live session. Hide the old ones that clutter the list (one click clears the whole external history; they stay resumable).
|
||||||
- **Web terminal** — full xterm.js terminal to every managed session, surviving browser disconnects.
|
- **Web terminal** — full xterm.js terminal to every managed session, surviving browser disconnects; truly fullscreen, with the prompt pinned to the bottom and full scrollback above.
|
||||||
- **Supervision from your phone** — installable PWA with push notifications when a session needs you; answer a prompt (its options, or deny) without opening a terminal.
|
- **Supervision from your phone** — installable PWA with push notifications when a session needs you; answer a prompt (its options, or deny) without opening a terminal.
|
||||||
- **Work groups** — bundle related repos (e.g. an API, its web frontend and its docs) into a named group, then launch **one Claude session that spans all of them at once** (via the CLI's `--add-dir`): a single conversation with one shared context working across every repo, plus a unified view of all their worktrees and a side-by-side multi-terminal grid. Group sessions can either create the same branch worktree in each repo first, or run straight on the main checkouts.
|
- **Work groups** — bundle related repos (e.g. an API, its web frontend and its docs) into a named group, then launch **one Claude session that spans all of them at once** (via the CLI's `--add-dir`): a single conversation with one shared context working across every repo, plus a unified view of all their worktrees and a side-by-side multi-terminal grid. Group sessions can either create the same branch worktree in each repo first, or run straight on the main checkouts.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.6.1",
|
"version": "1.8.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",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// group_removed sur le topic 'groups', ajout/retrait de repo, et purge CASCADE de la membership
|
// group_removed sur le topic 'groups', ajout/retrait de repo, et purge CASCADE de la membership
|
||||||
// quand le repo est supprimé (PRAGMA foreign_keys = ON).
|
// quand le repo est supprimé (PRAGMA foreign_keys = ON).
|
||||||
import { spawn, execFileSync } from 'node:child_process';
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -142,6 +142,34 @@ try {
|
|||||||
const pushedSession = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === gsession.id);
|
const pushedSession = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === gsession.id);
|
||||||
check('broadcast WS session_update (session de groupe)', !!pushedSession);
|
check('broadcast WS session_update (session de groupe)', !!pushedSession);
|
||||||
|
|
||||||
|
// ---- Worktree de groupe sur une NOUVELLE branche (scénario corrigé : mode auto, plus de -b raté) ----
|
||||||
|
const wtA = await j(`/api/v1/repos/${repoSummary.id}/worktrees`, 'POST', cookie, { branch: 'feature/cross', mode: 'auto', startSession: null });
|
||||||
|
const wtABody = await wtA.json();
|
||||||
|
check('POST /repos/:id/worktrees (branche neuve) → 201 + action=created', wtA.status === 201 && wtABody.action === 'created');
|
||||||
|
const wtB = await j(`/api/v1/repos/${repo2Summary.id}/worktrees`, 'POST', cookie, { branch: 'feature/cross', mode: 'auto', startSession: null });
|
||||||
|
check('POST /repos (2e repo) worktree branche neuve → 201', wtB.status === 201);
|
||||||
|
|
||||||
|
// GET /branches : la nouvelle branche apparaît (alimente le sélecteur de base côté UI).
|
||||||
|
const branchesA = await (await j(`/api/v1/repos/${repoSummary.id}/branches`, 'GET', cookie)).json();
|
||||||
|
check('GET /repos/:id/branches : feature/cross présente', Array.isArray(branchesA.local) && branchesA.local.includes('feature/cross'));
|
||||||
|
|
||||||
|
// session de groupe SUR cette branche → résout les worktrees créés (le cas qui échouait avant).
|
||||||
|
const gsBranch = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash', branch: 'feature/cross' });
|
||||||
|
const gsBranchBody = await gsBranch.json();
|
||||||
|
check('POST /groups/:id/session (branche) → 201 + 2 dirs', gsBranch.status === 201 && gsBranchBody.dirs?.length === 2);
|
||||||
|
await j(`/api/v1/sessions/${gsBranchBody.session.id}`, 'DELETE', cookie);
|
||||||
|
|
||||||
|
// commit : fichier neuf dans le worktree de repo1 puis commit via l'endpoint.
|
||||||
|
writeFileSync(join(wtABody.worktree.path, 'cross.txt'), 'wip\n');
|
||||||
|
const commitRes = await j(`/api/v1/repos/${repoSummary.id}/worktrees/commit`, 'POST', cookie, { path: wtABody.worktree.path, message: 'wip cross' });
|
||||||
|
check('POST /worktrees/commit → 200 + dirty=0', commitRes.status === 200 && (await commitRes.json()).worktree?.git?.dirtyCount === 0);
|
||||||
|
|
||||||
|
// promotion « en principal » : feature/cross devient le checkout principal de repo1, worktree supprimé.
|
||||||
|
const promRes = await j(`/api/v1/repos/${repoSummary.id}/worktrees/promote`, 'POST', cookie, { path: wtABody.worktree.path });
|
||||||
|
check('POST /worktrees/promote → 200', promRes.status === 200);
|
||||||
|
const wtsAfter = await (await j(`/api/v1/repos/${repoSummary.id}/worktrees`, 'GET', cookie)).json();
|
||||||
|
check('promotion : checkout principal désormais sur feature/cross', wtsAfter.worktrees?.find((w) => w.isMain)?.branch === 'feature/cross');
|
||||||
|
|
||||||
// groupe sans worktree résolu (branche inexistante) → 400.
|
// 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' });
|
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);
|
check('POST /groups/:id/session branche absente → 400', gsBad.status === 400);
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
const push = new PushService(db, config.vapidContact, undefined, box);
|
const push = new PushService(db, config.vapidContact, undefined, box);
|
||||||
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
||||||
const discovery = new DiscoveryService({
|
const discovery = new DiscoveryService({
|
||||||
|
db,
|
||||||
ptyManager: manager,
|
ptyManager: manager,
|
||||||
projectsDir: config.claudeProjectsDir,
|
projectsDir: config.claudeProjectsDir,
|
||||||
sessionsDir: config.claudeSessionsDir,
|
sessionsDir: config.claudeSessionsDir,
|
||||||
@@ -168,10 +169,10 @@ 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, db);
|
||||||
registerRepoRoutes(app, worktrees, db);
|
registerRepoRoutes(app, worktrees, db);
|
||||||
registerGroupRoutes(app, groups, db, worktrees, manager);
|
registerGroupRoutes(app, groups, db, worktrees, manager);
|
||||||
registerWorktreeRoutes(app, worktrees);
|
registerWorktreeRoutes(app, worktrees, db);
|
||||||
registerPushRoutes(app, push, db);
|
registerPushRoutes(app, push, db);
|
||||||
registerSettingsRoutes(app, db, config, serverVersion, push);
|
registerSettingsRoutes(app, db, config, serverVersion, push);
|
||||||
registerFsRoutes(app);
|
registerFsRoutes(app);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { join } from 'node:path';
|
|||||||
import type { SessionSummary } from '@arboretum/shared';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
import { scanProjects, type DiscoveredJsonl } from './jsonl-discovery.js';
|
import { scanProjects, type DiscoveredJsonl } from './jsonl-discovery.js';
|
||||||
import { readRegistry, type RegistryEntry } from './session-registry.js';
|
import { readRegistry, type RegistryEntry } from './session-registry.js';
|
||||||
|
import { listHiddenSessionIds, type Db } from '../db/index.js';
|
||||||
import type { PtyManager } from './pty-manager.js';
|
import type { PtyManager } from './pty-manager.js';
|
||||||
|
|
||||||
const DEFAULT_REFRESH_MS = 10_000;
|
const DEFAULT_REFRESH_MS = 10_000;
|
||||||
@@ -17,6 +18,7 @@ export interface DiscoveryServiceEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoveryOptions {
|
export interface DiscoveryOptions {
|
||||||
|
db: Db;
|
||||||
ptyManager: PtyManager;
|
ptyManager: PtyManager;
|
||||||
projectsDir?: string;
|
projectsDir?: string;
|
||||||
sessionsDir?: string;
|
sessionsDir?: string;
|
||||||
@@ -24,6 +26,7 @@ export interface DiscoveryOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
||||||
|
private readonly db: Db;
|
||||||
private readonly projectsDir: string;
|
private readonly projectsDir: string;
|
||||||
private readonly sessionsDir: string;
|
private readonly sessionsDir: string;
|
||||||
private readonly ptyManager: PtyManager;
|
private readonly ptyManager: PtyManager;
|
||||||
@@ -35,6 +38,7 @@ export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
|||||||
|
|
||||||
constructor(opts: DiscoveryOptions) {
|
constructor(opts: DiscoveryOptions) {
|
||||||
super();
|
super();
|
||||||
|
this.db = opts.db;
|
||||||
this.ptyManager = opts.ptyManager;
|
this.ptyManager = opts.ptyManager;
|
||||||
this.projectsDir = opts.projectsDir ?? join(homedir(), '.claude', 'projects');
|
this.projectsDir = opts.projectsDir ?? join(homedir(), '.claude', 'projects');
|
||||||
this.sessionsDir = opts.sessionsDir ?? join(homedir(), '.claude', 'sessions');
|
this.sessionsDir = opts.sessionsDir ?? join(homedir(), '.claude', 'sessions');
|
||||||
@@ -81,6 +85,9 @@ export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
|||||||
if (r.claudeSessionId) regBySid.set(r.claudeSessionId, r);
|
if (r.claudeSessionId) regBySid.set(r.claudeSessionId, r);
|
||||||
}
|
}
|
||||||
const known = this.ptyManager.knownClaudeSessionIds();
|
const known = this.ptyManager.knownClaudeSessionIds();
|
||||||
|
// Sessions masquées par l'utilisateur : on les garde dans le cache (resume/fork possibles) mais
|
||||||
|
// marquées `hidden` → la route /sessions les exclut par défaut.
|
||||||
|
const hiddenIds = listHiddenSessionIds(this.db);
|
||||||
|
|
||||||
// Dédoublonnage des JSONL par claudeSessionId (on retient le plus récent).
|
// Dédoublonnage des JSONL par claudeSessionId (on retient le plus récent).
|
||||||
const latest = new Map<string, DiscoveredJsonl>();
|
const latest = new Map<string, DiscoveredJsonl>();
|
||||||
@@ -113,6 +120,7 @@ export class DiscoveryService extends EventEmitter<DiscoveryServiceEvents> {
|
|||||||
resumable: !live, // morte → --resume direct ; vivante → fork/observe (jamais resume : corruption)
|
resumable: !live, // morte → --resume direct ; vivante → fork/observe (jamais resume : corruption)
|
||||||
attachable: false, // Arboretum ne tient pas le PTY d'une session externe
|
attachable: false, // Arboretum ne tient pas le PTY d'une session externe
|
||||||
registryStatus: r?.status ?? null,
|
registryStatus: r?.status ?? null,
|
||||||
|
hidden: hiddenIds.has(d.claudeSessionId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
// les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant.
|
// les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant.
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { resolve, sep } from 'node:path';
|
import { resolve, sep } from 'node:path';
|
||||||
import type { WorktreeGitStatus } from '@arboretum/shared';
|
import type { WorktreeGitStatus, WorktreeBranchAction, WorktreeBranchMode } from '@arboretum/shared';
|
||||||
|
|
||||||
const GIT_TIMEOUT_MS = 10_000;
|
const GIT_TIMEOUT_MS = 10_000;
|
||||||
|
// `push` peut dialoguer avec un remote (réseau) : on lui laisse une marge bien plus large.
|
||||||
|
const GIT_PUSH_TIMEOUT_MS = 120_000;
|
||||||
const GIT_MAX_BUFFER = 8 * 1024 * 1024;
|
const GIT_MAX_BUFFER = 8 * 1024 * 1024;
|
||||||
|
|
||||||
interface GitError extends Error {
|
interface GitError extends Error {
|
||||||
@@ -12,14 +14,14 @@ interface GitError extends Error {
|
|||||||
code?: number | string;
|
code?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function git(cwd: string, args: string[]): Promise<string> {
|
function git(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): Promise<string> {
|
||||||
return new Promise((resolveP, reject) => {
|
return new Promise((resolveP, reject) => {
|
||||||
execFile(
|
execFile(
|
||||||
'git',
|
'git',
|
||||||
args,
|
args,
|
||||||
{
|
{
|
||||||
cwd,
|
cwd,
|
||||||
timeout: GIT_TIMEOUT_MS,
|
timeout: timeoutMs,
|
||||||
maxBuffer: GIT_MAX_BUFFER,
|
maxBuffer: GIT_MAX_BUFFER,
|
||||||
// GIT_OPTIONAL_LOCKS=0 : pas de prise de verrou par `status` (perf + concurrence avec une
|
// GIT_OPTIONAL_LOCKS=0 : pas de prise de verrou par `status` (perf + concurrence avec une
|
||||||
// session claude active) ; LC_ALL=C : sortie stable pour le parsing.
|
// session claude active) ; LC_ALL=C : sortie stable pour le parsing.
|
||||||
@@ -140,6 +142,52 @@ export async function defaultBranch(repoPath: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Branche courante (nom court) du checkout en `repoPath`, ou null si HEAD détaché. */
|
||||||
|
export async function currentBranch(repoPath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const b = (await git(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
||||||
|
return b === 'HEAD' ? null : b;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Existence d'une branche, en local (`refs/heads`) et/ou suivie du remote (`refs/remotes/origin`). */
|
||||||
|
export async function branchExists(repoPath: string, branch: string): Promise<{ local: boolean; remote: boolean }> {
|
||||||
|
const verify = async (ref: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await git(repoPath, ['rev-parse', '--verify', '--quiet', ref]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const [local, remote] = await Promise.all([verify(`refs/heads/${branch}`), verify(`refs/remotes/origin/${branch}`)]);
|
||||||
|
return { local, remote };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branches locales + suivies de `origin` (noms courts) + branche par défaut — pour un sélecteur de base. */
|
||||||
|
export async function listBranches(repoPath: string): Promise<{ local: string[]; remote: string[]; default: string | null }> {
|
||||||
|
const local: string[] = [];
|
||||||
|
const remote: string[] = [];
|
||||||
|
try {
|
||||||
|
const out = await git(repoPath, ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes/origin']);
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const name = line.trim();
|
||||||
|
if (!name) continue;
|
||||||
|
if (name.startsWith('origin/')) {
|
||||||
|
const short = name.slice('origin/'.length);
|
||||||
|
if (short && short !== 'HEAD') remote.push(short);
|
||||||
|
} else {
|
||||||
|
local.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* dépôt sans refs encore (premier commit absent) → listes vides */
|
||||||
|
}
|
||||||
|
return { local, remote, default: await defaultBranch(repoPath) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function listWorktrees(repoPath: string): Promise<ParsedWorktree[]> {
|
export async function listWorktrees(repoPath: string): Promise<ParsedWorktree[]> {
|
||||||
return parseWorktreePorcelain(await git(repoPath, ['worktree', 'list', '--porcelain']));
|
return parseWorktreePorcelain(await git(repoPath, ['worktree', 'list', '--porcelain']));
|
||||||
}
|
}
|
||||||
@@ -175,19 +223,69 @@ export async function worktreeStatus(worktreePath: string): Promise<WorktreeGitS
|
|||||||
return { ahead, behind, dirtyCount, upstream };
|
return { ahead, behind, dirtyCount, upstream };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Point de départ d'une branche créée : `baseRef` explicite, sinon la branche par défaut du dépôt
|
||||||
|
* (locale ou suivie de `origin`), sinon undefined (git part alors du HEAD courant). */
|
||||||
|
async function resolveStartPoint(repoPath: string, baseRef?: string): Promise<string | undefined> {
|
||||||
|
if (baseRef) return baseRef;
|
||||||
|
const def = await defaultBranch(repoPath);
|
||||||
|
if (!def) return undefined;
|
||||||
|
const { local, remote } = await branchExists(repoPath, def);
|
||||||
|
if (local) return def;
|
||||||
|
if (remote) return `origin/${def}`;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un worktree en résolvant la branche selon `mode` (voir `WorktreeBranchMode`). Renvoie l'action
|
||||||
|
* effective. En mode `auto`, on choisit checkout / suivi-remote / création selon l'existence réelle de
|
||||||
|
* la branche — indispensable pour les groupes hétérogènes (branche présente dans certains dépôts seulement).
|
||||||
|
*/
|
||||||
export async function addWorktree(
|
export async function addWorktree(
|
||||||
repoPath: string,
|
repoPath: string,
|
||||||
opts: { path: string; branch: string; newBranch: boolean; baseRef?: string },
|
opts: { path: string; branch: string; mode: WorktreeBranchMode; baseRef?: string },
|
||||||
): Promise<void> {
|
): Promise<WorktreeBranchAction> {
|
||||||
|
const { local, remote } = await branchExists(repoPath, opts.branch);
|
||||||
|
let action: WorktreeBranchAction;
|
||||||
|
if (opts.mode === 'create') action = 'created';
|
||||||
|
else if (opts.mode === 'checkout') action = local ? 'reused' : 'tracked';
|
||||||
|
else action = local ? 'reused' : remote ? 'tracked' : 'created'; // auto
|
||||||
|
|
||||||
const args = ['worktree', 'add'];
|
const args = ['worktree', 'add'];
|
||||||
if (opts.newBranch) args.push('-b', opts.branch);
|
if (action === 'reused') {
|
||||||
args.push('--', opts.path);
|
args.push('--', opts.path, opts.branch);
|
||||||
if (opts.newBranch) {
|
} else if (action === 'tracked') {
|
||||||
if (opts.baseRef) args.push(opts.baseRef);
|
args.push('--track', '-b', opts.branch, '--', opts.path, `origin/${opts.branch}`);
|
||||||
} else {
|
} else {
|
||||||
args.push(opts.branch);
|
const start = await resolveStartPoint(repoPath, opts.baseRef);
|
||||||
|
args.push('-b', opts.branch, '--', opts.path);
|
||||||
|
if (start) args.push(start);
|
||||||
}
|
}
|
||||||
await git(repoPath, args);
|
await git(repoPath, args);
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `git add -A` puis commit. L'appelant garantit qu'il y a quelque chose à committer. */
|
||||||
|
export async function commitAll(repoPath: string, message: string): Promise<void> {
|
||||||
|
await git(repoPath, ['add', '-A']);
|
||||||
|
await git(repoPath, ['commit', '-m', message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pousse la branche courante. Si aucun upstream n'est configuré : `git push -u origin <branche>`. */
|
||||||
|
export async function push(repoPath: string): Promise<void> {
|
||||||
|
let hasUpstream = false;
|
||||||
|
try {
|
||||||
|
await git(repoPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
||||||
|
hasUpstream = true;
|
||||||
|
} catch {
|
||||||
|
hasUpstream = false;
|
||||||
|
}
|
||||||
|
if (hasUpstream) {
|
||||||
|
await git(repoPath, ['push'], GIT_PUSH_TIMEOUT_MS);
|
||||||
|
} else {
|
||||||
|
const branch = await currentBranch(repoPath);
|
||||||
|
if (!branch) throw new Error('cannot push a detached HEAD');
|
||||||
|
await git(repoPath, ['push', '-u', 'origin', branch], GIT_PUSH_TIMEOUT_MS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type {
|
|||||||
PostCreateHook,
|
PostCreateHook,
|
||||||
RepoSummary,
|
RepoSummary,
|
||||||
SessionSummary,
|
SessionSummary,
|
||||||
|
WorktreeBranchAction,
|
||||||
|
WorktreeBranchMode,
|
||||||
WorktreeGitStatus,
|
WorktreeGitStatus,
|
||||||
WorktreeSummary,
|
WorktreeSummary,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
@@ -22,13 +24,16 @@ import { scanForRepos } from './repo-scanner.js';
|
|||||||
import { preTrustProject } from './claude-trust.js';
|
import { preTrustProject } from './claude-trust.js';
|
||||||
import {
|
import {
|
||||||
addWorktree,
|
addWorktree,
|
||||||
|
commitAll,
|
||||||
defaultBranch,
|
defaultBranch,
|
||||||
isDirtyWorktreeError,
|
isDirtyWorktreeError,
|
||||||
isRepo,
|
isRepo,
|
||||||
isSafeAbsolutePath,
|
isSafeAbsolutePath,
|
||||||
isValidBranchName,
|
isValidBranchName,
|
||||||
|
listBranches,
|
||||||
listWorktrees,
|
listWorktrees,
|
||||||
pruneWorktrees,
|
pruneWorktrees,
|
||||||
|
push,
|
||||||
removeWorktree,
|
removeWorktree,
|
||||||
switchBranch,
|
switchBranch,
|
||||||
worktreeStatus,
|
worktreeStatus,
|
||||||
@@ -322,8 +327,8 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
|
|
||||||
async createWorktree(
|
async createWorktree(
|
||||||
repoId: string,
|
repoId: string,
|
||||||
req: { branch: string; newBranch: boolean; baseRef?: string; path?: string; runHooks?: boolean; preTrust?: boolean; startSession?: 'claude' | 'bash' | null },
|
req: { branch: string; mode?: WorktreeBranchMode; baseRef?: string; path?: string; runHooks?: boolean; preTrust?: boolean; startSession?: 'claude' | 'bash' | null },
|
||||||
): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[]; session: SessionSummary | null }> {
|
): Promise<{ worktree: WorktreeSummary; hookResults: HookRunResult[]; session: SessionSummary | null; action: WorktreeBranchAction }> {
|
||||||
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');
|
||||||
if (!isValidBranchName(req.branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${req.branch}`);
|
if (!isValidBranchName(req.branch)) throw httpError(400, 'BAD_BRANCH', `Invalid branch name: ${req.branch}`);
|
||||||
@@ -332,8 +337,9 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
if (existsSync(path)) throw httpError(409, 'PATH_EXISTS', `Path already exists: ${path}`);
|
if (existsSync(path)) throw httpError(409, 'PATH_EXISTS', `Path already exists: ${path}`);
|
||||||
|
|
||||||
return this.withLock(repoId, async () => {
|
return this.withLock(repoId, async () => {
|
||||||
|
let action: WorktreeBranchAction;
|
||||||
try {
|
try {
|
||||||
await addWorktree(row.path, { path, branch: req.branch, newBranch: req.newBranch, ...(req.baseRef ? { baseRef: req.baseRef } : {}) });
|
action = await addWorktree(row.path, { path, branch: req.branch, mode: req.mode ?? 'auto', ...(req.baseRef ? { baseRef: req.baseRef } : {}) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw httpError(400, 'WORKTREE_ADD_FAILED', (err as Error).message);
|
throw httpError(400, 'WORKTREE_ADD_FAILED', (err as Error).message);
|
||||||
}
|
}
|
||||||
@@ -352,7 +358,93 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
if (req.startSession) session = this.ptyManager.spawn({ cwd: path, command: req.startSession });
|
if (req.startSession) session = this.ptyManager.spawn({ cwd: path, command: req.startSession });
|
||||||
|
|
||||||
const worktree = (await this.emitWorktree(row, path)) ?? this.toSummary(row.id, row.path, { path, head: null, branch: req.branch, detached: false, locked: false, prunable: false, bare: false }, { ahead: 0, behind: 0, dirtyCount: 0, upstream: null });
|
const worktree = (await this.emitWorktree(row, path)) ?? this.toSummary(row.id, row.path, { path, head: null, branch: req.branch, detached: false, locked: false, prunable: false, bare: false }, { ahead: 0, behind: 0, dirtyCount: 0, upstream: null });
|
||||||
return { worktree, hookResults, session };
|
return { worktree, hookResults, session, action };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branches locales/remote + branche par défaut d'un repo — alimente le sélecteur de base côté UI. */
|
||||||
|
async listRepoBranches(repoId: string): Promise<{ local: string[]; remote: string[]; default: string | null }> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
return listBranches(row.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `git add -A` + commit dans le worktree visé (le checkout principal est un worktree valide ici). */
|
||||||
|
async commitWorktree(repoId: string, path: string, message: string): Promise<WorktreeSummary> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
const w = await this.findWorktree(row, path);
|
||||||
|
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||||
|
return this.withLock(repoId, async () => {
|
||||||
|
if ((await worktreeStatus(w.path)).dirtyCount === 0) {
|
||||||
|
throw httpError(409, 'NOTHING_TO_COMMIT', 'Nothing to commit — working tree is clean');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await commitAll(w.path, message);
|
||||||
|
} catch (err) {
|
||||||
|
throw httpError(400, 'COMMIT_FAILED', (err as Error).message);
|
||||||
|
}
|
||||||
|
this.factsCache.delete(repoId);
|
||||||
|
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pousse la branche du worktree visé (upstream auto si absent). */
|
||||||
|
async pushWorktree(repoId: string, path: string): Promise<WorktreeSummary> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
const w = await this.findWorktree(row, path);
|
||||||
|
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||||
|
return this.withLock(repoId, async () => {
|
||||||
|
try {
|
||||||
|
await push(w.path);
|
||||||
|
} catch (err) {
|
||||||
|
throw httpError(400, 'PUSH_FAILED', (err as Error).message);
|
||||||
|
}
|
||||||
|
this.factsCache.delete(repoId);
|
||||||
|
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Passer en principal » : la branche du worktree devient le checkout principal du dépôt. Une branche
|
||||||
|
* ne pouvant être extraite qu'à un seul endroit, on retire d'abord le worktree (libère la branche) puis
|
||||||
|
* on bascule le checkout principal dessus. Sans merge ni conflit possible. L'ancienne branche principale
|
||||||
|
* est conservée (jamais supprimée). Garde-fous d'arbre sale outrepassables par `force`.
|
||||||
|
*/
|
||||||
|
async promoteWorktree(repoId: string, path: string, force = false): Promise<WorktreeSummary | null> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
const w = await this.findWorktree(row, path);
|
||||||
|
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||||
|
if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'This is already the main checkout');
|
||||||
|
if (!w.branch || w.detached) throw httpError(400, 'DETACHED_WORKTREE', 'Worktree has no branch to promote (detached HEAD)');
|
||||||
|
const branch = w.branch;
|
||||||
|
return this.withLock(repoId, async () => {
|
||||||
|
if (!force) {
|
||||||
|
if ((await worktreeStatus(w.path)).dirtyCount > 0) {
|
||||||
|
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes — commit or pass force');
|
||||||
|
}
|
||||||
|
if ((await worktreeStatus(row.path)).dirtyCount > 0) {
|
||||||
|
throw httpError(409, 'DIRTY_TREE', 'Main checkout has uncommitted changes — commit/stash or pass force');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await removeWorktree(row.path, w.path, force);
|
||||||
|
} catch (err) {
|
||||||
|
if (!force && isDirtyWorktreeError(err)) {
|
||||||
|
throw httpError(409, 'WORKTREE_DIRTY', 'Worktree has uncommitted changes — pass force to promote anyway');
|
||||||
|
}
|
||||||
|
throw httpError(500, 'WORKTREE_REMOVE_FAILED', (err as Error).message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await switchBranch(row.path, { branch, create: false });
|
||||||
|
} catch (err) {
|
||||||
|
throw httpError(500, 'SWITCH_FAILED', (err as Error).message);
|
||||||
|
}
|
||||||
|
this.factsCache.delete(repoId);
|
||||||
|
this.emit('worktree_removed', { repoId, path: w.path });
|
||||||
|
return this.emitWorktree(row, row.path); // le checkout principal est désormais sur `branch`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,19 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
CREATE INDEX idx_sessions_group_id ON sessions(group_id);
|
CREATE INDEX idx_sessions_group_id ON sessions(group_id);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Masquage des sessions Claude découvertes (lancées en CLI hors Arboretum) qui polluent la liste.
|
||||||
|
// Calqué sur repos.hidden (#6) : la session masquée reste connue (resume/fork possibles) mais est
|
||||||
|
// exclue de la liste par défaut, et un re-scan ne la ressuscite pas. Clé = claudeSessionId (stable,
|
||||||
|
// partagé entre une découverte et sa reprise managée).
|
||||||
|
id: 9,
|
||||||
|
sql: `
|
||||||
|
CREATE TABLE hidden_sessions (
|
||||||
|
claude_session_id TEXT PRIMARY KEY,
|
||||||
|
hidden_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Db = DatabaseSync;
|
export type Db = DatabaseSync;
|
||||||
@@ -185,3 +198,23 @@ export function getSetting(db: Db, key: string): string | null {
|
|||||||
export function setSetting(db: Db, key: string, value: string): void {
|
export function setSetting(db: Db, key: string, value: string): void {
|
||||||
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
|
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Sessions masquées (par claudeSessionId) ----
|
||||||
|
|
||||||
|
/** Ensemble des claudeSessionId masqués par l'utilisateur. */
|
||||||
|
export function listHiddenSessionIds(db: Db): Set<string> {
|
||||||
|
const rows = db.prepare('SELECT claude_session_id FROM hidden_sessions').all() as Array<{ claude_session_id: string }>;
|
||||||
|
return new Set(rows.map((r) => r.claude_session_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Masque une session (idempotent). */
|
||||||
|
export function hideSession(db: Db, claudeSessionId: string): void {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO hidden_sessions (claude_session_id, hidden_at) VALUES (?, ?) ON CONFLICT(claude_session_id) DO NOTHING',
|
||||||
|
).run(claudeSessionId, new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ré-affiche une session masquée (idempotent). */
|
||||||
|
export function unhideSession(db: Db, claudeSessionId: string): void {
|
||||||
|
db.prepare('DELETE FROM hidden_sessions WHERE claude_session_id = ?').run(claudeSessionId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -139,7 +139,8 @@ export function registerGroupRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dirs.length === 0) {
|
if (dirs.length === 0) {
|
||||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'no directory to span (group has no resolvable worktree)' } });
|
const detail = branch ? `no repo has a worktree on branch "${branch}" (create it first)` : 'no repo has a resolvable main checkout';
|
||||||
|
return reply.status(400).send({ error: { code: 'NO_RESOLVABLE_WORKTREE', message: `Cannot start group session: ${detail}` } });
|
||||||
}
|
}
|
||||||
// cwd = parent commun des repos couverts, chacun relié en --add-dir (P6). Voir resolveGroupCwd.
|
// cwd = parent commun des repos couverts, chacun relié en --add-dir (P6). Voir resolveGroupCwd.
|
||||||
const { cwd, addDirs } = resolveGroupCwd(dirs);
|
const { cwd, addDirs } = resolveGroupCwd(dirs);
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { CreateSessionRequest, SessionResponse, SessionsListResponse } from '@arboretum/shared';
|
import type { CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionsListResponse } from '@arboretum/shared';
|
||||||
import type { PtyManager } from '../core/pty-manager.js';
|
import type { PtyManager } from '../core/pty-manager.js';
|
||||||
import { mergeSessions, type DiscoveryService } from '../core/discovery-service.js';
|
import { mergeSessions, type DiscoveryService } from '../core/discovery-service.js';
|
||||||
|
import { hideSession, unhideSession, type Db } from '../db/index.js';
|
||||||
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
|
|
||||||
export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager, discovery: DiscoveryService): void {
|
export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager, discovery: DiscoveryService, db: Db): void {
|
||||||
app.get('/api/v1/sessions', async (): Promise<SessionsListResponse> => {
|
app.get('/api/v1/sessions', async (req): Promise<SessionsListResponse> => {
|
||||||
return { sessions: mergeSessions(manager.list(), discovery.list()) };
|
const includeHidden = (req.query as { includeHidden?: string }).includeHidden === 'true';
|
||||||
|
const all = mergeSessions(manager.list(), discovery.list());
|
||||||
|
return { sessions: includeHidden ? all : all.filter((s) => !s.hidden) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Résout le claudeSessionId d'un :id de session (managée par UUID, ou découverte dont l'id EST le sid).
|
||||||
|
const resolveClaudeSid = (id: string): string | null => {
|
||||||
|
const managed = manager.resumeTargetById(id);
|
||||||
|
if (managed?.claudeSessionId) return managed.claudeSessionId;
|
||||||
|
return discovery.getDiscovered(id) ? id : null;
|
||||||
|
};
|
||||||
|
|
||||||
app.post('/api/v1/sessions', async (req, reply) => {
|
app.post('/api/v1/sessions', async (req, reply) => {
|
||||||
const body = req.body as Partial<CreateSessionRequest> | null;
|
const body = req.body as Partial<CreateSessionRequest> | null;
|
||||||
if (!body || typeof body.cwd !== 'string' || !body.cwd.startsWith('/')) {
|
if (!body || typeof body.cwd !== 'string' || !body.cwd.startsWith('/')) {
|
||||||
@@ -81,6 +92,49 @@ export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Masque une session découverte (ou managée morte) : exclue de la liste sauf ?includeHidden=true.
|
||||||
|
// Reste reprenable/forkable — c'est un filtre d'affichage, pas une suppression.
|
||||||
|
app.post('/api/v1/sessions/:id/hide', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const sid = resolveClaudeSid(id);
|
||||||
|
if (!sid) {
|
||||||
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No session with this id to hide' } });
|
||||||
|
}
|
||||||
|
hideSession(db, sid);
|
||||||
|
await discovery.refresh(); // rediffuse l'état (hidden) aux clients
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'session.hide', resourceId: sid });
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ré-affiche une session masquée.
|
||||||
|
app.delete('/api/v1/sessions/:id/hide', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const sid = resolveClaudeSid(id);
|
||||||
|
if (!sid) {
|
||||||
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No session with this id to unhide' } });
|
||||||
|
}
|
||||||
|
unhideSession(db, sid);
|
||||||
|
await discovery.refresh();
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'session.unhide', resourceId: sid });
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Masquage de masse : nettoie d'un coup tout l'historique externe actuellement visible. Les futures
|
||||||
|
// sessions externes réapparaîtront (sinon --no-discover). Renvoie le nombre de sessions masquées.
|
||||||
|
app.post('/api/v1/sessions/hide-discovered', async (req, reply) => {
|
||||||
|
const sids = discovery
|
||||||
|
.list()
|
||||||
|
.filter((s) => !s.hidden && s.claudeSessionId)
|
||||||
|
.map((s) => s.claudeSessionId as string);
|
||||||
|
for (const sid of sids) hideSession(db, sid);
|
||||||
|
await discovery.refresh();
|
||||||
|
if (sids.length > 0) {
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'session.hideDiscovered', resourceId: null, details: { count: sids.length } });
|
||||||
|
}
|
||||||
|
const res: HideDiscoveredResponse = { hidden: sids.length };
|
||||||
|
return reply.send(res);
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/v1/sessions/:id', async (req, reply) => {
|
app.delete('/api/v1/sessions/:id', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
if (!manager.kill(id)) {
|
if (!manager.kill(id)) {
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type {
|
import type {
|
||||||
AdoptWorktreeRequest,
|
AdoptWorktreeRequest,
|
||||||
|
CommitWorktreeRequest,
|
||||||
CreateWorktreeRequest,
|
CreateWorktreeRequest,
|
||||||
CreateWorktreeResponse,
|
CreateWorktreeResponse,
|
||||||
|
PromoteWorktreeRequest,
|
||||||
|
PushWorktreeRequest,
|
||||||
|
RepoBranchesResponse,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
StartRepoSessionRequest,
|
StartRepoSessionRequest,
|
||||||
|
WorktreeBranchMode,
|
||||||
WorktreeResponse,
|
WorktreeResponse,
|
||||||
WorktreesListResponse,
|
WorktreesListResponse,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
import type { WorktreeManager } from '../core/worktree-manager.js';
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
import type { Db } from '../db/index.js';
|
||||||
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
import { sendManagerError } from './repos.js';
|
import { sendManagerError } from './repos.js';
|
||||||
|
|
||||||
export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager): void {
|
/** Mappe l'API (mode prioritaire ; `newBranch` déprécié) vers la stratégie de résolution de branche. */
|
||||||
|
function resolveMode(body: { mode?: unknown; newBranch?: unknown }): WorktreeBranchMode {
|
||||||
|
if (body.mode === 'auto' || body.mode === 'create' || body.mode === 'checkout') return body.mode;
|
||||||
|
if (body.newBranch === true) return 'create';
|
||||||
|
if (body.newBranch === false) return 'checkout';
|
||||||
|
return 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void {
|
||||||
app.get('/api/v1/worktrees', async (): Promise<WorktreesListResponse> => ({ worktrees: await wt.listAllWorktrees() }));
|
app.get('/api/v1/worktrees', async (): Promise<WorktreesListResponse> => ({ worktrees: await wt.listAllWorktrees() }));
|
||||||
|
|
||||||
app.get('/api/v1/repos/:id/worktrees', async (req): Promise<WorktreesListResponse> => {
|
app.get('/api/v1/repos/:id/worktrees', async (req): Promise<WorktreesListResponse> => {
|
||||||
@@ -31,7 +46,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
|
|||||||
try {
|
try {
|
||||||
const out = await wt.createWorktree(id, {
|
const out = await wt.createWorktree(id, {
|
||||||
branch: body.branch,
|
branch: body.branch,
|
||||||
newBranch: body.newBranch !== false, // défaut : créer la branche
|
mode: resolveMode(body), // défaut : auto (détecte créer / checkout / suivi remote)
|
||||||
...(body.baseRef !== undefined ? { baseRef: body.baseRef } : {}),
|
...(body.baseRef !== undefined ? { baseRef: body.baseRef } : {}),
|
||||||
...(body.path !== undefined ? { path: body.path } : {}),
|
...(body.path !== undefined ? { path: body.path } : {}),
|
||||||
...(body.runHooks !== undefined ? { runHooks: body.runHooks } : {}),
|
...(body.runHooks !== undefined ? { runHooks: body.runHooks } : {}),
|
||||||
@@ -45,6 +60,17 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Branches du repo (locales + suivies de origin + défaut) — alimente le sélecteur de base côté UI.
|
||||||
|
app.get('/api/v1/repos/:id/branches', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
try {
|
||||||
|
const out = await wt.listRepoBranches(id);
|
||||||
|
return reply.send(out satisfies RepoBranchesResponse);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Session sur le checkout principal (« bosser sur la branche principale » sans worktree),
|
// Session sur le checkout principal (« bosser sur la branche principale » sans worktree),
|
||||||
// avec création/bascule de branche optionnelle côté serveur.
|
// avec création/bascule de branche optionnelle côté serveur.
|
||||||
app.post('/api/v1/repos/:id/session', async (req, reply) => {
|
app.post('/api/v1/repos/:id/session', async (req, reply) => {
|
||||||
@@ -101,6 +127,57 @@ export function registerWorktreeRoutes(app: FastifyInstance, wt: WorktreeManager
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Commit (git add -A + commit) dans un worktree (ou le checkout principal).
|
||||||
|
app.post('/api/v1/repos/:id/worktrees/commit', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = req.body as Partial<CommitWorktreeRequest> | null;
|
||||||
|
if (!body || typeof body.path !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
|
||||||
|
}
|
||||||
|
if (typeof body.message !== 'string' || body.message.trim() === '') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'message is required' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const worktree = await wt.commitWorktree(id, body.path, body.message.trim());
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.commit', resourceId: id, details: { path: body.path } });
|
||||||
|
return reply.send({ worktree } satisfies WorktreeResponse);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push de la branche d'un worktree (upstream auto si absent).
|
||||||
|
app.post('/api/v1/repos/:id/worktrees/push', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = req.body as Partial<PushWorktreeRequest> | null;
|
||||||
|
if (!body || typeof body.path !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const worktree = await wt.pushWorktree(id, body.path);
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.push', resourceId: id, details: { path: body.path } });
|
||||||
|
return reply.send({ worktree } satisfies WorktreeResponse);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Promotion « en principal » : la branche du worktree devient le checkout principal (worktree supprimé).
|
||||||
|
app.post('/api/v1/repos/:id/worktrees/promote', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = req.body as Partial<PromoteWorktreeRequest> | null;
|
||||||
|
if (!body || typeof body.path !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'path is required' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const worktree = await wt.promoteWorktree(id, body.path, body.force === true);
|
||||||
|
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'worktree.promote', resourceId: id, details: { path: body.path } });
|
||||||
|
return reply.send({ worktree });
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/v1/repos/:id/worktrees', async (req, reply) => {
|
app.delete('/api/v1/repos/:id/worktrees', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const query = req.query as { path?: string; force?: string };
|
const query = req.query as { path?: string; force?: string };
|
||||||
|
|||||||
@@ -148,6 +148,20 @@ describe('app e2e — auth, origin et sessions', () => {
|
|||||||
expect(res.json()).toEqual({ sessions: [] });
|
expect(res.json()).toEqual({ sessions: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('masquage des sessions : hide-discovered (vide), includeHidden, hide d’un id inconnu → 404', async () => {
|
||||||
|
const headers = { authorization: `Bearer ${t.token}` };
|
||||||
|
const mass = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/hide-discovered', headers });
|
||||||
|
expect(mass.statusCode).toBe(200);
|
||||||
|
expect(mass.json()).toEqual({ hidden: 0 });
|
||||||
|
|
||||||
|
const withHidden = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/sessions?includeHidden=true', headers });
|
||||||
|
expect(withHidden.statusCode).toBe(200);
|
||||||
|
expect(withHidden.json()).toEqual({ sessions: [] });
|
||||||
|
|
||||||
|
const unknown = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/sessions/nope/hide', headers });
|
||||||
|
expect(unknown.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
it('Origin interdite → 403 même avec un Bearer valide, et même sur la route publique de login', async () => {
|
it('Origin interdite → 403 même avec un Bearer valide, et même sur la route publique de login', async () => {
|
||||||
const res = await t.bundle.app.inject({
|
const res = await t.bundle.app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { DiscoveryService, mergeSessions } from '../src/core/discovery-service.j
|
|||||||
import { munge } from '../src/core/jsonl-discovery.js';
|
import { munge } from '../src/core/jsonl-discovery.js';
|
||||||
import { readProcStart } from '../src/core/session-registry.js';
|
import { readProcStart } from '../src/core/session-registry.js';
|
||||||
import { PtyManager } from '../src/core/pty-manager.js';
|
import { PtyManager } from '../src/core/pty-manager.js';
|
||||||
import { openDb, type Db } from '../src/db/index.js';
|
import { openDb, hideSession, unhideSession, listHiddenSessionIds, type Db } from '../src/db/index.js';
|
||||||
|
|
||||||
function writeJsonl(projectsDir: string, cwd: string, sid: string): void {
|
function writeJsonl(projectsDir: string, cwd: string, sid: string): void {
|
||||||
const dir = join(projectsDir, munge(cwd));
|
const dir = join(projectsDir, munge(cwd));
|
||||||
@@ -30,7 +30,7 @@ describe('DiscoveryService', () => {
|
|||||||
sessionsDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
|
sessionsDir = mkdtempSync(join(tmpdir(), 'arb-sess-'));
|
||||||
db = openDb(':memory:');
|
db = openDb(':memory:');
|
||||||
manager = new PtyManager(db, sessionsDir);
|
manager = new PtyManager(db, sessionsDir);
|
||||||
svc = new DiscoveryService({ ptyManager: manager, projectsDir, sessionsDir });
|
svc = new DiscoveryService({ db, ptyManager: manager, projectsDir, sessionsDir });
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
svc.stop();
|
svc.stop();
|
||||||
@@ -71,6 +71,29 @@ describe('DiscoveryService', () => {
|
|||||||
expect(svc.list().find((x) => x.id === 'managed-sid')).toBeUndefined();
|
expect(svc.list().find((x) => x.id === 'managed-sid')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marque hidden une session masquée mais la garde résoluble (resume/fork)', async () => {
|
||||||
|
writeJsonl(projectsDir, '/home/u/old', 'old-sid');
|
||||||
|
hideSession(db, 'old-sid');
|
||||||
|
await svc.refresh();
|
||||||
|
const s = svc.list().find((x) => x.id === 'old-sid');
|
||||||
|
expect(s?.hidden).toBe(true);
|
||||||
|
// toujours connue : la reprise/le fork doivent rester possibles
|
||||||
|
expect(svc.getDiscovered('old-sid')?.cwd).toBe('/home/u/old');
|
||||||
|
// ré-affichage
|
||||||
|
unhideSession(db, 'old-sid');
|
||||||
|
await svc.refresh();
|
||||||
|
expect(svc.list().find((x) => x.id === 'old-sid')?.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hideSession est idempotent et listHiddenSessionIds reflète l’état', () => {
|
||||||
|
hideSession(db, 'a');
|
||||||
|
hideSession(db, 'a');
|
||||||
|
hideSession(db, 'b');
|
||||||
|
expect(listHiddenSessionIds(db)).toEqual(new Set(['a', 'b']));
|
||||||
|
unhideSession(db, 'a');
|
||||||
|
expect(listHiddenSessionIds(db)).toEqual(new Set(['b']));
|
||||||
|
});
|
||||||
|
|
||||||
it('émet discovery_update sur changement uniquement', async () => {
|
it('émet discovery_update sur changement uniquement', async () => {
|
||||||
writeJsonl(projectsDir, '/home/u/x', 'sid-x');
|
writeJsonl(projectsDir, '/home/u/x', 'sid-x');
|
||||||
const seen: SessionSummary[] = [];
|
const seen: SessionSummary[] = [];
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
pruneWorktrees,
|
pruneWorktrees,
|
||||||
switchBranch,
|
switchBranch,
|
||||||
isDirtyWorktreeError,
|
isDirtyWorktreeError,
|
||||||
|
branchExists,
|
||||||
|
currentBranch,
|
||||||
|
listBranches,
|
||||||
|
commitAll,
|
||||||
} from '../src/core/git.js';
|
} from '../src/core/git.js';
|
||||||
|
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
@@ -96,7 +100,7 @@ describe('opérations git (repo tmp réel)', () => {
|
|||||||
const repo = makeTmpRepo();
|
const repo = makeTmpRepo();
|
||||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
||||||
dirs.push(wtPath);
|
dirs.push(wtPath);
|
||||||
await addWorktree(repo, { path: wtPath, branch: 'feat', newBranch: true });
|
await addWorktree(repo, { path: wtPath, branch: 'feat', mode: 'create' });
|
||||||
|
|
||||||
const list = await listWorktrees(repo);
|
const list = await listWorktrees(repo);
|
||||||
const wt = list.find((w) => resolve(w.path) === resolve(wtPath));
|
const wt = list.find((w) => resolve(w.path) === resolve(wtPath));
|
||||||
@@ -136,9 +140,57 @@ describe('opérations git (repo tmp réel)', () => {
|
|||||||
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`);
|
||||||
await addWorktree(repo, { path: wtPath, branch: 'gone', newBranch: true });
|
await addWorktree(repo, { path: wtPath, branch: 'gone', mode: 'create' });
|
||||||
rmSync(wtPath, { recursive: true, force: true }); // suppression "à la main"
|
rmSync(wtPath, { recursive: true, force: true }); // suppression "à la main"
|
||||||
await pruneWorktrees(repo);
|
await pruneWorktrees(repo);
|
||||||
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('branche : existence, liste, courante, commit', () => {
|
||||||
|
it('branchExists / currentBranch / listBranches', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
expect(await currentBranch(repo)).toBe('main');
|
||||||
|
expect(await branchExists(repo, 'main')).toEqual({ local: true, remote: false });
|
||||||
|
expect(await branchExists(repo, 'nope')).toEqual({ local: false, remote: false });
|
||||||
|
|
||||||
|
execFileSync('git', ['branch', 'dev'], { cwd: repo });
|
||||||
|
const b = await listBranches(repo);
|
||||||
|
expect(b.local).toContain('main');
|
||||||
|
expect(b.local).toContain('dev');
|
||||||
|
expect(b.remote).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commitAll : add -A + commit, fait baisser dirtyCount à 0', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
writeFileSync(join(repo, 'new.txt'), 'hello\n');
|
||||||
|
expect((await worktreeStatus(repo)).dirtyCount).toBeGreaterThan(0);
|
||||||
|
await commitAll(repo, 'add new.txt');
|
||||||
|
expect((await worktreeStatus(repo)).dirtyCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addWorktree : résolution auto (créer / réutiliser)', () => {
|
||||||
|
it('auto crée la branche si absente, la réutilise si présente', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
// branche absente → création (renvoie "created")
|
||||||
|
const wt1 = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
||||||
|
dirs.push(wt1);
|
||||||
|
expect(await addWorktree(repo, { path: wt1, branch: 'feat', mode: 'auto' })).toBe('created');
|
||||||
|
expect((await listWorktrees(repo)).find((w) => resolve(w.path) === resolve(wt1))?.branch).toBe('feat');
|
||||||
|
|
||||||
|
// la même branche existe désormais ; un AUTRE worktree dessus → checkout (renvoie "reused")
|
||||||
|
await removeWorktree(repo, wt1, true); // libère la branche
|
||||||
|
const wt2 = join(dirname(repo), `${basename(repo)}-wt-feat2`);
|
||||||
|
dirs.push(wt2);
|
||||||
|
expect(await addWorktree(repo, { path: wt2, branch: 'feat', mode: 'auto' })).toBe('reused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mode create échoue si la branche existe déjà', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
execFileSync('git', ['branch', 'dup'], { cwd: repo });
|
||||||
|
const wt = join(dirname(repo), `${basename(repo)}-wt-dup`);
|
||||||
|
dirs.push(wt);
|
||||||
|
await expect(addWorktree(repo, { path: wt, branch: 'dup', mode: 'create' })).rejects.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ describe('WorktreeManager', () => {
|
|||||||
claudeHome = mkdtempSync(join(tmpdir(), 'arb-ch-'));
|
claudeHome = mkdtempSync(join(tmpdir(), 'arb-ch-'));
|
||||||
dirs.push(claudeHome);
|
dirs.push(claudeHome);
|
||||||
pty = new PtyManager(db, join(claudeHome, 'sessions'));
|
pty = new PtyManager(db, join(claudeHome, 'sessions'));
|
||||||
discovery = new DiscoveryService({ ptyManager: pty, projectsDir: join(claudeHome, 'projects'), sessionsDir: join(claudeHome, 'sessions') });
|
discovery = new DiscoveryService({ db, ptyManager: pty, projectsDir: join(claudeHome, 'projects'), sessionsDir: join(claudeHome, 'sessions') });
|
||||||
wt = new WorktreeManager(db, pty, discovery);
|
wt = new WorktreeManager(db, pty, discovery);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +97,8 @@ describe('WorktreeManager', () => {
|
|||||||
|
|
||||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
||||||
dirs.push(wtPath);
|
dirs.push(wtPath);
|
||||||
const out = await wt.createWorktree(r.id, { branch: 'feat', newBranch: true, runHooks: true });
|
const out = await wt.createWorktree(r.id, { branch: 'feat', mode: 'create', runHooks: true });
|
||||||
|
expect(out.action).toBe('created');
|
||||||
|
|
||||||
expect(resolve(out.worktree.path)).toBe(resolve(wtPath));
|
expect(resolve(out.worktree.path)).toBe(resolve(wtPath));
|
||||||
expect(out.worktree.branch).toBe('feat');
|
expect(out.worktree.branch).toBe('feat');
|
||||||
@@ -113,7 +114,7 @@ describe('WorktreeManager', () => {
|
|||||||
it('createWorktree : branche invalide → 400', async () => {
|
it('createWorktree : branche invalide → 400', async () => {
|
||||||
const repo = makeTmpRepo();
|
const repo = makeTmpRepo();
|
||||||
const r = await wt.addRepo({ path: repo });
|
const r = await wt.addRepo({ path: repo });
|
||||||
await expect(wt.createWorktree(r.id, { branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 });
|
await expect(wt.createWorktree(r.id, { branch: '../evil', mode: 'create' })).rejects.toMatchObject({ statusCode: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('startMainSession : session dans le checkout principal (branche actuelle, sans mutation git)', async () => {
|
it('startMainSession : session dans le checkout principal (branche actuelle, sans mutation git)', async () => {
|
||||||
@@ -163,7 +164,7 @@ describe('WorktreeManager', () => {
|
|||||||
|
|
||||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-x`);
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-x`);
|
||||||
dirs.push(wtPath);
|
dirs.push(wtPath);
|
||||||
await wt.createWorktree(r.id, { branch: 'x', newBranch: true, runHooks: false });
|
await wt.createWorktree(r.id, { branch: 'x', mode: 'create', runHooks: false });
|
||||||
writeFileSync(join(wtPath, 'scratch.txt'), 'wip\n'); // worktree sale
|
writeFileSync(join(wtPath, 'scratch.txt'), 'wip\n'); // worktree sale
|
||||||
|
|
||||||
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'WORKTREE_DIRTY' });
|
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'WORKTREE_DIRTY' });
|
||||||
@@ -176,7 +177,7 @@ describe('WorktreeManager', () => {
|
|||||||
const r = await wt.addRepo({ path: repo });
|
const r = await wt.addRepo({ path: repo });
|
||||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-sess`);
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-sess`);
|
||||||
dirs.push(wtPath);
|
dirs.push(wtPath);
|
||||||
await wt.createWorktree(r.id, { branch: 'sess', newBranch: true, runHooks: false });
|
await wt.createWorktree(r.id, { branch: 'sess', mode: 'create', runHooks: false });
|
||||||
|
|
||||||
pty.spawn({ cwd: wtPath, command: 'bash' });
|
pty.spawn({ cwd: wtPath, command: 'bash' });
|
||||||
const list = await wt.listRepoWorktrees(r.id, true);
|
const list = await wt.listRepoWorktrees(r.id, true);
|
||||||
@@ -190,11 +191,49 @@ describe('WorktreeManager', () => {
|
|||||||
const r = await wt.addRepo({ path: repo });
|
const r = await wt.addRepo({ path: repo });
|
||||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-busy`);
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-busy`);
|
||||||
dirs.push(wtPath);
|
dirs.push(wtPath);
|
||||||
await wt.createWorktree(r.id, { branch: 'busy', newBranch: true, runHooks: false });
|
await wt.createWorktree(r.id, { branch: 'busy', mode: 'create', runHooks: false });
|
||||||
pty.spawn({ cwd: wtPath, command: 'bash' });
|
pty.spawn({ cwd: wtPath, command: 'bash' });
|
||||||
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'SESSION_LIVE_IN_WORKTREE' });
|
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'SESSION_LIVE_IN_WORKTREE' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('listRepoBranches : renvoie les branches locales (dont main)', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
const b = await wt.listRepoBranches(r.id);
|
||||||
|
expect(b.local).toContain('main');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commitWorktree : arbre propre → 409 NOTHING_TO_COMMIT ; arbre sale → commit (dirty=0)', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
await expect(wt.commitWorktree(r.id, repo, 'noop')).rejects.toMatchObject({ statusCode: 409, code: 'NOTHING_TO_COMMIT' });
|
||||||
|
writeFileSync(join(repo, 'f.txt'), 'x\n');
|
||||||
|
const w = await wt.commitWorktree(r.id, repo, 'add f');
|
||||||
|
expect(w.git.dirtyCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promoteWorktree : la branche du worktree devient le checkout principal, worktree supprimé, ancienne branche conservée', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
const wtPath = join(dirname(repo), `${basename(repo)}-wt-prom`);
|
||||||
|
dirs.push(wtPath);
|
||||||
|
await wt.createWorktree(r.id, { branch: 'prom', mode: 'create', runHooks: false });
|
||||||
|
|
||||||
|
await wt.promoteWorktree(r.id, wtPath);
|
||||||
|
|
||||||
|
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('prom');
|
||||||
|
expect((await wt.listRepoWorktrees(r.id, true)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
||||||
|
const branches = await wt.listRepoBranches(r.id);
|
||||||
|
expect(branches.local).toContain('main'); // ancienne branche principale conservée
|
||||||
|
expect(branches.local).toContain('prom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promoteWorktree : checkout principal refusé (400 IS_MAIN_WORKTREE)', async () => {
|
||||||
|
const repo = makeTmpRepo();
|
||||||
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
await expect(wt.promoteWorktree(r.id, repo)).rejects.toMatchObject({ statusCode: 400, code: 'IS_MAIN_WORKTREE' });
|
||||||
|
});
|
||||||
|
|
||||||
it('addRepo renvoie hidden:false ; updateRepo({hidden}) bascule et émet repo_update', async () => {
|
it('addRepo renvoie hidden:false ; updateRepo({hidden}) bascule et émet repo_update', async () => {
|
||||||
const repo = makeTmpRepo();
|
const repo = makeTmpRepo();
|
||||||
const r = await wt.addRepo({ path: repo });
|
const r = await wt.addRepo({ path: repo });
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export interface SessionsListResponse {
|
|||||||
export interface SessionResponse {
|
export interface SessionResponse {
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
}
|
}
|
||||||
|
/** POST /sessions/hide-discovered — masque tout l'historique de sessions externes visible. */
|
||||||
|
export interface HideDiscoveredResponse {
|
||||||
|
/** nombre de sessions effectivement masquées. */
|
||||||
|
hidden: number;
|
||||||
|
}
|
||||||
|
|
||||||
// POST /sessions/:id/resume et /sessions/:id/fork (P2) : aucun corps — cwd et command
|
// POST /sessions/:id/resume et /sessions/:id/fork (P2) : aucun corps — cwd et command
|
||||||
// sont TOUJOURS dérivés du :id (lu sur disque), jamais fournis par le client (cf. spike S1).
|
// sont TOUJOURS dérivés du :id (lu sur disque), jamais fournis par le client (cf. spike S1).
|
||||||
@@ -104,11 +109,25 @@ export interface HookRunResult {
|
|||||||
output: string;
|
output: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Stratégie de résolution de la branche d'un nouveau worktree :
|
||||||
|
* - `auto` (défaut) : détecte l'existence de la branche — checkout si elle existe en local, suivi de
|
||||||
|
* `origin/<branch>` si elle n'existe que sur le remote, sinon création (`-b`). Robuste pour les
|
||||||
|
* groupes hétérogènes où la branche peut déjà exister dans certains dépôts mais pas d'autres.
|
||||||
|
* - `create` : force la création (`-b`), échoue si la branche existe déjà.
|
||||||
|
* - `checkout` : force le checkout d'une branche existante, échoue si elle est absente.
|
||||||
|
*/
|
||||||
|
export type WorktreeBranchMode = 'auto' | 'create' | 'checkout';
|
||||||
|
/** Action réellement effectuée par la résolution `auto` (feedback UI). */
|
||||||
|
export type WorktreeBranchAction = 'created' | 'reused' | 'tracked';
|
||||||
|
|
||||||
export interface CreateWorktreeRequest {
|
export interface CreateWorktreeRequest {
|
||||||
branch: string;
|
branch: string;
|
||||||
/** true : créer la branche (`-b`) ; false : checkout d'une branche existante. */
|
/** stratégie de résolution de la branche (défaut : `auto`). Voir {@link WorktreeBranchMode}. */
|
||||||
newBranch: boolean;
|
mode?: WorktreeBranchMode;
|
||||||
/** point de départ de la nouvelle branche (défaut : HEAD courant du repo). */
|
/** @deprecated remplacé par `mode` ; mappé : true→create, false→checkout, absent→auto. */
|
||||||
|
newBranch?: boolean;
|
||||||
|
/** point de départ d'une branche créée (défaut : branche par défaut du dépôt, sinon HEAD). */
|
||||||
baseRef?: string;
|
baseRef?: string;
|
||||||
/** chemin cible du worktree (défaut : `<parent>/<repo>-wt-<branch>`). */
|
/** chemin cible du worktree (défaut : `<parent>/<repo>-wt-<branch>`). */
|
||||||
path?: string;
|
path?: string;
|
||||||
@@ -121,6 +140,35 @@ export interface CreateWorktreeResponse {
|
|||||||
worktree: WorktreeSummary;
|
worktree: WorktreeSummary;
|
||||||
hookResults: HookRunResult[];
|
hookResults: HookRunResult[];
|
||||||
session: SessionSummary | null;
|
session: SessionSummary | null;
|
||||||
|
/** action effective de la résolution de branche (créée / réutilisée / suivie du remote). */
|
||||||
|
action: WorktreeBranchAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/repos/:id/branches — branches locales/remote pour alimenter un sélecteur de base. */
|
||||||
|
export interface RepoBranchesResponse {
|
||||||
|
local: string[];
|
||||||
|
remote: string[];
|
||||||
|
/** branche par défaut du dépôt (origin/HEAD), ou null si indéterminée. */
|
||||||
|
default: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/repos/:id/worktrees/commit — `git add -A` puis commit dans le worktree visé. */
|
||||||
|
export interface CommitWorktreeRequest {
|
||||||
|
path: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
/** POST /api/v1/repos/:id/worktrees/push — pousse la branche du worktree (upstream auto si absent). */
|
||||||
|
export interface PushWorktreeRequest {
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* POST /api/v1/repos/:id/worktrees/promote — « passer en principal » : la branche du worktree devient
|
||||||
|
* le checkout principal du dépôt (le worktree est supprimé, l'ancienne branche principale est conservée).
|
||||||
|
*/
|
||||||
|
export interface PromoteWorktreeRequest {
|
||||||
|
path: string;
|
||||||
|
/** outrepasse les garde-fous d'arbre sale (worktree/checkout principal). */
|
||||||
|
force?: boolean;
|
||||||
}
|
}
|
||||||
export interface AdoptWorktreeRequest {
|
export interface AdoptWorktreeRequest {
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ export interface SessionSummary {
|
|||||||
addedDirs?: string[];
|
addedDirs?: string[];
|
||||||
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
|
// ---- Masquage (additif) ----
|
||||||
|
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Worktrees & repos (P3) ----
|
// ---- Worktrees & repos (P3) ----
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ const dlgOpts = computed(() => DLG_OPTS[locale.value as AppLocale]);
|
|||||||
<span class="font-mono text-[11px] text-zinc-400">claude</span>
|
<span class="font-mono text-[11px] text-zinc-400">claude</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- actions git de la carte : commit / push (fidèle au WorktreeCard) -->
|
||||||
|
<div class="mt-1 flex items-center gap-1.5">
|
||||||
|
<span class="inline-flex items-center rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 text-[11px] font-medium text-zinc-300">{{ t('mCommit') }}</span>
|
||||||
|
<span class="inline-flex items-center rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 text-[11px] font-medium text-zinc-300">{{ t('mPush') }} ↑2</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default {
|
|||||||
featKicker: 'Everything in one view',
|
featKicker: 'Everything in one view',
|
||||||
featTitle: 'Built to supervise agents working in parallel',
|
featTitle: 'Built to supervise agents working in parallel',
|
||||||
feat1Title: 'Worktree-first',
|
feat1Title: 'Worktree-first',
|
||||||
feat1Desc: 'Work on your main branch or spin up an isolated worktree — every session stays pinned to a branch. Branches, not chaos.',
|
feat1Desc: 'Work on your main branch or spin up an isolated worktree — then commit, push or promote a branch to main, without opening a terminal. 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',
|
||||||
@@ -35,11 +35,11 @@ export default {
|
|||||||
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:
|
||||||
'A live list of every worktree and its session state. The Needs attention rail floats the agents waiting on a decision to the top, so nothing sits blocked while you are heads-down elsewhere.',
|
'A live list of every worktree and its session state. The Needs attention rail floats the agents waiting on a decision to the top, so nothing sits blocked while you are heads-down elsewhere. Commit, push or promote a branch to main straight from the card, and hide the noisy history left by past terminal sessions so the list stays actionable.',
|
||||||
scBKicker: 'Multi-terminal grid',
|
scBKicker: 'Multi-terminal grid',
|
||||||
scBTitle: "Every agent's output, streaming live",
|
scBTitle: "Every agent's output, streaming live",
|
||||||
scBBody:
|
scBBody:
|
||||||
'Watch all your sessions at once — real terminal output, color-coded by state, updating in real time. Click any pane to take over, answer a prompt, or stop a run.',
|
'Watch all your sessions at once — real terminal output, color-coded by state, updating in real time. Click any pane to take over in a fullscreen terminal — prompt pinned to the bottom, full scrollback above — answer a prompt, or stop a run.',
|
||||||
scCKicker: 'On your phone',
|
scCKicker: 'On your phone',
|
||||||
scCTitle: 'Unblock an agent from the kitchen',
|
scCTitle: 'Unblock an agent from the kitchen',
|
||||||
scCBody:
|
scCBody:
|
||||||
@@ -93,6 +93,8 @@ export default {
|
|||||||
mClean: 'clean',
|
mClean: 'clean',
|
||||||
mDirty: '3 changed',
|
mDirty: '3 changed',
|
||||||
mStart: 'Start work',
|
mStart: 'Start work',
|
||||||
|
mCommit: 'Commit',
|
||||||
|
mPush: 'Push',
|
||||||
toastTitle: 'Session waiting for you',
|
toastTitle: 'Session waiting for you',
|
||||||
toastBody: 'api · feat/auth needs a decision',
|
toastBody: 'api · feat/auth needs a decision',
|
||||||
dlgQ: 'Apply this change to login.ts?',
|
dlgQ: 'Apply this change to login.ts?',
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default {
|
|||||||
featKicker: 'Tout dans une seule vue',
|
featKicker: 'Tout dans une seule vue',
|
||||||
featTitle: 'Conçu pour superviser des agents qui travaillent en parallèle',
|
featTitle: 'Conçu pour superviser des agents qui travaillent en parallèle',
|
||||||
feat1Title: "Worktree d'abord",
|
feat1Title: "Worktree d'abord",
|
||||||
feat1Desc: 'Travaillez sur votre branche principale ou créez un worktree isolé — chaque session reste rattachée à une branche. Des branches, pas du chaos.',
|
feat1Desc: 'Travaillez sur votre branche principale ou créez un worktree isolé — puis commitez, poussez ou passez une branche en principal, sans ouvrir de terminal. 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',
|
||||||
@@ -35,11 +35,11 @@ export default {
|
|||||||
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:
|
||||||
"Une liste en direct de chaque worktree et de l'état de sa session. Le bandeau À traiter fait remonter les agents en attente d'une décision, pour que rien ne reste bloqué pendant que vous êtes concentré ailleurs.",
|
"Une liste en direct de chaque worktree et de l'état de sa session. Le bandeau À traiter fait remonter les agents en attente d'une décision, pour que rien ne reste bloqué pendant que vous êtes concentré ailleurs. Commitez, poussez ou passez une branche en principal directement depuis la carte, et masquez l'historique encombrant laissé par vos anciennes sessions terminal pour garder la liste actionnable.",
|
||||||
scBKicker: 'Grille multi-terminaux',
|
scBKicker: 'Grille multi-terminaux',
|
||||||
scBTitle: 'La sortie de chaque agent, en direct',
|
scBTitle: 'La sortie de chaque agent, en direct',
|
||||||
scBBody:
|
scBBody:
|
||||||
"Surveillez toutes vos sessions en même temps — vraie sortie terminal, couleur selon l'état, mise à jour en temps réel. Cliquez sur un panneau pour reprendre la main, répondre, ou arrêter une exécution.",
|
"Surveillez toutes vos sessions en même temps — vraie sortie terminal, couleur selon l'état, mise à jour en temps réel. Cliquez sur un panneau pour reprendre la main dans un terminal plein écran — invite ancrée en bas, historique défilable au-dessus — répondre, ou arrêter une exécution.",
|
||||||
scCKicker: 'Sur votre téléphone',
|
scCKicker: 'Sur votre téléphone',
|
||||||
scCTitle: 'Débloquez un agent depuis la cuisine',
|
scCTitle: 'Débloquez un agent depuis la cuisine',
|
||||||
scCBody:
|
scCBody:
|
||||||
@@ -93,6 +93,8 @@ export default {
|
|||||||
mClean: 'propre',
|
mClean: 'propre',
|
||||||
mDirty: '3 modifiés',
|
mDirty: '3 modifiés',
|
||||||
mStart: 'Démarrer',
|
mStart: 'Démarrer',
|
||||||
|
mCommit: 'Commit',
|
||||||
|
mPush: 'Push',
|
||||||
toastTitle: 'Une session vous attend',
|
toastTitle: 'Une session vous attend',
|
||||||
toastBody: 'api · feat/auth attend une décision',
|
toastBody: 'api · feat/auth attend une décision',
|
||||||
dlgQ: 'Appliquer ce changement à login.ts ?',
|
dlgQ: 'Appliquer ce changement à login.ts ?',
|
||||||
|
|||||||
@@ -4,18 +4,21 @@
|
|||||||
<span class="badge bg-amber-900 text-amber-200">{{ t(`sessions.dialog.kind.${dialog.kind}`) }}</span>
|
<span class="badge bg-amber-900 text-amber-200">{{ t(`sessions.dialog.kind.${dialog.kind}`) }}</span>
|
||||||
<span class="text-sm text-amber-100">{{ dialog.waitingFor ?? t('sessions.dialog.waiting') }}</span>
|
<span class="text-sm text-amber-100">{{ dialog.waitingFor ?? t('sessions.dialog.waiting') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<!-- Options empilées verticalement et pleine largeur : un libellé long (ex. AskUserQuestion) wrappe
|
||||||
|
proprement au lieu d'être tronqué/collé au suivant comme avec un flex-wrap horizontal. -->
|
||||||
|
<div class="mt-2 flex flex-col gap-1.5">
|
||||||
<button
|
<button
|
||||||
v-for="opt in dialog.options"
|
v-for="opt in dialog.options"
|
||||||
:key="opt.n"
|
:key="opt.n"
|
||||||
class="btn text-xs"
|
class="btn w-full items-start justify-start whitespace-normal break-words text-left text-xs"
|
||||||
:class="opt.selected ? 'border-amber-400 text-amber-200' : ''"
|
:class="opt.selected ? 'border-amber-400 text-amber-200' : ''"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@click="respond('select', opt.n)"
|
@click="respond('select', opt.n)"
|
||||||
>
|
>
|
||||||
<span class="font-mono text-amber-400">{{ opt.n }}</span> {{ opt.label }}
|
<span class="font-mono text-amber-400">{{ opt.n }}</span>
|
||||||
|
<span class="min-w-0 flex-1">{{ opt.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-danger text-xs" :disabled="busy" @click="respond('deny')">
|
<button class="btn-danger w-full justify-start text-left text-xs" :disabled="busy" @click="respond('deny')">
|
||||||
{{ t('sessions.dialog.deny') }}
|
{{ t('sessions.dialog.deny') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,21 +21,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- mode feature : branche commune créée dans chaque repo -->
|
<!-- mode feature : branche commune créée (ou réutilisée si déjà présente) dans chaque repo -->
|
||||||
<template v-if="mode === 'feature'">
|
<template v-if="mode === 'feature'">
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
{{ t('crossRepo.branchLabel') }}
|
{{ t('crossRepo.branchLabel') }}
|
||||||
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" />
|
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" />
|
||||||
</label>
|
<span class="text-[11px] text-zinc-500">{{ t('crossRepo.branchHint') }}</span>
|
||||||
<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>
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
{{ t('crossRepo.baseRefLabel') }}
|
{{ t('crossRepo.baseRefLabel') }}
|
||||||
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
|
<input v-model="baseRef" class="input font-mono" list="group-base-branches" :placeholder="t('crossRepo.baseRefPlaceholder')" />
|
||||||
|
<datalist id="group-base-branches">
|
||||||
|
<option v-for="b in baseBranchOptions" :key="b" :value="b" />
|
||||||
|
</datalist>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
@@ -78,11 +77,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
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 { RepoSummary } from '@arboretum/shared';
|
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
|
||||||
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
|
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import { useToastsStore } from '../stores/toasts';
|
import { useToastsStore } from '../stores/toasts';
|
||||||
|
|
||||||
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
|
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
|
||||||
@@ -91,12 +91,29 @@ const emit = defineEmits<{ close: [] }>();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const groups = useGroupsStore();
|
const groups = useGroupsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
const toasts = useToastsStore();
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
const mode = ref<'feature' | 'main'>('feature');
|
const mode = ref<'feature' | 'main'>('feature');
|
||||||
const branch = ref('');
|
const branch = ref('');
|
||||||
const newBranch = ref(true);
|
|
||||||
const baseRef = ref('');
|
const baseRef = ref('');
|
||||||
|
|
||||||
|
// Sélecteur de branche de base : branches du 1er repo du groupe (indicatif ; vide → défaut serveur par repo).
|
||||||
|
const branches = ref<RepoBranchesResponse | null>(null);
|
||||||
|
const baseBranchOptions = computed<string[]>(() => {
|
||||||
|
const b = branches.value;
|
||||||
|
if (!b) return [];
|
||||||
|
return Array.from(new Set([...(b.default ? [b.default] : []), ...b.local, ...b.remote]));
|
||||||
|
});
|
||||||
|
onMounted(async () => {
|
||||||
|
const first = props.repos[0];
|
||||||
|
if (!first) return;
|
||||||
|
try {
|
||||||
|
branches.value = await worktrees.fetchBranches(first.id);
|
||||||
|
} catch {
|
||||||
|
/* le sélecteur de base est facultatif */
|
||||||
|
}
|
||||||
|
});
|
||||||
const command = ref<'claude' | 'bash'>('claude');
|
const command = ref<'claude' | 'bash'>('claude');
|
||||||
const wtResults = ref<Record<string, CrossRepoResult>>({});
|
const wtResults = ref<Record<string, CrossRepoResult>>({});
|
||||||
const outcome = ref<GroupFeatureOutcome | null>(null);
|
const outcome = ref<GroupFeatureOutcome | null>(null);
|
||||||
@@ -136,7 +153,7 @@ async function onSubmit(): Promise<void> {
|
|||||||
{
|
{
|
||||||
command: command.value,
|
command: command.value,
|
||||||
...(mode.value === 'feature'
|
...(mode.value === 'feature'
|
||||||
? { branch: branch.value.trim(), newBranch: newBranch.value, ...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}) }
|
? { branch: branch.value.trim(), ...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}) }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
(result) => {
|
(result) => {
|
||||||
|
|||||||
@@ -27,12 +27,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-end gap-2">
|
<div class="flex flex-wrap items-end gap-2">
|
||||||
<!-- mode worktree : branche + créer/existante -->
|
<!-- mode worktree : branche (créée si absente, réutilisée si présente) + branche de base + session -->
|
||||||
<template v-if="mode === 'worktree'">
|
<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 />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('worktrees.baseRefLabel') }}
|
||||||
|
<input v-model="baseRef" class="input font-mono" :list="datalistId" :placeholder="t('worktrees.baseRefPlaceholder')" />
|
||||||
|
<datalist :id="datalistId">
|
||||||
|
<option v-for="b in baseBranchOptions" :key="b" :value="b" />
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
{{ t('worktrees.start') }}
|
{{ t('worktrees.start') }}
|
||||||
<select v-model="startSession" class="input">
|
<select v-model="startSession" class="input">
|
||||||
@@ -41,7 +48,6 @@
|
|||||||
<option value="bash">bash</option>
|
<option value="bash">bash</option>
|
||||||
</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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- mode branche principale : option git (branche actuelle ou nouvelle branche) + commande -->
|
<!-- mode branche principale : option git (branche actuelle ou nouvelle branche) + commande -->
|
||||||
@@ -81,10 +87,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Eye, EyeOff, Plus, Scissors, Trash2 } from '@lucide/vue';
|
import { Eye, EyeOff, Plus, Scissors, Trash2 } from '@lucide/vue';
|
||||||
import type { RepoSummary } from '@arboretum/shared';
|
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import { useWorktreeView } from '../composables/useWorktreeView';
|
import { useWorktreeView } from '../composables/useWorktreeView';
|
||||||
import { useToastsStore } from '../stores/toasts';
|
import { useToastsStore } from '../stores/toasts';
|
||||||
@@ -103,13 +109,30 @@ 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 mode = ref<'worktree' | 'main'>('worktree');
|
||||||
const branch = ref('');
|
const branch = ref('');
|
||||||
const newBranch = ref(true);
|
const baseRef = ref('');
|
||||||
const startSession = ref<'claude' | 'bash' | null>(null);
|
const startSession = ref<'claude' | 'bash' | null>(null);
|
||||||
const mainBranchMode = ref<'current' | 'new'>('current');
|
const mainBranchMode = ref<'current' | 'new'>('current');
|
||||||
const mainCommand = ref<'claude' | 'bash'>('claude');
|
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);
|
||||||
|
|
||||||
|
// Sélecteur de branche de base (chargé paresseusement à l'ouverture du formulaire).
|
||||||
|
const branches = ref<RepoBranchesResponse | null>(null);
|
||||||
|
const datalistId = computed(() => `wt-base-branches-${props.repo.id}`);
|
||||||
|
const baseBranchOptions = computed<string[]>(() => {
|
||||||
|
const b = branches.value;
|
||||||
|
if (!b) return [];
|
||||||
|
return Array.from(new Set([...(b.default ? [b.default] : []), ...b.local, ...b.remote]));
|
||||||
|
});
|
||||||
|
watch(creating, async (open) => {
|
||||||
|
if (!open || branches.value) return;
|
||||||
|
try {
|
||||||
|
branches.value = await store.fetchBranches(props.repo.id);
|
||||||
|
} catch {
|
||||||
|
/* le sélecteur de base est facultatif */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// worktree : branche obligatoire ; branche principale + nouvelle branche : branche obligatoire ; sinon OK.
|
// worktree : branche obligatoire ; branche principale + nouvelle branche : branche obligatoire ; sinon OK.
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
|
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
|
||||||
@@ -120,7 +143,12 @@ async function onCreate(): Promise<void> {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
if (mode.value === 'worktree') {
|
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(),
|
||||||
|
mode: 'auto', // détecte créer / checkout / suivi remote
|
||||||
|
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
|
||||||
|
startSession: startSession.value,
|
||||||
|
});
|
||||||
toasts.success(t('toast.worktreeCreated'));
|
toasts.success(t('toast.worktreeCreated'));
|
||||||
} else {
|
} else {
|
||||||
// bosser sur la branche principale : session dans le checkout principal, avec création de branche optionnelle.
|
// bosser sur la branche principale : session dans le checkout principal, avec création de branche optionnelle.
|
||||||
@@ -131,6 +159,7 @@ async function onCreate(): Promise<void> {
|
|||||||
toasts.success(t('toast.sessionCreated'));
|
toasts.success(t('toast.sessionCreated'));
|
||||||
}
|
}
|
||||||
branch.value = '';
|
branch.value = '';
|
||||||
|
baseRef.value = '';
|
||||||
creating.value = false;
|
creating.value = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err);
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
{{ t('groups.spanBadge', spannedCount) }}
|
{{ t('groups.spanBadge', spannedCount) }}
|
||||||
</span>
|
</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>
|
||||||
|
<button
|
||||||
|
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
|
||||||
|
:aria-label="maximized ? t('terminal.restore') : t('terminal.maximize')"
|
||||||
|
:title="maximized ? t('terminal.restore') : t('terminal.maximize')"
|
||||||
|
@click="emit('toggle-maximize')"
|
||||||
|
>
|
||||||
|
<component :is="maximized ? Minimize2 : Maximize2" class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
||||||
<div class="min-h-0 flex-1">
|
<div class="min-h-0 flex-1">
|
||||||
@@ -23,13 +31,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Maximize2, Minimize2 } from '@lucide/vue';
|
||||||
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';
|
||||||
import DialogPrompt from './DialogPrompt.vue';
|
import DialogPrompt from './DialogPrompt.vue';
|
||||||
import TerminalView from './TerminalView.vue';
|
import TerminalView from './TerminalView.vue';
|
||||||
|
|
||||||
const props = defineProps<{ session: SessionSummary }>();
|
const props = defineProps<{ session: SessionSummary; maximized?: boolean }>();
|
||||||
|
const emit = defineEmits<{ 'toggle-maximize': [] }>();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,24 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<p v-if="sessions.length === 0" class="text-sm text-zinc-500">{{ t('groups.gridEmpty') }}</p>
|
<p v-if="sessions.length === 0" class="text-sm text-zinc-500">{{ t('groups.gridEmpty') }}</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p v-if="sessions.length > MAX_CELLS" class="text-xs text-amber-400">
|
<p v-if="!activeMax && sessions.length > MAX_CELLS" class="text-xs text-amber-400">
|
||||||
{{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }}
|
{{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }}
|
||||||
</p>
|
</p>
|
||||||
<!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux.
|
<!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux.
|
||||||
Sur mobile la grille retombe à une colonne (empilement). Hauteur relative au viewport
|
Sur mobile la grille retombe à une colonne (empilement). Hauteur relative au viewport
|
||||||
(dvh) : terminaux grands sur PC, exploitables sur mobile ; xterm re-fit via ResizeObserver.
|
(dvh) : terminaux grands sur PC, exploitables sur mobile ; xterm re-fit via ResizeObserver.
|
||||||
Plancher min-h-80 pour les fenêtres courtes. -->
|
Plancher min-h-80 pour les fenêtres courtes. -->
|
||||||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
<!-- une cellule maximisée → plein cadre ; sinon la grille responsive -->
|
||||||
|
<div v-if="activeMax" class="grid grid-cols-1">
|
||||||
|
<TerminalCell :session="activeMax" maximized class="h-[calc(100dvh-8rem)] min-h-80" @toggle-maximize="toggleMax(activeMax.id)" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
<TerminalCell
|
<TerminalCell
|
||||||
v-for="s in shown"
|
v-for="s in shown"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
:session="s"
|
:session="s"
|
||||||
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
|
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
|
||||||
|
@toggle-maximize="toggleMax(s.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,7 +27,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
import TerminalCell from './TerminalCell.vue';
|
import TerminalCell from './TerminalCell.vue';
|
||||||
@@ -32,4 +37,14 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const MAX_CELLS = 6;
|
const MAX_CELLS = 6;
|
||||||
const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
|
const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
|
||||||
|
|
||||||
|
// Maximisation d'une cellule au choix (état purement présentation, local à la grille). Si la session
|
||||||
|
// maximisée disparaît (terminée), on retombe automatiquement sur la grille.
|
||||||
|
const maximizedId = ref<string | null>(null);
|
||||||
|
function toggleMax(id: string): void {
|
||||||
|
maximizedId.value = maximizedId.value === id ? null : id;
|
||||||
|
}
|
||||||
|
const activeMax = computed<SessionSummary | null>(() =>
|
||||||
|
maximizedId.value ? (shown.value.find((s) => s.id === maximizedId.value) ?? null) : null,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative min-h-0 bg-[#09090b]">
|
<div class="relative h-full min-h-0 bg-[#09090b]">
|
||||||
<div ref="container" class="h-full w-full" />
|
<div ref="container" class="h-full w-full" />
|
||||||
<div
|
<div
|
||||||
v-if="!controlling && !ended"
|
v-if="!controlling && !ended"
|
||||||
@@ -71,6 +71,19 @@ onMounted(async () => {
|
|||||||
fit.fit();
|
fit.fit();
|
||||||
const activeTerm = term;
|
const activeTerm = term;
|
||||||
|
|
||||||
|
// Re-mesure après stabilisation du layout flex : au `onMounted`, le conteneur peut ne pas avoir sa
|
||||||
|
// taille finale (la hauteur flex se résout après le 1er paint) → un seul `fit()` donne trop peu de
|
||||||
|
// lignes et déforme le TUI. On refait donc le calcul en `requestAnimationFrame`, puis on resynchronise
|
||||||
|
// les dimensions PTY. Sert aussi après un resync (reconnexion) où l'écran est remis à plat.
|
||||||
|
const refit = (): void => {
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
} catch {
|
||||||
|
/* conteneur sans dimension (démontage en cours) : ignoré */
|
||||||
|
}
|
||||||
|
attachment?.resize(activeTerm.cols, activeTerm.rows);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
attachment = await wsClient.attach({
|
attachment = await wsClient.attach({
|
||||||
sessionId: props.sessionId,
|
sessionId: props.sessionId,
|
||||||
@@ -79,7 +92,10 @@ onMounted(async () => {
|
|||||||
rows: activeTerm.rows,
|
rows: activeTerm.rows,
|
||||||
sink: {
|
sink: {
|
||||||
write: (data, callback) => activeTerm.write(data, callback),
|
write: (data, callback) => activeTerm.write(data, callback),
|
||||||
reset: () => activeTerm.reset(),
|
reset: () => {
|
||||||
|
activeTerm.reset();
|
||||||
|
requestAnimationFrame(refit);
|
||||||
|
},
|
||||||
onDetached: (reason) => {
|
onDetached: (reason) => {
|
||||||
if (reason === 'session_exit' || reason === 'replaced') ended.value = true;
|
if (reason === 'session_exit' || reason === 'replaced') ended.value = true;
|
||||||
},
|
},
|
||||||
@@ -99,12 +115,10 @@ onMounted(async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controlling.value = attachment.controlling;
|
controlling.value = attachment.controlling;
|
||||||
|
requestAnimationFrame(refit); // recale la taille une fois le layout fullbleed stabilisé
|
||||||
|
|
||||||
activeTerm.onData((data) => attachment?.sendStdin(data));
|
activeTerm.onData((data) => attachment?.sendStdin(data));
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(refit);
|
||||||
fit.fit();
|
|
||||||
attachment?.resize(activeTerm.cols, activeTerm.rows);
|
|
||||||
});
|
|
||||||
resizeObserver.observe(container.value);
|
resizeObserver.observe(container.value);
|
||||||
if (props.mode === 'interactive') activeTerm.focus();
|
if (props.mode === 'interactive') activeTerm.focus();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,6 +62,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- actions git : commit / push / promotion en principal (sans ouvrir le terminal) -->
|
||||||
|
<div v-if="canPush || worktree.git.dirtyCount || canPromote" class="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<form v-if="committingOpen" class="flex flex-wrap items-center gap-1" @submit.prevent="onCommit">
|
||||||
|
<input v-model="commitMessage" class="input w-44 text-xs" :placeholder="t('worktrees.commitPlaceholder')" />
|
||||||
|
<button type="submit" class="btn-primary text-xs" :disabled="!!gitBusy || commitMessage.trim() === ''">
|
||||||
|
{{ gitBusy === 'commit' ? t('worktrees.committing') : t('worktrees.commit') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn text-xs" @click="committingOpen = false">{{ t('common.cancel') }}</button>
|
||||||
|
</form>
|
||||||
|
<button v-else-if="worktree.git.dirtyCount" class="btn text-xs" :disabled="!!gitBusy" @click="committingOpen = true">
|
||||||
|
{{ t('worktrees.commit') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button v-if="canPush" class="btn text-xs" :disabled="!!gitBusy" @click="onPush">
|
||||||
|
{{ gitBusy === 'push' ? t('worktrees.pushing') : t('worktrees.push') }}<span v-if="worktree.git.ahead"> ↑{{ worktree.git.ahead }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button v-if="canPromote && !promoteConfirming" class="btn text-xs" :disabled="!!gitBusy" @click="promoteConfirming = true">
|
||||||
|
{{ t('worktrees.promote') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span v-if="gitError" class="text-xs text-amber-400">{{ gitError }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- confirmation explicite de la promotion (action destructive : ce worktree sera supprimé) -->
|
||||||
|
<div v-if="promoteConfirming" class="mt-1 rounded border border-amber-700/40 bg-amber-950/20 p-2 text-xs text-amber-200">
|
||||||
|
{{ promoteConfirmText }}
|
||||||
|
<div class="mt-1 flex items-center gap-2">
|
||||||
|
<button class="btn-primary text-xs" :disabled="!!gitBusy" @click="onPromote">
|
||||||
|
{{ gitBusy === 'promote' ? t('worktrees.promoting') : forcePromoteNeeded ? t('worktrees.forcePromote') : t('worktrees.promote') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn text-xs" @click="cancelPromote">{{ t('common.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- réponse rapide aux dialogues des sessions corrélées en attente (sans ouvrir le terminal) -->
|
<!-- réponse rapide aux dialogues des sessions corrélées en attente (sans ouvrir le terminal) -->
|
||||||
<DialogPrompt v-for="ws in waitingSessions" :key="`dlg-${ws.id}`" :session="ws" class="mt-2" />
|
<DialogPrompt v-for="ws in waitingSessions" :key="`dlg-${ws.id}`" :session="ws" class="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
@@ -135,6 +170,73 @@ 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)}` : '—'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- actions git : commit / push / promotion en principal ---
|
||||||
|
const gitBusy = ref<null | 'commit' | 'push' | 'promote'>(null);
|
||||||
|
const gitError = ref<string | null>(null);
|
||||||
|
const committingOpen = ref(false);
|
||||||
|
const commitMessage = ref('');
|
||||||
|
const promoteConfirming = ref(false);
|
||||||
|
const forcePromoteNeeded = ref(false);
|
||||||
|
|
||||||
|
const canPush = computed(() => !!props.worktree.branch && !props.worktree.detached);
|
||||||
|
const canPromote = computed(() => !props.worktree.isMain && !!props.worktree.branch && !props.worktree.detached);
|
||||||
|
const promoteConfirmText = computed(() => t('worktrees.promoteConfirm', { branch: props.worktree.branch ?? '' }));
|
||||||
|
|
||||||
|
function gitMsg(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
function cancelPromote(): void {
|
||||||
|
promoteConfirming.value = false;
|
||||||
|
forcePromoteNeeded.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCommit(): Promise<void> {
|
||||||
|
if (gitBusy.value || commitMessage.value.trim() === '') return;
|
||||||
|
gitBusy.value = 'commit';
|
||||||
|
gitError.value = null;
|
||||||
|
try {
|
||||||
|
await store.commitWorktree(props.worktree.repoId, props.worktree.path, commitMessage.value.trim());
|
||||||
|
committingOpen.value = false;
|
||||||
|
commitMessage.value = '';
|
||||||
|
} catch (err) {
|
||||||
|
gitError.value = gitMsg(err);
|
||||||
|
} finally {
|
||||||
|
gitBusy.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPush(): Promise<void> {
|
||||||
|
if (gitBusy.value) return;
|
||||||
|
gitBusy.value = 'push';
|
||||||
|
gitError.value = null;
|
||||||
|
try {
|
||||||
|
await store.pushWorktree(props.worktree.repoId, props.worktree.path);
|
||||||
|
} catch (err) {
|
||||||
|
gitError.value = gitMsg(err);
|
||||||
|
} finally {
|
||||||
|
gitBusy.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPromote(): Promise<void> {
|
||||||
|
if (gitBusy.value) return;
|
||||||
|
gitBusy.value = 'promote';
|
||||||
|
gitError.value = null;
|
||||||
|
try {
|
||||||
|
await store.promoteWorktree(props.worktree.repoId, props.worktree.path, forcePromoteNeeded.value);
|
||||||
|
// succès : la branche est passée en principal et ce worktree est supprimé → la carte se démonte.
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
forcePromoteNeeded.value = true; // arbre modifié → proposer la promotion forcée
|
||||||
|
gitError.value = err.message;
|
||||||
|
} else {
|
||||||
|
gitError.value = gitMsg(err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
gitBusy.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function reset(): void {
|
function reset(): void {
|
||||||
confirming.value = false;
|
confirming.value = false;
|
||||||
forceNeeded.value = false;
|
forceNeeded.value = false;
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
<div class="flex min-h-0 w-full flex-1 overflow-hidden">
|
<div class="flex min-h-0 w-full flex-1 overflow-hidden">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
<main v-if="fullbleed" class="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">
|
<!-- fullbleed (terminal) : pas de tabbar mobile ni de padding bas → terminal réellement plein écran
|
||||||
|
(la navigation reste accessible via le lien « ← retour » du header de la vue). -->
|
||||||
|
<main v-if="fullbleed" class="flex min-h-0 flex-1 flex-col">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<main v-else class="flex-1 overflow-y-auto pb-14 md:pb-0">
|
<main v-else class="flex-1 overflow-y-auto pb-14 md:pb-0">
|
||||||
@@ -10,7 +12,7 @@
|
|||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<MobileTabBar />
|
<MobileTabBar v-if="!fullbleed" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ export default {
|
|||||||
live: 'live',
|
live: 'live',
|
||||||
resumable: 'resumable',
|
resumable: 'resumable',
|
||||||
sourceDiscovered: 'discovered',
|
sourceDiscovered: 'discovered',
|
||||||
|
hide: 'Hide',
|
||||||
|
unhide: 'Unhide',
|
||||||
|
hiddenBadge: 'hidden',
|
||||||
|
hideDiscovered: 'Hide external history',
|
||||||
|
showHidden: 'Show hidden',
|
||||||
|
hideHidden: 'Hide hidden',
|
||||||
clients: 'no client | 1 client | {n} clients',
|
clients: 'no client | 1 client | {n} clients',
|
||||||
exitCode: 'exit code {code}',
|
exitCode: 'exit code {code}',
|
||||||
statusLabel: {
|
statusLabel: {
|
||||||
@@ -107,14 +113,15 @@ export default {
|
|||||||
modeMain: 'Main checkouts',
|
modeMain: 'Main checkouts',
|
||||||
branchLabel: 'Branch name',
|
branchLabel: 'Branch name',
|
||||||
branchPlaceholder: 'feature/…',
|
branchPlaceholder: 'feature/…',
|
||||||
newBranch: 'create branch',
|
branchHint: 'Created where missing, reused where it already exists in the repo.',
|
||||||
baseRefLabel: 'Base ref (optional)',
|
baseRefLabel: 'Base branch (optional)',
|
||||||
|
baseRefPlaceholder: "repo's default branch",
|
||||||
commandLabel: 'Session',
|
commandLabel: 'Session',
|
||||||
reposLabel: 'Repos in this session',
|
reposLabel: 'Repos in this session',
|
||||||
launch: 'Launch session',
|
launch: 'Launch session',
|
||||||
launching: 'Launching…',
|
launching: 'Launching…',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
ok: 'worktree created',
|
ok: 'worktree ready',
|
||||||
error: 'failed',
|
error: 'failed',
|
||||||
skippedLabel: 'skipped',
|
skippedLabel: 'skipped',
|
||||||
done: 'Group session started.',
|
done: 'Group session started.',
|
||||||
@@ -140,6 +147,8 @@ export default {
|
|||||||
creating: 'Creating…',
|
creating: 'Creating…',
|
||||||
branch: 'Branch',
|
branch: 'Branch',
|
||||||
newBranch: 'create branch',
|
newBranch: 'create branch',
|
||||||
|
baseRefLabel: 'Base branch',
|
||||||
|
baseRefPlaceholder: 'default branch',
|
||||||
start: 'Start',
|
start: 'Start',
|
||||||
starting: 'Starting…',
|
starting: 'Starting…',
|
||||||
startNone: 'no session',
|
startNone: 'no session',
|
||||||
@@ -155,6 +164,16 @@ export default {
|
|||||||
confirmDelete: 'Confirm delete',
|
confirmDelete: 'Confirm delete',
|
||||||
forceDelete: 'Force delete',
|
forceDelete: 'Force delete',
|
||||||
prune: 'Prune',
|
prune: 'Prune',
|
||||||
|
commit: 'Commit',
|
||||||
|
commitPlaceholder: 'Commit message',
|
||||||
|
committing: 'Committing…',
|
||||||
|
push: 'Push',
|
||||||
|
pushing: 'Pushing…',
|
||||||
|
promote: 'Make main',
|
||||||
|
promoting: 'Promoting…',
|
||||||
|
promoteConfirm:
|
||||||
|
'Branch “{branch}” will become the repository’s main checkout; this worktree will be removed (the previous main branch is kept). Continue?',
|
||||||
|
forcePromote: 'Force (dirty tree)',
|
||||||
main: 'main',
|
main: 'main',
|
||||||
detached: 'detached',
|
detached: 'detached',
|
||||||
locked: 'locked',
|
locked: 'locked',
|
||||||
@@ -168,6 +187,10 @@ export default {
|
|||||||
sessionEnded: 'Session ended',
|
sessionEnded: 'Session ended',
|
||||||
back: 'Sessions',
|
back: 'Sessions',
|
||||||
notAttachable: 'This session runs outside Arboretum — fork it to interact, or resume it once it has stopped.',
|
notAttachable: 'This session runs outside Arboretum — fork it to interact, or resume it once it has stopped.',
|
||||||
|
fullscreen: 'Fullscreen',
|
||||||
|
exitFullscreen: 'Exit fullscreen',
|
||||||
|
maximize: 'Maximize',
|
||||||
|
restore: 'Restore',
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
enable: 'Enable notifications',
|
enable: 'Enable notifications',
|
||||||
@@ -298,6 +321,9 @@ export default {
|
|||||||
sessionKilled: 'Session killed',
|
sessionKilled: 'Session killed',
|
||||||
sessionResumed: 'Session resumed',
|
sessionResumed: 'Session resumed',
|
||||||
sessionForked: 'Session forked',
|
sessionForked: 'Session forked',
|
||||||
|
sessionHidden: 'Session hidden',
|
||||||
|
sessionUnhidden: 'Session shown',
|
||||||
|
sessionsHidden: 'no session hidden | 1 session hidden | {n} sessions hidden',
|
||||||
repoAdded: 'Repository added',
|
repoAdded: 'Repository added',
|
||||||
repoRemoved: 'Repository removed',
|
repoRemoved: 'Repository removed',
|
||||||
worktreeCreated: 'Worktree created',
|
worktreeCreated: 'Worktree created',
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ const fr: typeof en = {
|
|||||||
live: 'en cours',
|
live: 'en cours',
|
||||||
resumable: 'reprenable',
|
resumable: 'reprenable',
|
||||||
sourceDiscovered: 'découverte',
|
sourceDiscovered: 'découverte',
|
||||||
|
hide: 'Masquer',
|
||||||
|
unhide: 'Réafficher',
|
||||||
|
hiddenBadge: 'masquée',
|
||||||
|
hideDiscovered: 'Masquer l’historique externe',
|
||||||
|
showHidden: 'Afficher les masquées',
|
||||||
|
hideHidden: 'Masquer les masquées',
|
||||||
clients: 'aucun client | 1 client | {n} clients',
|
clients: 'aucun client | 1 client | {n} clients',
|
||||||
exitCode: 'code de sortie {code}',
|
exitCode: 'code de sortie {code}',
|
||||||
statusLabel: {
|
statusLabel: {
|
||||||
@@ -109,14 +115,15 @@ const fr: typeof en = {
|
|||||||
modeMain: 'Checkouts principaux',
|
modeMain: 'Checkouts principaux',
|
||||||
branchLabel: 'Nom de branche',
|
branchLabel: 'Nom de branche',
|
||||||
branchPlaceholder: 'feature/…',
|
branchPlaceholder: 'feature/…',
|
||||||
newBranch: 'créer la branche',
|
branchHint: 'Créée si absente, réutilisée si déjà présente dans le dépôt.',
|
||||||
baseRefLabel: 'Réf. de base (optionnel)',
|
baseRefLabel: 'Branche de base (optionnel)',
|
||||||
|
baseRefPlaceholder: 'branche par défaut du dépôt',
|
||||||
commandLabel: 'Session',
|
commandLabel: 'Session',
|
||||||
reposLabel: 'Dépôts de la session',
|
reposLabel: 'Dépôts de la session',
|
||||||
launch: 'Lancer la session',
|
launch: 'Lancer la session',
|
||||||
launching: 'Lancement…',
|
launching: 'Lancement…',
|
||||||
close: 'Fermer',
|
close: 'Fermer',
|
||||||
ok: 'worktree créé',
|
ok: 'worktree prêt',
|
||||||
error: 'échec',
|
error: 'échec',
|
||||||
skippedLabel: 'ignoré',
|
skippedLabel: 'ignoré',
|
||||||
done: 'Session de groupe démarrée.',
|
done: 'Session de groupe démarrée.',
|
||||||
@@ -142,6 +149,8 @@ const fr: typeof en = {
|
|||||||
creating: 'Création…',
|
creating: 'Création…',
|
||||||
branch: 'Branche',
|
branch: 'Branche',
|
||||||
newBranch: 'créer la branche',
|
newBranch: 'créer la branche',
|
||||||
|
baseRefLabel: 'Branche de base',
|
||||||
|
baseRefPlaceholder: 'branche par défaut',
|
||||||
start: 'Démarrer',
|
start: 'Démarrer',
|
||||||
starting: 'Démarrage…',
|
starting: 'Démarrage…',
|
||||||
startNone: 'aucune session',
|
startNone: 'aucune session',
|
||||||
@@ -157,6 +166,16 @@ const fr: typeof en = {
|
|||||||
confirmDelete: 'Confirmer',
|
confirmDelete: 'Confirmer',
|
||||||
forceDelete: 'Forcer la suppression',
|
forceDelete: 'Forcer la suppression',
|
||||||
prune: 'Élaguer',
|
prune: 'Élaguer',
|
||||||
|
commit: 'Commit',
|
||||||
|
commitPlaceholder: 'Message de commit',
|
||||||
|
committing: 'Commit…',
|
||||||
|
push: 'Push',
|
||||||
|
pushing: 'Push…',
|
||||||
|
promote: 'Passer en principal',
|
||||||
|
promoting: 'Promotion…',
|
||||||
|
promoteConfirm:
|
||||||
|
'La branche « {branch} » deviendra le checkout principal du dépôt ; ce worktree sera supprimé (l’ancienne branche principale est conservée). Continuer ?',
|
||||||
|
forcePromote: 'Forcer (arbre modifié)',
|
||||||
main: 'principal',
|
main: 'principal',
|
||||||
detached: 'détaché',
|
detached: 'détaché',
|
||||||
locked: 'verrouillé',
|
locked: 'verrouillé',
|
||||||
@@ -171,6 +190,10 @@ const fr: typeof en = {
|
|||||||
back: 'Sessions',
|
back: 'Sessions',
|
||||||
notAttachable:
|
notAttachable:
|
||||||
'Cette session tourne hors d’Arboretum — dupliquez-la pour interagir, ou reprenez-la une fois arrêtée.',
|
'Cette session tourne hors d’Arboretum — dupliquez-la pour interagir, ou reprenez-la une fois arrêtée.',
|
||||||
|
fullscreen: 'Plein écran',
|
||||||
|
exitFullscreen: 'Quitter le plein écran',
|
||||||
|
maximize: 'Agrandir',
|
||||||
|
restore: 'Réduire',
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
enable: 'Activer les notifications',
|
enable: 'Activer les notifications',
|
||||||
@@ -301,6 +324,9 @@ const fr: typeof en = {
|
|||||||
sessionKilled: 'Session tuée',
|
sessionKilled: 'Session tuée',
|
||||||
sessionResumed: 'Session reprise',
|
sessionResumed: 'Session reprise',
|
||||||
sessionForked: 'Session dupliquée',
|
sessionForked: 'Session dupliquée',
|
||||||
|
sessionHidden: 'Session masquée',
|
||||||
|
sessionUnhidden: 'Session réaffichée',
|
||||||
|
sessionsHidden: 'aucune session masquée | 1 session masquée | {n} sessions masquées',
|
||||||
repoAdded: 'Dépôt ajouté',
|
repoAdded: 'Dépôt ajouté',
|
||||||
repoRemoved: 'Dépôt retiré',
|
repoRemoved: 'Dépôt retiré',
|
||||||
worktreeCreated: 'Worktree créé',
|
worktreeCreated: 'Worktree créé',
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export interface CrossRepoResult {
|
|||||||
/** Options de lancement d'une session de groupe (P6). */
|
/** Options de lancement d'une session de groupe (P6). */
|
||||||
export interface GroupFeatureOptions {
|
export interface GroupFeatureOptions {
|
||||||
command: 'claude' | 'bash';
|
command: 'claude' | 'bash';
|
||||||
/** présent → crée le worktree de cette branche dans chaque repo puis relie ; absent → checkouts principaux. */
|
/** présent → crée/réutilise le worktree de cette branche dans chaque repo puis relie ; absent → checkouts principaux. */
|
||||||
branch?: string;
|
branch?: string;
|
||||||
newBranch?: boolean;
|
/** branche de base d'une branche créée (défaut serveur : branche par défaut du dépôt). */
|
||||||
baseRef?: string;
|
baseRef?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ export const useGroupsStore = defineStore('groups', () => {
|
|||||||
try {
|
try {
|
||||||
const res = await wt.createWorktree(repoId, {
|
const res = await wt.createWorktree(repoId, {
|
||||||
branch,
|
branch,
|
||||||
newBranch: opts.newBranch ?? true,
|
mode: 'auto', // détecte créer / checkout / suivi remote selon l'existence par dépôt
|
||||||
...(opts.baseRef ? { baseRef: opts.baseRef } : {}),
|
...(opts.baseRef ? { baseRef: opts.baseRef } : {}),
|
||||||
startSession: null,
|
startSession: null,
|
||||||
});
|
});
|
||||||
@@ -176,6 +176,11 @@ export const useGroupsStore = defineStore('groups', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Mode feature : si AUCUN worktree n'a pu être créé/résolu, ne pas tenter la session (elle
|
||||||
|
// échouerait en « no resolvable worktree » et masquerait les vraies erreurs par dépôt).
|
||||||
|
if (opts.branch && worktreeResults.length > 0 && !worktreeResults.some((r) => r.status === 'ok')) {
|
||||||
|
return { worktreeResults, session: null, dirs: [], skipped: [] };
|
||||||
|
}
|
||||||
const res = await createGroupSession(groupId, {
|
const res = await createGroupSession(groupId, {
|
||||||
command: opts.command,
|
command: opts.command,
|
||||||
...(opts.branch ? { branch: opts.branch } : {}),
|
...(opts.branch ? { branch: opts.branch } : {}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import type { CreateSessionRequest, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared';
|
import type { 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';
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
const sessions = ref<SessionSummary[]>([]);
|
const sessions = ref<SessionSummary[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const loadError = ref<string | null>(null);
|
const loadError = ref<string | null>(null);
|
||||||
|
// false (défaut) : les sessions masquées sont exclues ; true : on les inclut (mode « afficher les masquées »).
|
||||||
|
const showHidden = ref(false);
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
// vivantes d'abord, puis par date de création décroissante (même ordre que le serveur)
|
// vivantes d'abord, puis par date de création décroissante (même ordre que le serveur)
|
||||||
@@ -15,7 +17,17 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
return [...list].sort((a, b) => Number(b.live) - Number(a.live) || b.createdAt.localeCompare(a.createdAt));
|
return [...list].sort((a, b) => Number(b.live) - Number(a.live) || b.createdAt.localeCompare(a.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remove(id: string): void {
|
||||||
|
const idx = sessions.value.findIndex((s) => s.id === id);
|
||||||
|
if (idx >= 0) sessions.value.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
function upsert(session: SessionSummary): void {
|
function upsert(session: SessionSummary): void {
|
||||||
|
// session masquée et mode « afficher les masquées » inactif : on la retire de la liste.
|
||||||
|
if (session.hidden && !showHidden.value) {
|
||||||
|
remove(session.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const idx = sessions.value.findIndex((s) => s.id === session.id);
|
const idx = sessions.value.findIndex((s) => s.id === session.id);
|
||||||
if (idx >= 0) sessions.value.splice(idx, 1, session);
|
if (idx >= 0) sessions.value.splice(idx, 1, session);
|
||||||
else sessions.value.push(session);
|
else sessions.value.push(session);
|
||||||
@@ -43,7 +55,8 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
loadError.value = null;
|
loadError.value = null;
|
||||||
try {
|
try {
|
||||||
const res = await api.get<SessionsListResponse>('/api/v1/sessions');
|
const url = showHidden.value ? '/api/v1/sessions?includeHidden=true' : '/api/v1/sessions';
|
||||||
|
const res = await api.get<SessionsListResponse>(url);
|
||||||
sessions.value = sorted(res.sessions);
|
sessions.value = sorted(res.sessions);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loadError.value = err instanceof Error ? err.message : String(err);
|
loadError.value = err instanceof Error ? err.message : String(err);
|
||||||
@@ -52,6 +65,31 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setShowHidden(value: boolean): Promise<void> {
|
||||||
|
showHidden.value = value;
|
||||||
|
await fetchSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Masque une session découverte (retrait optimiste si on n'affiche pas les masquées).
|
||||||
|
async function hideSession(id: string): Promise<void> {
|
||||||
|
await api.post<{ ok: true }>(`/api/v1/sessions/${id}/hide`);
|
||||||
|
if (!showHidden.value) remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ré-affiche une session masquée.
|
||||||
|
async function unhideSession(id: string): Promise<void> {
|
||||||
|
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}/hide`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Masque tout l'historique de sessions externes visible. Renvoie le nombre masqué.
|
||||||
|
async function hideDiscovered(): Promise<number> {
|
||||||
|
const res = await api.post<HideDiscoveredResponse>('/api/v1/sessions/hide-discovered');
|
||||||
|
if (!showHidden.value) {
|
||||||
|
sessions.value = sessions.value.filter((s) => !(s.source === 'discovered' && !s.hidden));
|
||||||
|
}
|
||||||
|
return res.hidden;
|
||||||
|
}
|
||||||
|
|
||||||
function startRealtime(): void {
|
function startRealtime(): void {
|
||||||
unsubscribe ??= wsClient.subscribeSessions(onEvent);
|
unsubscribe ??= wsClient.subscribeSessions(onEvent);
|
||||||
}
|
}
|
||||||
@@ -90,12 +128,17 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
sessions,
|
sessions,
|
||||||
loading,
|
loading,
|
||||||
loadError,
|
loadError,
|
||||||
|
showHidden,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
|
setShowHidden,
|
||||||
startRealtime,
|
startRealtime,
|
||||||
stopRealtime,
|
stopRealtime,
|
||||||
createSession,
|
createSession,
|
||||||
killSession,
|
killSession,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
forkSession,
|
forkSession,
|
||||||
|
hideSession,
|
||||||
|
unhideSession,
|
||||||
|
hideDiscovered,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import type {
|
|||||||
CreateWorktreeRequest,
|
CreateWorktreeRequest,
|
||||||
CreateWorktreeResponse,
|
CreateWorktreeResponse,
|
||||||
DiscoverReposResponse,
|
DiscoverReposResponse,
|
||||||
|
RepoBranchesResponse,
|
||||||
RepoResponse,
|
RepoResponse,
|
||||||
ReposListResponse,
|
ReposListResponse,
|
||||||
RepoSummary,
|
RepoSummary,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
SessionSummary,
|
SessionSummary,
|
||||||
StartRepoSessionRequest,
|
StartRepoSessionRequest,
|
||||||
|
WorktreeResponse,
|
||||||
WorktreeSummary,
|
WorktreeSummary,
|
||||||
WorktreesListResponse,
|
WorktreesListResponse,
|
||||||
} from '@arboretum/shared';
|
} from '@arboretum/shared';
|
||||||
@@ -127,6 +129,32 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
|||||||
await refreshRepoWorktrees(repoId);
|
await refreshRepoWorktrees(repoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Branches du repo (locales/remote + défaut) pour le sélecteur de branche de base. */
|
||||||
|
async function fetchBranches(repoId: string): Promise<RepoBranchesResponse> {
|
||||||
|
return api.get<RepoBranchesResponse>(`/api/v1/repos/${repoId}/branches`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit (add -A + commit) dans un worktree ; le worktree mis à jour arrive aussi par WS. */
|
||||||
|
async function commitWorktree(repoId: string, path: string, message: string): Promise<WorktreeSummary> {
|
||||||
|
const res = await api.post<WorktreeResponse>(`/api/v1/repos/${repoId}/worktrees/commit`, { path, message });
|
||||||
|
upsertWorktree(res.worktree);
|
||||||
|
return res.worktree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push de la branche d'un worktree (upstream auto si absent). */
|
||||||
|
async function pushWorktree(repoId: string, path: string): Promise<WorktreeSummary> {
|
||||||
|
const res = await api.post<WorktreeResponse>(`/api/v1/repos/${repoId}/worktrees/push`, { path });
|
||||||
|
upsertWorktree(res.worktree);
|
||||||
|
return res.worktree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** « Passer en principal » : la branche du worktree devient le checkout principal (worktree supprimé). */
|
||||||
|
async function promoteWorktree(repoId: string, path: string, force = false): Promise<void> {
|
||||||
|
await api.post<{ worktree: WorktreeSummary | null }>(`/api/v1/repos/${repoId}/worktrees/promote`, { path, force });
|
||||||
|
removeWorktreeLocal(path);
|
||||||
|
await refreshRepoWorktrees(repoId);
|
||||||
|
}
|
||||||
|
|
||||||
function startRealtime(): void {
|
function startRealtime(): void {
|
||||||
unsubscribe ??= wsClient.subscribeWorktrees(onEvent);
|
unsubscribe ??= wsClient.subscribeWorktrees(onEvent);
|
||||||
}
|
}
|
||||||
@@ -151,6 +179,10 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
|||||||
startMainSession,
|
startMainSession,
|
||||||
deleteWorktree,
|
deleteWorktree,
|
||||||
prune,
|
prune,
|
||||||
|
fetchBranches,
|
||||||
|
commitWorktree,
|
||||||
|
pushWorktree,
|
||||||
|
promoteWorktree,
|
||||||
startRealtime,
|
startRealtime,
|
||||||
stopRealtime,
|
stopRealtime,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-0 flex-col">
|
<div ref="root" class="flex min-h-0 flex-1 flex-col bg-[#09090b]">
|
||||||
<header class="flex items-center gap-3 border-b border-zinc-800 bg-zinc-900/60 px-3 py-1.5">
|
<header class="flex items-center gap-3 border-b border-zinc-800 bg-zinc-900/60 px-3 py-1.5">
|
||||||
<RouterLink :to="{ name: 'sessions' }" class="text-sm text-zinc-400 transition-colors hover:text-zinc-200">
|
<RouterLink :to="{ name: 'sessions' }" class="text-sm text-zinc-400 transition-colors hover:text-zinc-200">
|
||||||
← {{ t('terminal.back') }}
|
← {{ t('terminal.back') }}
|
||||||
@@ -8,6 +8,15 @@
|
|||||||
<span class="truncate font-mono text-xs text-zinc-500" :title="session?.cwd ?? sessionId">
|
<span class="truncate font-mono text-xs text-zinc-500" :title="session?.cwd ?? sessionId">
|
||||||
{{ session?.cwd ?? sessionId }}
|
{{ session?.cwd ?? sessionId }}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-auto shrink-0 rounded p-1 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
|
||||||
|
:title="t(isFullscreen ? 'terminal.exitFullscreen' : 'terminal.fullscreen')"
|
||||||
|
:aria-label="t(isFullscreen ? 'terminal.exitFullscreen' : 'terminal.fullscreen')"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<component :is="isFullscreen ? Minimize : Maximize" class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<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" />
|
||||||
@@ -19,9 +28,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Maximize, Minimize } from '@lucide/vue';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import TerminalView from '../components/TerminalView.vue';
|
import TerminalView from '../components/TerminalView.vue';
|
||||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||||
@@ -36,8 +46,25 @@ const session = computed(() => store.sessions.find((s) => s.id === sessionId.val
|
|||||||
// défaut true : une navigation directe (liste pas encore chargée) tente l'attache d'une session managée.
|
// défaut true : une navigation directe (liste pas encore chargée) tente l'attache d'une session managée.
|
||||||
const attachable = computed(() => session.value?.attachable ?? true);
|
const attachable = computed(() => session.value?.attachable ?? true);
|
||||||
|
|
||||||
|
// Vrai plein écran (API Fullscreen) — surtout utile sur mobile pour récupérer la barre d'URL.
|
||||||
|
// On synchronise `isFullscreen` sur l'événement natif : l'utilisateur peut sortir via Échap.
|
||||||
|
const root = useTemplateRef<HTMLDivElement>('root');
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
function syncFullscreen(): void {
|
||||||
|
isFullscreen.value = document.fullscreenElement === root.value;
|
||||||
|
}
|
||||||
|
function toggleFullscreen(): void {
|
||||||
|
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
|
||||||
|
else void root.value?.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
document.addEventListener('fullscreenchange', syncFullscreen);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('fullscreenchange', syncFullscreen);
|
||||||
|
if (document.fullscreenElement === root.value) void document.exitFullscreen().catch(() => {});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<PageHeader :title="t('nav.sessions')">
|
<PageHeader :title="t('nav.sessions')">
|
||||||
|
<BaseButton v-if="hasDiscovered" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingAll" @click="onHideDiscovered">
|
||||||
|
{{ t('sessions.hideDiscovered') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton variant="ghost" size="sm" :icon="store.showHidden ? EyeOff : Eye" @click="onToggleHidden">
|
||||||
|
{{ t(store.showHidden ? 'sessions.hideHidden' : 'sessions.showHidden') }}
|
||||||
|
</BaseButton>
|
||||||
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="store.fetchSessions()">
|
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="store.fetchSessions()">
|
||||||
{{ t('common.refresh') }}
|
{{ t('common.refresh') }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
@@ -43,6 +49,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
|
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
|
||||||
<BaseBadge v-if="s.source === 'discovered'" tone="sky">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
|
<BaseBadge v-if="s.source === 'discovered'" tone="sky">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
|
||||||
|
<BaseBadge v-if="s.hidden" tone="zinc">{{ t('sessions.hiddenBadge') }}</BaseBadge>
|
||||||
<SessionStateBadge :session="s" />
|
<SessionStateBadge :session="s" />
|
||||||
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,6 +80,9 @@
|
|||||||
<BaseButton variant="primary" size="sm" :icon="Play" :loading="actingId === s.id" @click="onResume(s.id)">{{ t('sessions.resume') }}</BaseButton>
|
<BaseButton variant="primary" size="sm" :icon="Play" :loading="actingId === s.id" @click="onResume(s.id)">{{ t('sessions.resume') }}</BaseButton>
|
||||||
<BaseButton size="sm" :icon="GitFork" :loading="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</BaseButton>
|
<BaseButton size="sm" :icon="GitFork" :loading="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- masquage : réafficher une session masquée, ou masquer une session découverte qui pollue la liste -->
|
||||||
|
<BaseButton v-if="s.hidden" variant="ghost" size="sm" :icon="Eye" :loading="hidingId === s.id" @click="onUnhide(s.id)">{{ t('sessions.unhide') }}</BaseButton>
|
||||||
|
<BaseButton v-else-if="s.source === 'discovered'" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingId === s.id" @click="onHide(s.id)">{{ t('sessions.hide') }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -88,11 +98,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
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, Trash2, GitFork, Play, Search } from '@lucide/vue';
|
import { RefreshCw, FolderOpen, 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';
|
||||||
@@ -130,6 +140,10 @@ const creating = ref(false);
|
|||||||
const killArmedId = ref<string | null>(null);
|
const killArmedId = ref<string | null>(null);
|
||||||
const killing = ref(false);
|
const killing = ref(false);
|
||||||
const actingId = ref<string | null>(null);
|
const actingId = ref<string | null>(null);
|
||||||
|
const hidingId = ref<string | null>(null);
|
||||||
|
const hidingAll = ref(false);
|
||||||
|
// au moins une session externe visible → propose le masquage de masse
|
||||||
|
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// navigation directe : garantit la liste même si l'AppShell n'a pas encore chargé.
|
// navigation directe : garantit la liste même si l'AppShell n'a pas encore chargé.
|
||||||
@@ -197,4 +211,48 @@ async function onConfirmKill(id: string): Promise<void> {
|
|||||||
killArmedId.value = null;
|
killArmedId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onHide(id: string): Promise<void> {
|
||||||
|
hidingId.value = id;
|
||||||
|
try {
|
||||||
|
await store.hideSession(id);
|
||||||
|
toasts.success(t('toast.sessionHidden'));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
} finally {
|
||||||
|
hidingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUnhide(id: string): Promise<void> {
|
||||||
|
hidingId.value = id;
|
||||||
|
try {
|
||||||
|
await store.unhideSession(id);
|
||||||
|
toasts.success(t('toast.sessionUnhidden'));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
} finally {
|
||||||
|
hidingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onHideDiscovered(): Promise<void> {
|
||||||
|
hidingAll.value = true;
|
||||||
|
try {
|
||||||
|
const n = await store.hideDiscovered();
|
||||||
|
toasts.success(t('toast.sessionsHidden', n));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
} finally {
|
||||||
|
hidingAll.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleHidden(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await store.setShowHidden(!store.showHidden);
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ const en: HelpSection[] = [
|
|||||||
title: 'Discovered sessions',
|
title: 'Discovered sessions',
|
||||||
body: 'Sessions you start in your own terminal are detected automatically and tagged as discovered. Observe them read-only, or resume/fork them once stopped.',
|
body: 'Sessions you start in your own terminal are detected automatically and tagged as discovered. Observe them read-only, or resume/fork them once stopped.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Hide noisy history',
|
||||||
|
body: 'Old sessions started outside Arboretum can pile up. Hide one from its row, or use “Hide external history” to clear them all in one click. Hidden sessions stay resumable and survive a re-scan — toggle “Show hidden” to bring them back.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fullscreen terminal',
|
||||||
|
body: 'The web terminal fills the whole screen, with the prompt pinned to the bottom and full scrollback above. Tap the fullscreen button in the header to go edge-to-edge — handy on phones.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Resume & fork',
|
title: 'Resume & fork',
|
||||||
body: 'Resume restarts a stopped session in its original working directory, with full scrollback replayed. Fork creates a fresh independent copy with the same command and cwd.',
|
body: 'Resume restarts a stopped session in its original working directory, with full scrollback replayed. Fork creates a fresh independent copy with the same command and cwd.',
|
||||||
@@ -238,6 +246,14 @@ const fr: HelpSection[] = [
|
|||||||
title: 'Sessions découvertes',
|
title: 'Sessions découvertes',
|
||||||
body: 'Les sessions lancées dans votre propre terminal sont détectées automatiquement et marquées « découvertes ». Observez-les en lecture seule, ou reprenez/forkez-les une fois arrêtées.',
|
body: 'Les sessions lancées dans votre propre terminal sont détectées automatiquement et marquées « découvertes ». Observez-les en lecture seule, ou reprenez/forkez-les une fois arrêtées.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Masquer l’historique',
|
||||||
|
body: 'Les anciennes sessions lancées hors d’Arboretum peuvent s’accumuler. Masquez-en une depuis sa ligne, ou utilisez « Masquer l’historique externe » pour toutes les retirer en un clic. Les sessions masquées restent reprenables et survivent à un nouveau scan — activez « Afficher les masquées » pour les retrouver.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Terminal plein écran',
|
||||||
|
body: 'Le terminal web occupe tout l’écran, avec l’invite ancrée en bas et tout l’historique défilable au-dessus. Touchez le bouton plein écran dans l’en-tête pour passer bord à bord — pratique sur téléphone.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Reprendre & forker',
|
title: 'Reprendre & forker',
|
||||||
body: 'Reprendre relance une session arrêtée dans son répertoire d’origine, avec tout l’historique rejoué. Forker crée une copie indépendante neuve avec la même commande et le même cwd.',
|
body: 'Reprendre relance une session arrêtée dans son répertoire d’origine, avec tout l’historique rejoué. Forker crée une copie indépendante neuve avec la même commande et le même cwd.',
|
||||||
|
|||||||
Reference in New Issue
Block a user