feat(web): coquille IDE + arbre de projets unifié (route /ide)
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled

components/ide/ : IdeShell (grille barre d'activité + panneau gauche + zone centrale + dock + barre de statut, splitters persistés, réconciliation des ressources ouvertes contre les données live), ActivityBar (Explorer/Git/Sessions/Groupes + Réglages/Aide), PrimarySidebar, ProjectTree + ProjectTreeNode (arbre unique repos -> worktrees -> sessions, fichiers embarqués via FileTree en mode sans chrome, dépliage paresseux), StatusBar.

Route /ide (layout 'ide'), entrée de nav « IDE », namespace i18n ide (EN/FR). Clics de l'arbre pilotent le store (contexte worktree, ouverture de fichier/terminal). Centre et dock en placeholders (remplis en B4/B5). FileTree gagne un mode embedded. 427 tests.
This commit is contained in:
2026-07-17 17:08:35 +02:00
parent 6acd4a16fd
commit c96d929c7a
13 changed files with 446 additions and 11 deletions

View File

@@ -0,0 +1,61 @@
<template>
<nav class="flex w-[var(--ide-activitybar-w)] shrink-0 flex-col items-center gap-1 border-r border-border bg-surface-1 py-2">
<button
v-for="item in items"
:key="item.view"
type="button"
class="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors"
:class="isActive(item.view) ? 'bg-surface-3 text-fg' : 'text-fg-subtle hover:bg-surface-2 hover:text-fg'"
:title="item.label"
:aria-label="item.label"
@click="ide.toggleActivity(item.view)"
>
<component :is="item.icon" :size="20" :stroke-width="1.75" />
<span
v-if="item.badge"
class="absolute top-1 right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-1 text-[9px] font-semibold text-amber-950"
>
{{ item.badge }}
</span>
</button>
<div class="mt-auto flex flex-col items-center gap-1">
<RouterLink
v-for="l in links"
:key="l.name"
:to="{ name: l.name }"
:title="l.label"
:aria-label="l.label"
class="flex h-10 w-10 items-center justify-center rounded-lg text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
>
<component :is="l.icon" :size="20" :stroke-width="1.75" />
</RouterLink>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { Boxes, FolderTree, GitCompare, LifeBuoy, Settings, SquareTerminal } from '@lucide/vue';
import { useIdeStore, type ActivityView } from '../../stores/ide';
import { useNav } from '../../composables/useNav';
const { t } = useI18n();
const ide = useIdeStore();
const { waitingCount } = useNav();
const isActive = (v: ActivityView): boolean => ide.activeActivity === v && ide.leftVisible;
const items = computed(() => [
{ view: 'explorer' as ActivityView, icon: FolderTree, label: t('ide.activity.explorer'), badge: 0 },
{ view: 'git' as ActivityView, icon: GitCompare, label: t('ide.activity.git'), badge: 0 },
{ view: 'sessions' as ActivityView, icon: SquareTerminal, label: t('ide.activity.sessions'), badge: waitingCount.value },
{ view: 'groups' as ActivityView, icon: Boxes, label: t('ide.activity.groups'), badge: 0 },
]);
const links = computed(() => [
{ name: 'settings', icon: Settings, label: t('nav.settings') },
{ name: 'help', icon: LifeBuoy, label: t('nav.help') },
]);
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="flex h-dvh min-h-0 flex-col bg-surface-0 text-fg">
<div class="flex min-h-0 flex-1">
<ActivityBar />
<template v-if="ide.leftVisible">
<PrimarySidebar />
<PanelSplitter v-model="ide.leftWidth" axis="x" :min="200" :max="560" />
</template>
<div class="flex min-w-0 flex-1 flex-col">
<!-- centre : éditeur à onglets (livré en B4). Placeholder pour l'instant. -->
<main class="min-h-0 flex-1 overflow-hidden">
<EmptyState :icon="FileCode" :title="t('ide.noFileOpen')" :hint="t('ide.noFileHint')" class="m-6" />
</main>
<!-- dock terminaux (livré en B5). Placeholder pour l'instant. -->
<template v-if="ide.bottomVisible">
<PanelSplitter v-model="ide.bottomHeight" axis="y" :min="120" :max="640" invert />
<section class="shrink-0 overflow-hidden border-t border-border" :style="{ height: `${ide.bottomHeight}px` }">
<EmptyState :icon="SquareTerminal" :title="t('ide.noTerminal')" :hint="t('ide.noTerminalHint')" class="m-6" />
</section>
</template>
</div>
</div>
<StatusBar />
</div>
</template>
<script setup lang="ts">
// Coquille de la vue IDE (layout 'ide', plein écran). Assemble barre d'activité, panneau gauche,
// zone centrale (éditeur), dock terminaux et barre de statut. Propriétaire de la réconciliation
// des ressources ouvertes contre les données live (worktrees / sessions).
import { watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { FileCode, SquareTerminal } from '@lucide/vue';
import { useIdeStore, wtKey } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions';
import ActivityBar from './ActivityBar.vue';
import PrimarySidebar from './PrimarySidebar.vue';
import PanelSplitter from './PanelSplitter.vue';
import StatusBar from './StatusBar.vue';
import EmptyState from '../ui/EmptyState.vue';
const { t } = useI18n();
const ide = useIdeStore();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
function prune(): void {
const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id));
const known = new Set(worktrees.worktrees.map((w) => wtKey(w.repoId, w.path)));
ide.pruneDeadResources(live, known);
}
// On ne réconcilie qu'une fois les données prêtes (fetch initial terminé) pour ne pas fermer les
// onglets/terminaux persistés avant leur chargement. Puis à chaque ajout/retrait de worktree ou
// de session.
watch(
[() => worktrees.loading, () => sessions.sessions.length, () => worktrees.worktrees.length],
() => {
if (!worktrees.loading) prune();
},
{ immediate: true },
);
</script>

