Files
arboretum/packages/web/src/components/ide/modals/LaunchProjectModal.vue
Johan LEROY a7e04278fd
All checks were successful
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
release: git-arboretum 3.3.0 (« Démarrer le projet » : lancement multi-terminaux), vscode 0.4.0, desktop 0.1.3
« 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).
2026-07-21 13:54:16 +02:00

186 lines
7.5 KiB
Vue

<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>