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:
119
packages/web/src/components/NewProjectModal.vue
Normal file
119
packages/web/src/components/NewProjectModal.vue
Normal 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>
|
||||
82
packages/web/src/components/SessionContextBar.vue
Normal file
82
packages/web/src/components/SessionContextBar.vue
Normal 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>
|
||||
@@ -184,6 +184,19 @@ export default {
|
||||
externalSessions: 'external',
|
||||
showExternalSessions: 'Show external sessions ({n})',
|
||||
},
|
||||
project: {
|
||||
new: 'New project',
|
||||
title: 'New project',
|
||||
intro: 'Creates a new folder under the chosen root, then starts a session in it.',
|
||||
nameLabel: 'Project name',
|
||||
namePlaceholder: 'my-project',
|
||||
rootLabel: 'Root folder',
|
||||
rootPlaceholder: '/absolute/path/root',
|
||||
gitInit: 'Initialize a git repository (git init)',
|
||||
commandLabel: 'Command',
|
||||
create: 'Create',
|
||||
creating: 'Creating…',
|
||||
},
|
||||
terminal: {
|
||||
observer: 'observer (read-only)',
|
||||
sessionEnded: 'Session ended',
|
||||
@@ -194,6 +207,8 @@ export default {
|
||||
maximize: 'Maximize',
|
||||
restore: 'Restore',
|
||||
openFullscreen: 'Open fullscreen',
|
||||
group: 'Group',
|
||||
covers: 'covers',
|
||||
},
|
||||
push: {
|
||||
enable: 'Enable notifications',
|
||||
@@ -337,6 +352,7 @@ export default {
|
||||
groupDeleted: 'Group deleted',
|
||||
groupSessionLaunched: 'Group session started',
|
||||
groupSessionPartial: 'Group session started ({n} repo(s) skipped)',
|
||||
projectCreated: 'Project created',
|
||||
genericError: 'Something went wrong',
|
||||
dismiss: 'Dismiss',
|
||||
},
|
||||
|
||||
@@ -186,6 +186,19 @@ const fr: typeof en = {
|
||||
externalSessions: 'externes',
|
||||
showExternalSessions: 'Afficher les sessions externes ({n})',
|
||||
},
|
||||
project: {
|
||||
new: 'Nouveau projet',
|
||||
title: 'Nouveau projet',
|
||||
intro: 'Crée un nouveau dossier sous la racine choisie, puis y lance une session.',
|
||||
nameLabel: 'Nom du projet',
|
||||
namePlaceholder: 'mon-projet',
|
||||
rootLabel: 'Dossier racine',
|
||||
rootPlaceholder: '/chemin/absolu/racine',
|
||||
gitInit: 'Initialiser un dépôt git (git init)',
|
||||
commandLabel: 'Commande',
|
||||
create: 'Créer',
|
||||
creating: 'Création…',
|
||||
},
|
||||
terminal: {
|
||||
observer: 'observateur (lecture seule)',
|
||||
sessionEnded: 'Session terminée',
|
||||
@@ -197,6 +210,8 @@ const fr: typeof en = {
|
||||
maximize: 'Agrandir',
|
||||
restore: 'Réduire',
|
||||
openFullscreen: 'Ouvrir en plein écran',
|
||||
group: 'Groupe',
|
||||
covers: 'couvre',
|
||||
},
|
||||
push: {
|
||||
enable: 'Activer les notifications',
|
||||
@@ -340,6 +355,7 @@ const fr: typeof en = {
|
||||
groupDeleted: 'Groupe supprimé',
|
||||
groupSessionLaunched: 'Session de groupe démarrée',
|
||||
groupSessionPartial: 'Session de groupe démarrée ({n} dépôt(s) ignoré(s))',
|
||||
projectCreated: 'Projet créé',
|
||||
genericError: 'Une erreur est survenue',
|
||||
dismiss: 'Fermer',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared';
|
||||
import type { CreateProjectRequest, CreateProjectResponse, CreateSessionRequest, HideDiscoveredResponse, SessionResponse, SessionSummary, SessionsListResponse } from '@arboretum/shared';
|
||||
import { api } from '../lib/api';
|
||||
import { wsClient, type SessionEvent } from '../lib/ws-client';
|
||||
|
||||
@@ -106,6 +106,13 @@ export const useSessionsStore = defineStore('sessions', () => {
|
||||
return res.session;
|
||||
}
|
||||
|
||||
// Crée un nouveau dossier de projet `<root>/<name>` côté serveur puis y lance une session.
|
||||
async function createProject(req: CreateProjectRequest): Promise<CreateProjectResponse> {
|
||||
const res = await api.post<CreateProjectResponse>('/api/v1/projects', req);
|
||||
upsert(res.session);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function killSession(id: string): Promise<void> {
|
||||
await api.delete<{ ok: true }>(`/api/v1/sessions/${id}`);
|
||||
}
|
||||
@@ -134,6 +141,7 @@ export const useSessionsStore = defineStore('sessions', () => {
|
||||
startRealtime,
|
||||
stopRealtime,
|
||||
createSession,
|
||||
createProject,
|
||||
killSession,
|
||||
resumeSession,
|
||||
forkSession,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<component :is="isFullscreen ? Minimize : Maximize" class="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
<SessionContextBar v-if="session" :session="session" />
|
||||
<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" />
|
||||
<!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). -->
|
||||
@@ -33,12 +34,17 @@ import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Maximize, Minimize } from '@lucide/vue';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import { useGroupsStore } from '../stores/groups';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import TerminalView from '../components/TerminalView.vue';
|
||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||
import SessionContextBar from '../components/SessionContextBar.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useSessionsStore();
|
||||
const groups = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
|
||||
const sessionId = computed(() => String(route.params.id));
|
||||
const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive'));
|
||||
@@ -61,6 +67,9 @@ function toggleFullscreen(): void {
|
||||
onMounted(() => {
|
||||
// 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();
|
||||
// best-effort : la barre de contexte a besoin des groupes + worktrees pour résoudre repos/branches.
|
||||
if (groups.groups.length === 0) void groups.fetchGroups();
|
||||
if (worktrees.repos.length === 0) void worktrees.fetchAll();
|
||||
document.addEventListener('fullscreenchange', syncFullscreen);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<PageHeader :title="t('nav.sessions')">
|
||||
<BaseButton variant="primary" size="sm" :icon="FolderPlus" @click="showNewProject = true">
|
||||
{{ t('project.new') }}
|
||||
</BaseButton>
|
||||
<BaseButton v-if="hasDiscovered" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingAll" @click="onHideDiscovered">
|
||||
{{ t('sessions.hideDiscovered') }}
|
||||
</BaseButton>
|
||||
@@ -37,6 +40,7 @@
|
||||
@select="onPickCwd"
|
||||
@close="showPicker = false"
|
||||
/>
|
||||
<NewProjectModal v-if="showNewProject" @close="showNewProject = false" />
|
||||
|
||||
<SkeletonRow v-if="store.loading && store.sessions.length === 0" />
|
||||
<EmptyState v-else-if="store.sessions.length === 0" :icon="TerminalSquare" :title="t('sessions.empty')" />
|
||||
@@ -102,7 +106,7 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { RefreshCw, FolderOpen, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search } from '@lucide/vue';
|
||||
import { RefreshCw, FolderOpen, FolderPlus, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search } from '@lucide/vue';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useToastsStore } from '../stores/toasts';
|
||||
@@ -118,6 +122,7 @@ import Pagination from '../components/Pagination.vue';
|
||||
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
||||
import NewProjectModal from '../components/NewProjectModal.vue';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -136,6 +141,7 @@ const controls = useListControls<SessionSummary>({
|
||||
const newCwd = ref('');
|
||||
const newCommand = ref<'claude' | 'bash'>('claude');
|
||||
const showPicker = ref(false);
|
||||
const showNewProject = ref(false);
|
||||
const creating = ref(false);
|
||||
const killArmedId = ref<string | null>(null);
|
||||
const killing = ref(false);
|
||||
|
||||
Reference in New Issue
Block a user