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

View File

@@ -1,16 +1,18 @@
<template>
<div class="flex h-dvh min-h-0 flex-col bg-surface-0 text-fg">
<div v-if="isMobile" class="flex min-h-0 flex-1 flex-col">
<header class="flex items-center gap-2 border-b border-border px-2 py-1.5">
<RouterLink :to="{ name: 'dashboard' }" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('nav.worktrees')">
<Home :size="18" />
</RouterLink>
<span class="truncate text-sm font-medium">{{ mobileTitle }}</span>
<header class="flex items-center gap-2 border-b border-border px-3 py-2">
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ mobileTitle }}</span>
<button type="button" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('common.settings')" @click="openMobileMenu">
<MoreVertical :size="18" />
</button>
</header>
<div class="min-h-0 flex-1 overflow-hidden">
<ProjectTree v-if="ide.mobilePanel === 'explorer'" />
<GitPanel v-else-if="ide.mobilePanel === 'git'" />
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" />
<SessionsPanel v-else-if="ide.mobilePanel === 'sessions'" />
<GroupsPanel v-else-if="ide.mobilePanel === 'groups'" />
<EditorArea v-else />
</div>
<nav class="flex shrink-0 border-t border-border">
@@ -52,6 +54,10 @@
<StatusBar />
</div>
<!-- Hôtes uniques (Teleport vers body) : pile de modals et menu contextuel de l'IDE. -->
<ModalHost />
<ContextMenu />
</div>
</template>
@@ -59,14 +65,17 @@
// 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 { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { FileCode, FolderTree, GitCompare, Home, SquareTerminal } from '@lucide/vue';
import { Boxes, FileCode, FolderTree, GitCompare, LifeBuoy, List, LogOut, MoreVertical, Settings, SquareTerminal } from '@lucide/vue';
import { decodeWtKey } from '@arboretum/shared';
import { useIdeStore, wtKey } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions';
import { useModalsStore } from '../../stores/modals';
import { useContextMenu } from '../../composables/useContextMenu';
import { useSession } from '../../composables/useSession';
import ActivityBar from './ActivityBar.vue';
import PrimarySidebar from './PrimarySidebar.vue';
import PanelSplitter from './PanelSplitter.vue';
@@ -75,13 +84,31 @@ import EditorArea from './EditorArea.vue';
import TerminalDock from './TerminalDock.vue';
import ProjectTree from './ProjectTree.vue';
import GitPanel from './GitPanel.vue';
import SessionsPanel from './SessionsPanel.vue';
import GroupsPanel from './GroupsPanel.vue';
import ModalHost from './ModalHost.vue';
import ContextMenu from './ContextMenu.vue';
const { t } = useI18n();
const ide = useIdeStore();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const modals = useModalsStore();
const ctx = useContextMenu();
const { logout } = useSession();
const route = useRoute();
// Overlays lazy (partagés avec l'ActivityBar desktop) : accès compte depuis le menu mobile.
const SettingsOverlay = defineAsyncComponent(() => import('./overlays/SettingsOverlay.vue'));
const HelpOverlay = defineAsyncComponent(() => import('./overlays/HelpOverlay.vue'));
function openMobileMenu(e: MouseEvent): void {
ctx.open(e.clientX, e.clientY, [
{ label: t('nav.settings'), icon: Settings, onSelect: () => void modals.open(SettingsOverlay) },
{ label: t('nav.help'), icon: LifeBuoy, onSelect: () => void modals.open(HelpOverlay) },
{ label: t('common.logout'), icon: LogOut, onSelect: () => void logout() },
]);
}
// --- responsive : panneau unique sous le breakpoint md (768px) ---
const isMobile = ref(false);
let mql: MediaQueryList | null = null;
@@ -100,28 +127,40 @@ const mobilePanels = computed(() => [
{ key: 'editor', icon: FileCode, label: t('workspace.editor') },
{ key: 'terminal', icon: SquareTerminal, label: t('ide.terminals') },
{ key: 'git', icon: GitCompare, label: t('ide.activity.git') },
{ key: 'sessions', icon: List, label: t('ide.activity.sessions') },
{ key: 'groups', icon: Boxes, label: t('ide.activity.groups') },
]);
const mobileTitle = computed(() => mobilePanels.value.find((p) => p.key === ide.mobilePanel)?.label ?? '');
const setMobilePanel = (key: string): void => {
ide.mobilePanel = key;
};
// Deep-link : /workspace/:repoId/:wt (ex. lien de l'extension VS Code) cible un worktree précis.
// On le rend actif, on déplie son sous-arbre, et on ouvre éventuellement ?file=<relPath>.
function applyDeepLink(): void {
if (route.name !== 'workspace') return;
const repoId = String(route.params.repoId ?? '');
const wt = decodeWtKey(String(route.params.wt ?? ''));
if (!repoId || !wt) return;
ide.setActivity('explorer');
ide.setActiveWorktree(repoId, wt);
if (!ide.expandedRepoIds.includes(repoId)) ide.toggleRepo(repoId);
if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt);
const file = route.query.file ? String(route.query.file) : null;
if (file) ide.openFile(repoId, wt, file);
// Deep-link / redirections rétro-compat. /workspace/:repoId/:wt (ex. extension VS Code) cible un
// worktree. Sur /ide, on consomme une query one-shot (terminal / panel / overlay) posée par les
// redirections des anciennes routes, puis on l'efface pour garder l'URL propre.
function applyRoute(): void {
if (route.name === 'workspace') {
const repoId = String(route.params.repoId ?? '');
const wt = decodeWtKey(String(route.params.wt ?? ''));
if (!repoId || !wt) return;
ide.setActivity('explorer');
ide.setActiveWorktree(repoId, wt);
if (!ide.expandedRepoIds.includes(repoId)) ide.toggleRepo(repoId);
if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt);
const file = route.query.file ? String(route.query.file) : null;
if (file) ide.openFile(repoId, wt, file);
return;
}
// Intention portée par le meta de la route (rétro-compat des anciens chemins) : ouvrir un
// terminal, activer un panneau, ou ouvrir un overlay. URL bookmarkable, aucun strip.
const m = route.meta;
if (m.terminal && route.params.id) ide.openTerminal(String(route.params.id));
if (m.panel === 'sessions' || m.panel === 'groups') ide.setActivity(m.panel);
if (m.overlay === 'settings') modals.open(SettingsOverlay);
else if (m.overlay === 'help') modals.open(HelpOverlay);
}
onMounted(applyDeepLink);
watch(() => route.fullPath, applyDeepLink);
onMounted(applyRoute);
watch(() => route.fullPath, applyRoute);
function prune(): void {
const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id));