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
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:
@@ -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>
|
||||
Reference in New Issue
Block a user