View File

@@ -0,0 +1,35 @@
<template>
<aside
class="flex min-h-0 shrink-0 flex-col border-r border-border bg-surface-1"
:style="{ width: `${ide.leftWidth}px` }"
>
<ProjectTree v-if="ide.activeActivity === 'explorer'" />
<!-- Git / Sessions / Groupes : panneaux dédiés livrés en B6 ; placeholder pour l'instant. -->
<div v-else class="flex h-full items-center justify-center p-4">
<EmptyState :icon="placeholder.icon" :title="placeholder.title" :hint="t('ide.comingSoon')" />
</div>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { Boxes, GitCompare, SquareTerminal } from '@lucide/vue';
import { useIdeStore } from '../../stores/ide';
import ProjectTree from './ProjectTree.vue';
import EmptyState from '../ui/EmptyState.vue';
const { t } = useI18n();
const ide = useIdeStore();
const placeholder = computed(() => {
switch (ide.activeActivity) {
case 'git':
return { icon: GitCompare, title: t('ide.activity.git') };
case 'sessions':
return { icon: SquareTerminal, title: t('ide.activity.sessions') };
default:
return { icon: Boxes, title: t('ide.activity.groups') };
}
});
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
{{ t('ide.projects') }}
<button
type="button"
class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg"
:title="t('repos.scan')"
:disabled="scanning"
@click="onScan"
>
<ScanSearch :size="14" />
</button>
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.addProject')" @click="showPicker = true">
<Plus :size="14" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-auto pb-2">
<p v-if="repos.length === 0" class="px-3 py-2 text-xs text-fg-subtle">{{ t('ide.noProjects') }}</p>
<ProjectTreeNode v-for="repo in repos" :key="repo.id" :repo="repo" />
</div>
<DirectoryPicker v-if="showPicker" mode="repo" :initial-path="''" @select="onPick" @close="showPicker = false" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Plus, ScanSearch } from '@lucide/vue';
import { useWorktreesStore } from '../../stores/worktrees';
import { useToastsStore } from '../../stores/toasts';
import ProjectTreeNode from './ProjectTreeNode.vue';
import DirectoryPicker from '../DirectoryPicker.vue';
const { t } = useI18n();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const showPicker = ref(false);
const scanning = ref(false);
const repos = computed(() =>
[...worktrees.repos].filter((r) => !r.hidden).sort((a, b) => a.label.localeCompare(b.label)),
);
async function onScan(): Promise<void> {
scanning.value = true;
try {
const res = await worktrees.discover();
toasts.success(t('repos.scanResult', res.added));
} catch (err) {
toasts.error(err);
} finally {
scanning.value = false;
}
}
async function onPick(path: string): Promise<void> {
showPicker.value = false;
try {
await worktrees.addRepo(path);
toasts.success(t('toast.repoAdded'));
} catch (err) {
toasts.error(err);
}
}
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div>
<!-- ligne repo (niveau projet) -->
<button
type="button"
class="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-xs text-fg hover:bg-surface-2/60"
@click="ide.toggleRepo(repo.id)"
>
<component :is="repoExpanded ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<FolderGit2 :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate font-medium">{{ repo.label }}</span>
<span v-if="worktreeList.length" class="ml-auto shrink-0 text-[10px] text-fg-subtle">{{ worktreeList.length }}</span>
</button>
<div v-if="repoExpanded" class="pl-3">
<p v-if="worktreeList.length === 0" class="px-3 py-0.5 text-[11px] text-fg-subtle">{{ t('ide.noWorktrees') }}</p>
<div v-for="wt in worktreeList" :key="wt.path">
<!-- ligne worktree -->
<button
type="button"
class="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-xs hover:bg-surface-2/60"
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
@click="onWtClick(wt)"
>
<component :is="isWtExpanded(wt) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<component :is="wt.isMain ? Home : GitBranch" :size="12" class="shrink-0 text-fg-subtle" />
<span class="truncate font-mono">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
<span v-if="isDirty(wt)" class="ml-auto shrink-0 text-amber-400" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })"></span>
</button>
<!-- sessions live corrélées au worktree (par cwd) -->
<button
v-for="s in sessionsFor(wt)"
:key="s.id"
type="button"
class="flex w-full items-center gap-1.5 rounded py-0.5 pr-2 pl-9 text-left text-[11px] hover:bg-surface-2/60"
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
@click="ide.openTerminal(s.id)"
>
<SessionStateBadge :session="s" />
<span class="truncate font-mono">{{ s.command }}</span>
</button>
<!-- fichiers du worktree (arbre embarqué, chargé paresseusement au dépliage) -->
<div v-if="isWtExpanded(wt)" class="pl-4">
<FileTree
:wt="wt.path"
:active="activeFileFor(wt)"
embedded
:depth="0"
@open="(rel) => ide.openFile(wt.repoId, wt.path, rel)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ChevronDown, ChevronRight, FolderGit2, GitBranch, Home } from '@lucide/vue';
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import { useIdeStore } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions';
import FileTree from '../workspace/FileTree.vue';
import SessionStateBadge from '../SessionStateBadge.vue';
const props = defineProps<{ repo: RepoSummary }>();
const { t } = useI18n();
const ide = useIdeStore();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const repoExpanded = computed(() => ide.expandedRepoIds.includes(props.repo.id));
const worktreeList = computed(() => worktrees.worktreesForRepo(props.repo.id));
const isWtExpanded = (wt: WorktreeSummary): boolean => ide.expandedWtPaths.includes(wt.path);
const isActiveWt = (wt: WorktreeSummary): boolean =>
ide.activeContext?.repoId === wt.repoId && ide.activeContext?.wtPath === wt.path;
const isDirty = (wt: WorktreeSummary): boolean => wt.git.dirtyCount > 0;
// sessions vivantes dont le cwd correspond au worktree (corrélation par répertoire).
function sessionsFor(wt: WorktreeSummary): SessionSummary[] {
return sessions.sessions.filter((s) => s.live && s.cwd === wt.path);
}
// fichier actif si l'onglet éditeur courant appartient à ce worktree.
function activeFileFor(wt: WorktreeSummary): string | null {
const tab = ide.activeTab;
return tab && tab.repoId === wt.repoId && tab.wtPath === wt.path ? tab.file : null;
}
function onWtClick(wt: WorktreeSummary): void {
ide.setActiveWorktree(wt.repoId, wt.path);
ide.toggleWt(wt.path);
}
</script>

