Un terminal pouvait rester tout noir alors que sa session tournait. Le PTY était vivant et avait bien écrit sa sortie : la panne était dans le transport. Le replay d'attache est une frame BINAIRE, mais un client n'apprend son numéro de canal qu'avec le message `attached` ; le serveur envoyait le replay AVANT, donc tout client le jetait sur un canal inconnu. Rien n'était peint, et un TUI au repos (Claude à son prompt) ne réémet jamais rien de lui-même. `attach()` renvoie désormais le replay et la gateway l'émet APRÈS `attached` : un seul correctif serveur répare le web, l'app de bureau et l'extension VS Code, qui portaient le même défaut client. Le resize de l'attache masquait le bug en provoquant un SIGWINCH, d'où son apparence intermittente. Seconde moitié du symptôme (« je tape et rien ne se passe ») : le dock montait avant la liste des sessions, en déduisait « non attachable » et s'attachait en observateur, à vie et en silence. Un pane n'attache plus avant de connaître sa session (`sessions.loaded`). Attaches sans écran : le message `attach` accepte un `screen` optionnel (défaut true). Un client qui n'affiche rien et veut seulement répondre à un dialogue ne prend plus le contrôle de la session, ne lui impose plus ses dimensions (ce qui figeait la géométrie du vrai terminal) et ne reçoit plus le flux pour le jeter. Rendre les pannes visibles : la raison d'un exit est écrite dans le terminal (`[arboretum] bash exited with code 3`) avant le détachement ; un repaint est forcé si rien n'arrive 1,2 s après l'attache, puis annoncé avec « Refresh screen » ; les refus de canal remontent à l'écran au lieu d'un console.warn ; le chemin du CLI claude est revalidé (périmé après une bascule nvm/asdf, le PTY mourait sans un octet). Colonnes de terminaux : le dock devient une rangée de colonnes redimensionnables (3 max), chacune avec ses onglets. Algèbre pure dans lib/dock-model.ts, cinq invariants documentés, ratios plutôt que pixels. `dockSessionIds` et `activeDockSessionId` deviennent des computed dérivés : aucun consommateur ni test existant ne change. Alt+clic ouvre à côté depuis les quatre panneaux. Le plafond de hauteur du dock suit le viewport au lieu d'un 640 px figé. Correctif préexistant au passage : PanelSplitter passait ses bornes par valeur, figées au premier rendu, alors que le clavier les relisait. Portée git : la vue Changements suit le worktree du terminal focalisé, ou tous les dépôts de son groupe pour une session de groupe, avec « tout voir » à un clic. L'index Git de la sidebar reste global (c'est la sortie d'une portée étroite) et le badge d'activité aussi (il sert à signaler le travail qu'on ne regarde pas). Seul le TERMINAL impose le contexte : le repli sur l'onglet éditeur, essayé d'abord, rétrécissait la vue multi-projet dès qu'on ouvrait un fichier. Vérifications : acceptance-p17.mjs prouve l'ordre des trames sur un vrai WebSocket (avec l'ancien ordre : 0 octet rejoué, échec), verify-terminals.mjs prouve par interaction réelle que le terminal peint, que deux colonnes coexistent, que la frappe atteint le bon PTY (fichier témoin par cwd) et que la vue suit le terminal.
251 lines
11 KiB
Vue
251 lines
11 KiB
Vue
<template>
|
|
<div class="flex h-full min-h-0 flex-col">
|
|
<div class="label-mono flex items-center gap-1 px-3 py-2">
|
|
{{ t('ide.activity.groups') }}
|
|
<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">
|
|
<!-- chargement / échec / vide distingués (cf. ProjectTree) -->
|
|
<SkeletonRow v-if="groups.loading && groups.groups.length === 0" :count="3" :height="22" class="px-2 py-1" />
|
|
<div v-else-if="groups.loadError" class="px-2 py-1 text-xs">
|
|
<p class="text-danger">{{ groups.loadError }}</p>
|
|
<button type="button" class="mt-1 rounded px-1 text-accent hover:bg-surface-2" @click="groups.fetchGroups()">
|
|
{{ t('common.retry') }}
|
|
</button>
|
|
</div>
|
|
<p v-else-if="groups.groups.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('groups.empty') }}</p>
|
|
|
|
<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"
|
|
:title="groupTitle(g)"
|
|
@click="ide.toggleGroup(g.id)"
|
|
>
|
|
<component :is="isExpanded(g.id) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
|
<Boxes
|
|
:size="13"
|
|
class="shrink-0"
|
|
:class="g.color ? '' : 'text-fg-subtle'"
|
|
:style="g.color ? { color: g.color } : undefined"
|
|
/>
|
|
<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-4">
|
|
<!-- Composition réelle du groupe : chaque dépôt membre avec SES worktrees et leur statut git.
|
|
Répond enfin à « quels worktrees existent dans ce groupe et lesquels sont actifs », qui
|
|
exigeait auparavant d'ouvrir le modal d'édition puis de déplier l'autre panneau. -->
|
|
<p v-if="groups.reposInGroup(g.id).length === 0" class="px-2 py-0.5 text-[11px] text-fg-subtle">
|
|
{{ t('groups.noRepos') }}
|
|
</p>
|
|
<div v-for="repo in groups.reposInGroup(g.id)" :key="repo.id">
|
|
<div class="flex items-center gap-1 px-2 py-0.5 text-[11px] text-fg-muted">
|
|
<FolderGit2 :size="11" class="shrink-0 text-fg-subtle" />
|
|
<span class="min-w-0 truncate" :title="repo.path">{{ repo.label }}</span>
|
|
</div>
|
|
<div v-for="wt in worktrees.worktreesForRepo(repo.id)" :key="wt.path">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-1 rounded py-0.5 pr-2 pl-3 text-left text-[11px] 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="11" class="shrink-0 text-fg-subtle" />
|
|
<component :is="wt.isMain ? Home : GitBranch" :size="11" class="shrink-0 text-fg-subtle" />
|
|
<span class="min-w-0 truncate font-mono" :title="wt.branch ?? wt.head">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
|
|
<span v-if="sessionCount(wt) > 0" class="shrink-0 text-fg-subtle" :title="t('groups.sessionCount', sessionCount(wt))">
|
|
<SquareTerminal :size="11" />
|
|
</span>
|
|
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="ml-auto shrink-0 pl-1" />
|
|
</button>
|
|
|
|
<!-- Arborescence de fichiers du worktree, exactement comme dans l'Explorateur (même
|
|
composant, même état d'expansion partagé) : on travaille dans un groupe sans avoir
|
|
à repasser par l'autre panneau pour ouvrir un fichier. -->
|
|
<div v-if="isWtExpanded(wt)" class="pl-3">
|
|
<FileTree
|
|
:wt="wt.path"
|
|
:active="activeFileFor(wt)"
|
|
embedded
|
|
:depth="0"
|
|
:version="worktrees.changeVersion(wt.repoId, wt.path)"
|
|
@open="(rel) => ide.openFile(wt.repoId, wt.path, rel)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- sessions du groupe (une session de groupe couvre plusieurs dépôts : elle n'appartient à
|
|
aucun worktree en particulier) -->
|
|
<p v-if="groupSessions(g.id).length > 0" class="px-2 pt-1 text-[10px] text-fg-subtle">{{ t('groups.sessions') }}</p>
|
|
<button
|
|
v-for="s in groupSessions(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'"
|
|
:title="sessionTitle(s)"
|
|
@click="(e: MouseEvent) => ide.openTerminal(s.id, { split: e.altKey })"
|
|
>
|
|
<SessionStateBadge :session="s" />
|
|
<span class="min-w-0 truncate font-mono" :class="s.live ? '' : 'text-fg-subtle'">{{ sessionLabel(s, worktrees) }}</span>
|
|
<span v-if="s.addedDirs?.length" class="shrink-0 text-[10px] text-fg-subtle">·{{ s.addedDirs.length }}</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 { useI18n } from 'vue-i18n';
|
|
import {
|
|
Boxes,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Combine,
|
|
FolderGit2,
|
|
GitBranch,
|
|
Home,
|
|
MoreVertical,
|
|
Pencil,
|
|
Plus,
|
|
SquareTerminal,
|
|
Trash2,
|
|
} from '@lucide/vue';
|
|
import type { GroupSummary, SessionSummary, WorktreeSummary } 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 GitStatusBadge from '../workspace/GitStatusBadge.vue';
|
|
import FileTree from '../workspace/FileTree.vue';
|
|
import SkeletonRow from '../ui/SkeletonRow.vue';
|
|
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();
|
|
|
|
// L'expansion vit dans le store IDE (persistée, comme celle des dépôts) : ce panneau est démonté à
|
|
// chaque changement d'onglet de l'ActivityBar, un état local était donc perdu à chaque aller-retour.
|
|
const isExpanded = (id: string): boolean => ide.expandedGroupIds.includes(id);
|
|
|
|
const isActiveWt = (wt: WorktreeSummary): boolean =>
|
|
ide.activeContext?.repoId === wt.repoId && ide.activeContext?.wtPath === wt.path;
|
|
|
|
/** Sessions du groupe, résolues sur le store sessions (vivantes d'abord, puis les terminées). */
|
|
function groupSessions(id: string): SessionSummary[] {
|
|
return groups
|
|
.sessionsInGroup(id)
|
|
.map((s) => sessions.sessions.find((x) => x.id === s.id) ?? s)
|
|
.sort((a, b) => Number(b.live) - Number(a.live) || b.createdAt.localeCompare(a.createdAt));
|
|
}
|
|
|
|
const sessionCount = (wt: WorktreeSummary): number => wt.sessions.filter((s) => s.live).length;
|
|
|
|
/** Infobulle du groupe : sa description (jamais affichée jusqu'ici) et le nombre de worktrees. */
|
|
function groupTitle(g: GroupSummary): string {
|
|
const lines = [g.label];
|
|
if (g.description) lines.push(g.description);
|
|
lines.push(t('groups.worktreeCount', groups.worktreesInGroup(g.id).length));
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function sessionTitle(s: SessionSummary): string {
|
|
const lines = [s.title ? `${s.title} (${s.command})` : s.command, s.cwd];
|
|
if (s.addedDirs?.length) lines.push(`--add-dir : ${s.addedDirs.join(', ')}`);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
const isWtExpanded = (wt: WorktreeSummary): boolean => ide.expandedWtPaths.includes(wt.path);
|
|
|
|
/** Fichier ouvert appartenant à CE worktree, pour surligner la bonne ligne de l'arbre. */
|
|
function activeFileFor(wt: WorktreeSummary): string | null {
|
|
const tab = ide.activeTab;
|
|
return tab && tab.repoId === wt.repoId && tab.wtPath === wt.path ? tab.file : null;
|
|
}
|
|
|
|
/** Même geste que dans l'Explorateur : le worktree devient actif et son arbre se déplie. */
|
|
function onWtClick(wt: WorktreeSummary): void {
|
|
ide.setActiveWorktree(wt.repoId, wt.path);
|
|
ide.toggleWt(wt.path);
|
|
}
|
|
|
|
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>
|