release: git-arboretum 3.3.0 (« Démarrer le projet » : lancement multi-terminaux), vscode 0.4.0, desktop 0.1.3
CI / Build & test (Node 22) (push) Successful in 10m17s
CI / Build & test (Node 24) (push) Successful in 10m13s
CI / No em/en dashes (push) Successful in 4s
Deploy site (production) / build-and-deploy (push) Successful in 24s
Release / Publish to Gitea npm registry (push) Successful in 10m17s
VSCode Release / Package VSIX (push) Successful in 9m39s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 15m41s
CI / Pack & boot smoke (Node 22) (push) Successful in 10m3s

« Démarrer le projet » : un repo définit une fois ses commandes de démarrage
(serveur de dev, API, base de données), un clic ouvre un terminal PTY par
commande dans le dock IDE.

Serveur (additif, PROTOCOL_VERSION inchangé) :
- LaunchCommand[] persistées sur repos.launch_commands (migration 13) ; champ additif SessionSummary.launchRunId.
- POST /repos/:id/launch : résolution du worktree côté serveur, cwd de commande borné (anti-traversal), commandIds outrepasse enabled.
- GET /repos/:id/launch/detect : détection package.json / Procfile / docker-compose.
- Shell de login interactif ($SHELL -l -i, charge le PATH nvm/asdf) + auto-type de la commande ; le shell survit à la commande (échec visible).

Web : LaunchProjectModal + actions (ProjectTreeNode, SessionsPanel, CommandPalette), stores sessions/worktrees, i18n EN/FR.

Alignement du reste du projet :
- Extension VS Code 0.4.0 : commande Start Project (repo/worktree), Stop Launch, badge « launch » dans l'arbre, méthode REST startLaunch.
- Site vitrine : 16e feature card (Rocket) + section showcase « Start the project » (mockup fidèle au modal), i18n EN/FR.
- Documentation : README (EN + FR), help-content (EN + FR), CHANGELOGs server + vscode.