View File

@@ -0,0 +1,51 @@
<template>
<footer
class="flex h-[var(--ide-statusbar-h)] shrink-0 items-center gap-3 border-t border-border bg-surface-1 px-3 text-[11px] text-fg-muted"
>
<span class="flex items-center gap-1.5" :title="wsStatus">
<span class="h-2 w-2 rounded-full" :class="wsDot" />
{{ t('common.appName') }}
</span>
<GitStatusBadge v-if="activeWt" :git="activeWt.git" :branch="activeWt.branch" />
<span v-else class="text-fg-subtle">{{ t('ide.ready') }}</span>
<button
type="button"
class="ml-auto rounded px-1.5 py-0.5 transition-colors hover:bg-surface-2 hover:text-fg"
:class="ide.bottomVisible ? 'text-fg' : ''"
:title="t('ide.terminals')"
@click="ide.toggleBottom()"
>
<span class="inline-flex items-center gap-1"><SquareTerminal :size="12" /> {{ dockCount }}</span>
</button>
</footer>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { SquareTerminal } from '@lucide/vue';
import { useIdeStore } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees';
import { wsClient } from '../../lib/ws-client';
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
const { t } = useI18n();
const ide = useIdeStore();
const worktrees = useWorktreesStore();
const activeWt = computed(() => {
const c = ide.activeContext;
if (!c) return null;
return worktrees.worktrees.find((w) => w.repoId === c.repoId && w.path === c.wtPath) ?? null;
});
const wsStatus = computed(() => wsClient.status.value);
const wsDot = computed(() =>
wsStatus.value === 'open'
? 'bg-emerald-500'
: wsStatus.value === 'reconnecting' || wsStatus.value === 'connecting'
? 'bg-amber-500'
: 'bg-zinc-600',
);
const dockCount = computed(() => ide.dockSessionIds.length);
</script>

