feat: création de projet (nouveau dossier + session) & en-tête de session enrichi

- POST /api/v1/projects : crée <root>/<name> (mkdir non récursif, 409 si existant),
  git init optionnel, puis lance une session dedans. Anti-traversal sur le nom (core/project.ts).
- Web : NewProjectModal (racine préremplie scanRoots[0], case git init) + bouton dans SessionsListView.
- SessionContextBar : badge du groupe (cliquable) + repos couverts avec leur branche dans l'en-tête
  de session ; session simple = repo+branche déduits du cwd.
- 14 tests (unitaires + route).
This commit is contained in:
2026-06-23 18:52:22 +02:00
parent 06a400acc7
commit 8b9060e0c0
14 changed files with 571 additions and 2 deletions

View File

@@ -0,0 +1,119 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<header class="flex items-center gap-2">
<h2 class="font-semibold text-zinc-100">{{ t('project.title') }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<p class="text-xs text-zinc-500">{{ t('project.intro') }}</p>
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('project.nameLabel') }}
<input v-model="name" class="input font-mono" :placeholder="t('project.namePlaceholder')" required :disabled="creating" />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('project.rootLabel') }}
<input v-model="root" class="input font-mono" :placeholder="t('project.rootPlaceholder')" required :disabled="creating" />
</label>
<div>
<button type="button" class="btn text-xs" :disabled="creating" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
</div>
<DirectoryPicker v-if="showPicker" mode="dir" :initial-path="root" @select="onPickRoot" @close="showPicker = false" />
<label class="flex items-center gap-2 text-xs text-zinc-300">
<input v-model="gitInit" type="checkbox" :disabled="creating" /> {{ t('project.gitInit') }}
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('project.commandLabel') }}
<select v-model="command" class="input" :disabled="creating">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
<p class="font-mono text-[11px] text-zinc-500">{{ previewPath }}</p>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<div class="flex items-center gap-2">
<button type="submit" class="btn-primary" :disabled="creating || name.trim() === '' || root.trim() === ''">
{{ creating ? t('project.creating') : t('project.create') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useSessionsStore } from '../stores/sessions';
import { useSettingsStore } from '../stores/settings';
import { useToastsStore } from '../stores/toasts';
import DirectoryPicker from './DirectoryPicker.vue';
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const router = useRouter();
const sessions = useSessionsStore();
const settings = useSettingsStore();
const toasts = useToastsStore();
const name = ref('');
// Racine par défaut : la 1ère racine de scan (réglage Découverte) ; modifiable via le picker.
const root = ref('');
const gitInit = ref(false);
const command = ref<'claude' | 'bash'>('claude');
const showPicker = ref(false);
const creating = ref(false);
const errorMsg = ref<string | null>(null);
// Aperçu du chemin final (indicatif ; jointure naïve suffisante pour l'affichage).
const previewPath = computed(() => {
const r = root.value.trim().replace(/\/+$/, '');
const n = name.value.trim();
return r && n ? `${r}/${n}` : '';
});
onMounted(async () => {
if (!settings.loaded) {
try {
await settings.fetch();
} catch {
/* le préremplissage de la racine est facultatif */
}
}
root.value = settings.scanRoots[0] ?? '';
});
function onPickRoot(path: string): void {
root.value = path;
showPicker.value = false;
}
async function onSubmit(): Promise<void> {
if (creating.value || name.value.trim() === '' || root.value.trim() === '') return;
creating.value = true;
errorMsg.value = null;
try {
const res = await sessions.createProject({
root: root.value.trim(),
name: name.value.trim(),
gitInit: gitInit.value,
command: command.value,
});
toasts.success(t('toast.projectCreated'));
emit('close');
await router.push({ name: 'session', params: { id: res.session.id } });
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
creating.value = false;
}
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<!-- Barre de contexte sous l'en-tête : groupe + repos couverts (avec branche). Masquée s'il n'y a
rien d'utile à montrer (session simple dans un dossier non reconnu le cwd du header suffit). -->
<div v-if="shouldShow" class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-zinc-800 bg-zinc-900/40 px-3 py-1.5 text-xs">
<RouterLink
v-if="group"
:to="{ name: 'group', params: { id: group.id } }"
class="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-zinc-200 transition-colors hover:bg-zinc-800"
:title="t('terminal.group')"
>
<span class="h-2.5 w-2.5 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
<span class="font-medium">{{ group.label }}</span>
<span class="text-zinc-500"></span>
</RouterLink>
<span v-if="group" class="text-zinc-600">{{ t('terminal.covers') }}</span>
<span
v-for="e in entries"
:key="e.dir"
class="flex items-center gap-1.5 rounded bg-zinc-950/60 px-1.5 py-0.5"
:title="e.dir"
>
<span class="font-mono text-zinc-300">{{ e.label }}</span>
<span v-if="e.branch" class="badge bg-zinc-800 font-mono text-zinc-400">{{ e.branch }}</span>
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared';
import { useGroupsStore } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
const props = defineProps<{ session: SessionSummary }>();
const { t } = useI18n();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const group = computed(() => (props.session.groupId ? (groups.byId(props.session.groupId) ?? null) : null));
function basename(dir: string): string {
return dir.split('/').filter(Boolean).pop() ?? dir;
}
interface CoverEntry {
dir: string;
label: string;
branch: string | null;
resolved: boolean;
}
// Résout un répertoire couvert vers repo + branche : worktree exact d'abord (branche connue),
// sinon checkout principal d'un repo, sinon dernier segment du chemin (non résolu).
function resolve(dir: string): CoverEntry {
const wt = worktrees.worktrees.find((w) => w.path === dir);
if (wt) {
const repo = worktrees.repos.find((r) => r.id === wt.repoId);
return { dir, label: repo?.label ?? basename(dir), branch: wt.branch, resolved: true };
}
const repo = worktrees.repos.find((r) => r.path === dir);
if (repo) return { dir, label: repo.label, branch: null, resolved: true };
return { dir, label: basename(dir), branch: null, resolved: false };
}
const entries = computed<CoverEntry[]>(() => {
const dirs = [props.session.cwd, ...(props.session.addedDirs ?? [])];
const seen = new Set<string>();
const out: CoverEntry[] = [];
for (const d of dirs) {
if (seen.has(d)) continue;
seen.add(d);
out.push(resolve(d));
}
return out;
});
// Afficher si on est dans un groupe, ou si au moins un répertoire correspond à un repo connu.
const shouldShow = computed(() => group.value !== null || entries.value.some((e) => e.resolved));
</script>