Vérifié : 430 tests, typecheck, build (web + site + vscode), acceptance-p13 ALL GREEN, VSIX packagé, garde anti-tirets, vérif visuelle du site (thèmes clair et sombre).
This commit is contained in:
2026-07-21 13:54:16 +02:00
parent 7327407193
commit a7e04278fd
39 changed files with 1226 additions and 25 deletions
+10 -1
View File
@@ -53,7 +53,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
import { useI18n } from 'vue-i18n';
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus } from '@lucide/vue';
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus, Rocket } from '@lucide/vue';
import type { WorktreeSummary } from '@arboretum/shared';
import { useSessionsStore } from '../stores/sessions';
import { useWorktreesStore } from '../stores/worktrees';
@@ -63,6 +63,7 @@ import { useModalsStore } from '../stores/modals';
import { useCommandPalette } from '../composables/useCommandPalette';
import NewSessionModal from './ide/modals/NewSessionModal.vue';
import GroupCreateModal from './ide/modals/GroupCreateModal.vue';
import LaunchProjectModal from './ide/modals/LaunchProjectModal.vue';
interface PaletteItem {
id: string;
@@ -103,6 +104,14 @@ const items = computed<PaletteItem[]>(() => {
const out: PaletteItem[] = [];
for (const r of worktrees.repos) {
out.push({ id: `repo-${r.id}`, type: 'repo', label: r.label, sublabel: r.path, icon: GitBranch as Component, keywords: `${r.label} ${r.path}`.toLowerCase(), run: () => revealRepo(r.id) });
out.push({
id: `launch-${r.id}`,
type: 'action',
label: t('launch.startNamed', { name: r.label }),
icon: Rocket as Component,
keywords: `${t('launch.startProject')} ${r.label}`.toLowerCase(),
run: () => void modals.open(LaunchProjectModal, { repoId: r.id }),
});
}
for (const w of worktrees.worktrees) {
out.push({ id: `wt-${w.path}`, type: 'worktree', label: w.branch ?? basename(w.path), sublabel: w.path, icon: GitBranch as Component, keywords: `${w.branch ?? ''} ${w.path}`.toLowerCase(), run: () => revealWorktree(w) });
@@ -93,6 +93,7 @@ import {
Scissors,
Eye,
EyeOff,
Rocket,
} from '@lucide/vue';
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import { ApiError } from '../../lib/api';
@@ -107,6 +108,7 @@ import SessionStateBadge from '../SessionStateBadge.vue';
import ConfirmModal from './modals/ConfirmModal.vue';
import WorktreeCreateModal from './modals/WorktreeCreateModal.vue';
import NewSessionModal from './modals/NewSessionModal.vue';
import LaunchProjectModal from './modals/LaunchProjectModal.vue';
const props = defineProps<{ repo: RepoSummary }>();
const { t } = useI18n();
@@ -144,6 +146,7 @@ function onWtClick(wt: WorktreeSummary): void {
// --- menu contextuel repo ---
function openRepoMenu(e: MouseEvent): void {
const items: ContextMenuItem[] = [
{ label: t('launch.startProject'), icon: Rocket, onSelect: () => void modals.open(LaunchProjectModal, { repoId: props.repo.id }) },
{ label: t('ide.menu.newWorktree'), icon: GitBranch, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'worktree' }) },
{ label: t('ide.menu.workOnMain'), icon: Home, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'main' }) },
{ label: t('ide.menu.prune'), icon: Scissors, onSelect: () => void onPrune() },
@@ -190,6 +193,7 @@ function confirmRemoveRepo(): void {
// --- menu contextuel worktree ---
function openWtMenu(e: MouseEvent, wt: WorktreeSummary): void {
const items: ContextMenuItem[] = [
{ label: t('launch.startProject'), icon: Rocket, onSelect: () => void modals.open(LaunchProjectModal, { repoId: wt.repoId, worktreePath: wt.path, ...(wt.branch ? { targetLabel: wt.branch } : {}) }) },
{ label: t('ide.menu.openTerminal'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal, { cwd: wt.path }) },
{ label: t('ide.menu.commit'), icon: GitCompare, onSelect: () => openGitPanel(wt) },
{ label: t('ide.menu.push'), icon: Upload, onSelect: () => void onPush(wt) },
@@ -62,6 +62,8 @@ import {
ArchiveRestore,
RefreshCw,
FolderPlus,
Rocket,
Square,
} from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared';
import { useIdeStore } from '../../stores/ide';
@@ -77,6 +79,7 @@ import AttentionList from './AttentionList.vue';
import ConfirmModal from './modals/ConfirmModal.vue';
import NewSessionModal from './modals/NewSessionModal.vue';
import NewProjectModal from '../NewProjectModal.vue';
import LaunchProjectModal from './modals/LaunchProjectModal.vue';
const { t } = useI18n();
const ide = useIdeStore();
@@ -120,6 +123,12 @@ function menuFor(s: SessionSummary): ContextMenuItem[] {
else if (s.source === 'discovered') items.push({ label: t('sessions.hide'), icon: EyeOff, onSelect: () => void actions.hide(s.id) });
if (s.archived) items.push({ label: t('sessions.unarchive'), icon: ArchiveRestore, onSelect: () => void actions.unarchive(s.id) });
else if (s.source === 'managed' && !s.live) items.push({ label: t('sessions.archive'), icon: Archive, onSelect: () => void actions.archive(s.id) });
// « Démarrer le projet » : arrêter d'un coup tous les terminaux vivants du même lancement.
if (s.live && s.launchRunId) {
const runId = s.launchRunId;
const n = store.sessions.filter((x) => x.live && x.launchRunId === runId).length;
if (n >= 2) items.push({ label: t('launch.stopAll', { n }), icon: Square, danger: true, onSelect: () => void store.stopLaunch(runId) });
}
return items;
}
@@ -129,10 +138,20 @@ function openRowMenu(e: MouseEvent, s: SessionSummary): void {
}
function openAddMenu(e: MouseEvent): void {
ctx.open(e.clientX, e.clientY, [
const items: ContextMenuItem[] = [
{ label: t('sessions.newSession'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal) },
{ label: t('project.new'), icon: FolderPlus, onSelect: () => void modals.open(NewProjectModal) },
]);
];
// Raccourci « Démarrer le projet » ciblant le worktree actif de l'IDE, quand il existe.
const active = ide.activeContext;
if (active?.repoId) {
items.push({
label: t('launch.startProject'),
icon: Rocket,
onSelect: () => void modals.open(LaunchProjectModal, { repoId: active.repoId, ...(active.wtPath ? { worktreePath: active.wtPath } : {}) }),
});
}
ctx.open(e.clientX, e.clientY, items);
}
function openMoreMenu(e: MouseEvent): void {
@@ -0,0 +1,185 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
<header class="flex items-center gap-2">
<Rocket :size="16" class="text-accent" />
<h2 class="font-semibold text-fg">{{ t('launch.title') }}</h2>
<span v-if="targetLabel" class="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[11px] text-fg-muted">{{ targetLabel }}</span>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<p class="text-xs text-fg-subtle">{{ t('launch.hint') }}</p>
<!-- lignes de commandes éditables -->
<div class="flex flex-col gap-2">
<div v-for="(c, i) in rows" :key="c.id" class="flex items-center gap-2">
<input
v-model="c.enabled"
type="checkbox"
class="size-4 shrink-0 accent-[var(--color-accent-solid)]"
:title="t('launch.enabledTitle')"
/>
<input v-model="c.label" class="input w-28 shrink-0 text-xs" :placeholder="t('launch.labelPlaceholder')" />
<input v-model="c.run" class="input min-w-0 flex-1 font-mono text-xs" :placeholder="t('launch.runPlaceholder')" />
<input v-model="c.cwd" class="input w-24 shrink-0 font-mono text-xs" :placeholder="t('launch.cwdPlaceholder')" />
<button type="button" class="btn shrink-0 px-1.5" :title="t('launch.remove')" @click="removeRow(i)">
<Trash2 :size="13" />
</button>
</div>
<p v-if="rows.length === 0" class="rounded border border-dashed border-border px-3 py-4 text-center text-xs text-fg-subtle">
{{ t('launch.empty') }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="btn inline-flex items-center gap-1 text-xs" @click="addRow">
<Plus :size="13" /> {{ t('launch.add') }}
</button>
<button type="button" class="btn inline-flex items-center gap-1 text-xs" :disabled="detecting" @click="detect">
<Sparkles :size="13" /> {{ detecting ? t('common.working') : t('launch.detect') }}
</button>
</div>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
<footer class="mt-1 flex items-center gap-2 border-t border-border pt-3">
<button type="button" class="btn text-xs" :disabled="saving || working" @click="onSave">
{{ saving ? t('common.working') : t('launch.save') }}
</button>
<button
type="button"
class="btn-primary ml-auto inline-flex items-center gap-1.5"
:disabled="working || saving || enabledCount === 0"
@click="onStart"
>
<Play :size="14" />
{{ working ? t('common.working') : t('launch.start', { n: enabledCount }) }}
</button>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
// « Démarrer le projet » : édite les commandes de démarrage d'un repo (label / commande / sous-dossier
// / activée), détecte des suggestions (package.json/Procfile/docker-compose), enregistre, et lance
// un terminal par commande activée (ide.openTerminal en boucle). Le worktree cible est passé en prop.
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Play, Plus, Rocket, Sparkles, Trash2 } from '@lucide/vue';
import type { LaunchCommand } from '@arboretum/shared';
import { useWorktreesStore } from '../../../stores/worktrees';
import { useSessionsStore } from '../../../stores/sessions';
import { useIdeStore } from '../../../stores/ide';
import { useToastsStore } from '../../../stores/toasts';
const props = defineProps<{ repoId: string; worktreePath?: string; targetLabel?: string }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const ide = useIdeStore();
const toasts = useToastsStore();
// Copie éditable locale des commandes du repo (jamais muter le store directement).
const rows = ref<LaunchCommand[]>([]);
const detecting = ref(false);
const saving = ref(false);
const working = ref(false);
const errorMsg = ref<string | null>(null);
const enabledCount = computed(() => rows.value.filter((c) => c.enabled && c.run.trim() !== '').length);
function newId(): string {
return globalThis.crypto?.randomUUID?.() ?? `lc-${Date.now()}-${rows.value.length}`;
}
onMounted(async () => {
const repo = worktrees.repos.find((r) => r.id === props.repoId);
rows.value = (repo?.launchCommands ?? []).map((c) => ({ ...c }));
// Auto-détection au premier ouvert si aucune commande n'est encore définie.
if (rows.value.length === 0) await detect();
});
function addRow(): void {
rows.value = [...rows.value, { id: newId(), label: '', run: '', enabled: true }];
}
function removeRow(i: number): void {
rows.value = rows.value.filter((_, idx) => idx !== i);
}
async function detect(): Promise<void> {
if (detecting.value) return;
detecting.value = true;
errorMsg.value = null;
try {
const suggestions = await worktrees.detectLaunch(props.repoId, props.worktreePath);
// Fusion : n'ajoute que les commandes dont le `run` n'est pas déjà présent.
const known = new Set(rows.value.map((c) => c.run.trim()));
const fresh = suggestions.filter((s) => !known.has(s.run.trim()));
if (fresh.length === 0 && suggestions.length > 0) toasts.info(t('launch.detectNone'));
rows.value = [...rows.value, ...fresh.map((c) => ({ ...c, id: newId() }))];
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
detecting.value = false;
}
}
/** Normalise avant persistance : trim, retire les lignes sans commande, enlève cwd vide. */
function normalized(): LaunchCommand[] {
return rows.value
.map((c) => ({
id: c.id,
label: c.label.trim() || c.run.trim().slice(0, 24),
run: c.run.trim(),
enabled: c.enabled,
...(c.cwd && c.cwd.trim() !== '' ? { cwd: c.cwd.trim() } : {}),
}))
.filter((c) => c.run !== '');
}
async function persist(): Promise<void> {
const cmds = normalized();
await worktrees.updateRepoLaunchCommands(props.repoId, cmds);
rows.value = cmds.map((c) => ({ ...c }));
}
async function onSave(): Promise<void> {
if (saving.value) return;
saving.value = true;
errorMsg.value = null;
try {
await persist();
toasts.success(t('launch.saved'));
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
async function onStart(): Promise<void> {
if (working.value || enabledCount.value === 0) return;
working.value = true;
errorMsg.value = null;
try {
// On enregistre d'abord (les ids persistés doivent correspondre aux commandIds envoyés).
await persist();
const ids = normalized().filter((c) => c.enabled).map((c) => c.id);
const res = await sessions.startLaunch(props.repoId, {
commandIds: ids,
...(props.worktreePath ? { worktreePath: props.worktreePath } : {}),
});
for (const s of res.sessions) ide.openTerminal(s.id);
if (res.skipped.length > 0) toasts.info(t('launch.partial', { started: res.sessions.length, skipped: res.skipped.length }));
else toasts.success(t('launch.started', { n: res.sessions.length }));
emit('close');
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
working.value = false;
}
}
</script>