feat(web): IDE unique comme seule coquille, ancien dashboard retiré

L'IDE (/ide) devient l'unique interface sur tous les supports (navigateur,
PWA, app Electron chargent la même SPA). Tout le cycle de vie de l'ancien
monde est réintégré dans l'IDE ; AppShell et les vues Dashboard / Sessions /
Groups / GroupView / SessionView sont supprimés.

- Infra: pile de modals Teleport (stores/modals.ts + ModalHost) et menu
  contextuel (useContextMenu + ContextMenu), montés dans IdeShell sans le
  démonter (attaches WS du dock préservées).
- Explorer: créer worktree (branche + baseRef + session), travailler sur main,
  prune, promote (escalade force), supprimer worktree, commit/push, masquer/
  supprimer repo, ajouter dépôt local ou cloner.
- Sessions: create/kill/resume/fork, hide/unhide, archive/unarchive, masquer
  les découvertes, supervision AttentionList + réponse aux dialogues.
- Groupes: create/delete, composition, lancement de session de groupe (ouvre
  le terminal dans le dock).
- Compte: Réglages et Aide en overlays par-dessus l'IDE ; langue, push/PWA,
  tokens, connexions git, RGPD, déconnexion. Command Palette et deep-links
  recâblés pour rester dans l'IDE.
- Routes: les anciens chemins rendent IdeShell via meta (overlay/panel/
  terminal) ; /workspace/:repoId/:wt inchangé (contrat extension VS Code).
  Mobile: nav à 6 panneaux (explorer/editor/terminal/git/sessions/groups).

La grille multi-terminaux de groupe est retirée (supplantée par la session de
groupe unique P6 via --add-dir + le dock multi-onglets).
This commit is contained in:
2026-07-20 12:10:12 +02:00
parent e48798ebff
commit c694f9f2dd
57 changed files with 1457 additions and 3045 deletions
+120 -14
View File
@@ -2,31 +2,137 @@
<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.activity.groups') }}
<RouterLink :to="{ name: 'groups' }" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('nav.groups')">
<ExternalLink :size="12" />
</RouterLink>
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.menu.newGroup')" @click="openNewGroup">
<Plus :size="14" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
<p v-if="groups.groups.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('groups.empty') }}</p>
<RouterLink
v-for="g in groups.groups"
:key="g.id"
:to="{ name: 'group', params: { id: g.id } }"
class="flex items-center gap-2 rounded px-2 py-1 text-xs text-fg-muted hover:bg-surface-2/60"
>
<Boxes :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate">{{ g.label }}</span>
<span class="ml-auto shrink-0 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
</RouterLink>
<div v-for="g in groups.groups" :key="g.id">
<div
class="group flex items-center rounded text-xs text-fg-muted hover:bg-surface-2/60"
@contextmenu.prevent="openGroupMenu($event, g)"
>
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left" @click="toggleExpand(g.id)">
<component :is="isExpanded(g.id) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<Boxes :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate">{{ g.label }}</span>
<span class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
</button>
<button
type="button"
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
:title="t('common.settings')"
@click.stop="openGroupMenu($event, g)"
>
<MoreVertical :size="13" />
</button>
</div>
<div v-if="isExpanded(g.id)" class="pl-6">
<button
v-for="s in liveSessionsFor(g.id)"
:key="s.id"
type="button"
class="flex w-full items-center gap-1.5 rounded px-2 py-0.5 text-left text-[11px]"
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
@click="ide.openTerminal(s.id)"
>
<SessionStateBadge :session="s" />
<span class="truncate font-mono">{{ sessionLabel(s, worktrees) }}</span>
</button>
<button
type="button"
class="mt-0.5 flex w-full items-center gap-1.5 rounded px-2 py-0.5 text-left text-[11px] text-accent hover:bg-surface-2/60 disabled:opacity-40"
:disabled="groups.reposInGroup(g.id).length === 0"
@click="launch(g)"
>
<Combine :size="12" /> {{ t('groups.newFeature') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// Panneau Groupes de l'IDE : création, composition, lancement d'une session de groupe et ouverture
// des sessions du groupe dans le dock. Tout via le store groups / modals ; aucune navigation.
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Boxes, ExternalLink } from '@lucide/vue';
import { Boxes, ChevronDown, ChevronRight, Combine, MoreVertical, Pencil, Plus, Trash2 } from '@lucide/vue';
import type { GroupSummary } from '@arboretum/shared';
import { useGroupsStore } from '../../stores/groups';
import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions';
import { useIdeStore } from '../../stores/ide';
import { useModalsStore } from '../../stores/modals';
import { useToastsStore } from '../../stores/toasts';
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
import { sessionLabel } from '../../lib/session-label';
import SessionStateBadge from '../SessionStateBadge.vue';
import GroupSessionModal from '../GroupSessionModal.vue';
import GroupCreateModal from './modals/GroupCreateModal.vue';
import GroupEditModal from './modals/GroupEditModal.vue';
import ConfirmModal from './modals/ConfirmModal.vue';
const { t } = useI18n();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const ide = useIdeStore();
const modals = useModalsStore();
const toasts = useToastsStore();
const ctx = useContextMenu();
const expanded = ref<string[]>([]);
const isExpanded = (id: string): boolean => expanded.value.includes(id);
function toggleExpand(id: string): void {
expanded.value = isExpanded(id) ? expanded.value.filter((x) => x !== id) : [...expanded.value, id];
}
// sessions vivantes du groupe, en version live (store sessions).
function liveSessionsFor(id: string) {
return groups
.sessionsInGroup(id)
.map((s) => sessions.sessions.find((x) => x.id === s.id) ?? s)
.filter((s) => s.live);
}
function openNewGroup(): void {
modals.open(GroupCreateModal);
}
function launch(g: GroupSummary): void {
modals.open(GroupSessionModal, { groupId: g.id, repos: groups.reposInGroup(g.id) });
}
function openGroupMenu(e: MouseEvent, g: GroupSummary): void {
const items: ContextMenuItem[] = [
{
label: t('ide.menu.launchGroup'),
icon: Combine,
disabled: groups.reposInGroup(g.id).length === 0,
onSelect: () => launch(g),
},
{ label: t('ide.menu.editGroup'), icon: Pencil, onSelect: () => void modals.open(GroupEditModal, { groupId: g.id }) },
{ label: t('ide.menu.deleteGroup'), icon: Trash2, danger: true, onSelect: () => confirmDelete(g) },
];
ctx.open(e.clientX, e.clientY, items);
}
function confirmDelete(g: GroupSummary): void {
modals.open(ConfirmModal, {
title: t('ide.menu.deleteGroup'),
message: g.label,
danger: true,
confirmLabel: t('groups.confirmDelete'),
onConfirm: async () => {
await groups.deleteGroup(g.id);
toasts.success(t('toast.groupDeleted'));
},
});
}
</script>