View File

@@ -1,12 +1,12 @@
<template>
<div class="flex h-full flex-col">
<div class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
<div :class="embedded ? '' : 'flex h-full flex-col'">
<div v-if="!embedded" class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
<FolderTree :size="13" /> {{ t('workspace.files') }}
<button type="button" class="ml-auto rounded p-0.5 hover:bg-zinc-800 hover:text-zinc-300" :title="t('common.refresh')" @click="load">
<RefreshCw :size="12" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
<div :class="embedded ? '' : 'min-h-0 flex-1 overflow-auto px-1 pb-2'">
<p v-if="loading" class="px-2 py-1 text-xs text-zinc-600">{{ t('fs.loading') }}</p>
<p v-else-if="error" class="px-2 py-1 text-xs text-rose-400">{{ error }}</p>
<p v-else-if="entries.length === 0" class="px-2 py-1 text-xs text-zinc-600">{{ t('fs.empty') }}</p>
@@ -15,7 +15,7 @@
:key="e.path"
:entry="e"
:wt="wt"
:depth="0"
:depth="depth"
:active="active"
@open="(p) => emit('open', p)"
/>
@@ -31,7 +31,10 @@ import type { FsEntry, FsListResponse } from '@arboretum/shared';
import { api } from '../../lib/api';
import FileTreeNode from './FileTreeNode.vue';
const props = defineProps<{ wt: string; active: string | null }>();
const props = withDefaults(defineProps<{ wt: string; active: string | null; embedded?: boolean; depth?: number }>(), {
embedded: false,
depth: 0,
});
const emit = defineEmits<{ open: [relPath: string] }>();
const { t } = useI18n();