fix(server, web, vscode): les terminaux ne restent plus noirs, panes côte à côte, git scopé au terminal
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.
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
type="button"
|
||||
class="truncate text-left font-mono text-[11px] text-warn/80 transition-colors hover:text-warn"
|
||||
:title="s.cwd"
|
||||
@click="ide.openTerminal(s.id)"
|
||||
@click="(e: MouseEvent) => ide.openTerminal(s.id, { split: e.altKey })"
|
||||
>
|
||||
{{ sessionLabel(s, worktrees) }}
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,19 @@
|
||||
<GitCompare :size="13" />
|
||||
{{ t('changes.title') }}
|
||||
<span class="text-fg-subtle normal-case">{{ t('changes.summary', { n: pendingCount }, pendingCount) }}</span>
|
||||
<span class="ml-auto flex items-center gap-1">
|
||||
<span class="ml-auto flex min-w-0 items-center gap-1">
|
||||
<!-- Portée : suivre le terminal focalisé (ou son groupe), ou tout voir. Le libellé dit
|
||||
TOUJOURS ce qui serait suivi, y compris en mode « tout voir ». -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 items-center gap-1 rounded px-1 py-0.5 normal-case hover:bg-surface-2 hover:text-fg"
|
||||
:class="scoped ? 'text-accent' : 'text-fg-subtle'"
|
||||
:title="scoped ? t('changes.scope.all') : t('changes.scope.follow')"
|
||||
@click="toggleScope"
|
||||
>
|
||||
<Crosshair :size="13" class="shrink-0" />
|
||||
<span class="truncate text-[11px]">{{ scopeLabel }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-0.5 hover:bg-surface-2 hover:text-fg"
|
||||
@@ -42,6 +54,21 @@
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- « Tout est committé et poussé » serait un MENSONGE sous une portée restreinte : le travail
|
||||
existe peut-être juste à côté. On le dit, avec la sortie. -->
|
||||
<EmptyState
|
||||
v-else-if="groups.length === 0 && scoped"
|
||||
:icon="Crosshair"
|
||||
:title="t('changes.scope.emptyInScope')"
|
||||
:hint="t('changes.scope.emptyInScopeHint')"
|
||||
class="m-6"
|
||||
>
|
||||
<template #action>
|
||||
<button type="button" class="rounded-md border border-border px-2 py-1 text-xs text-accent hover:bg-surface-2" @click="toggleScope">
|
||||
{{ t('changes.scope.all') }}
|
||||
</button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
<EmptyState
|
||||
v-else-if="groups.length === 0"
|
||||
:icon="GitCompare"
|
||||
@@ -68,12 +95,13 @@
|
||||
// panneau latéral comme surface de travail ; la sidebar n'en garde que l'index.
|
||||
import { computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Eye, EyeOff, FoldVertical, FolderGit2, GitCompare, RefreshCw, UnfoldVertical } from '@lucide/vue';
|
||||
import { Crosshair, Eye, EyeOff, FoldVertical, FolderGit2, GitCompare, RefreshCw, UnfoldVertical } from '@lucide/vue';
|
||||
import { useIdeStore, wtKey } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useGroupsStore } from '../../stores/groups';
|
||||
import { useChangesStore } from '../../stores/changes';
|
||||
import { groupWorktreesByRepo, pendingWorktreeCount } from '../../lib/changes-model';
|
||||
import { useContextScope } from '../../composables/useContextScope';
|
||||
import ChangesWorktreeBlock from './ChangesWorktreeBlock.vue';
|
||||
import EmptyState from '../ui/EmptyState.vue';
|
||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||
@@ -83,15 +111,19 @@ const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const groupsStore = useGroupsStore();
|
||||
const changes = useChangesStore();
|
||||
// Portée de travail : le terminal focalisé impose son worktree (ou son groupe). C'est ce qui évite de
|
||||
// se perdre entre projets ; la bascule « tout voir » reste à un clic.
|
||||
const { scope, scoped, label: scopeLabel, inScope, toggle: toggleScope } = useContextScope();
|
||||
|
||||
const repos = computed(() => worktrees.visibleRepos);
|
||||
const groups = computed(() =>
|
||||
groupWorktreesByRepo(repos.value, (id) => worktrees.worktreesForRepo(id), {
|
||||
showClean: ide.changesShowClean,
|
||||
active: ide.activeContext,
|
||||
inScope: inScope.value,
|
||||
}),
|
||||
);
|
||||
const pendingCount = computed(() => pendingWorktreeCount(repos.value, (id) => worktrees.worktreesForRepo(id)));
|
||||
const pendingCount = computed(() => pendingWorktreeCount(repos.value, (id) => worktrees.worktreesForRepo(id), inScope.value));
|
||||
|
||||
const allKeys = computed(() => groups.value.flatMap((g) => g.worktrees.map((w) => wtKey(w.repoId, w.path))));
|
||||
const allExpanded = computed(() => allKeys.value.length > 0 && allKeys.value.every((k) => ide.changesExpanded.includes(k)));
|
||||
@@ -109,9 +141,11 @@ function toggleAll(): void {
|
||||
// Premier affichage : les blocs sont repliés par défaut (aucune requête), mais une vue entièrement
|
||||
// repliée ne montre rien. On déplie donc le premier bloc, celui du worktree actif s'il est listé.
|
||||
watch(
|
||||
() => allKeys.value,
|
||||
(keys) => {
|
||||
if (keys.length === 0 || ide.changesExpanded.length > 0) return;
|
||||
// La portée compte comme dépendance : changer de terminal renouvelle la liste, et sans cela la vue
|
||||
// restait entièrement repliée sur le nouveau contexte.
|
||||
() => [allKeys.value, scope.value] as const,
|
||||
([keys]) => {
|
||||
if (keys.length === 0 || keys.some((k) => ide.changesExpanded.includes(k))) return;
|
||||
const ctx = ide.activeContext;
|
||||
const preferred = ctx ? wtKey(ctx.repoId, ctx.wtPath) : null;
|
||||
ide.setChangesExpanded([preferred && keys.includes(preferred) ? preferred : keys[0]!]);
|
||||
|
||||
@@ -32,8 +32,15 @@
|
||||
class="flex h-[var(--ide-row-h)] w-full items-center gap-1.5 px-2 pl-6 text-left text-xs"
|
||||
:class="isActive(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/50'"
|
||||
:title="wt.path"
|
||||
@click="ide.openChanges(wt.repoId, wt.path)"
|
||||
@click="reveal(wt)"
|
||||
>
|
||||
<!-- L'index reste GLOBAL, volontairement : c'est la seule sortie d'une portée étroite (sinon
|
||||
impossible de rejoindre un autre projet, donc portée inéchappable). On marque juste ce
|
||||
qui tombe dans la portée courante. -->
|
||||
<span
|
||||
class="w-1 shrink-0 self-stretch rounded-full"
|
||||
:class="inScope({ repoId: wt.repoId, wtPath: wt.path }) && scoped ? 'bg-accent/70' : 'bg-transparent'"
|
||||
/>
|
||||
<component :is="wt.isMain ? Home : GitBranch" :size="13" class="shrink-0 text-fg-subtle" />
|
||||
<span class="truncate font-mono">{{ wt.branch ?? t('worktrees.detached') }}</span>
|
||||
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="ml-auto shrink-0" />
|
||||
@@ -64,6 +71,7 @@ import { useIdeStore } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useGroupsStore } from '../../stores/groups';
|
||||
import { dirtyFileCount, pendingWorktreeCount, sortForIndex } from '../../lib/changes-model';
|
||||
import { useContextScope } from '../../composables/useContextScope';
|
||||
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
|
||||
import BaseBadge from '../ui/BaseBadge.vue';
|
||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||
@@ -71,6 +79,17 @@ import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const { inScope, scoped } = useContextScope();
|
||||
|
||||
/**
|
||||
* Ouvre le détail d'un worktree dans la zone centrale. Si ce worktree est HORS de la portée courante
|
||||
* (on suit un terminal d'un autre projet), on élargit la vue : sans cela le clic ne montrerait rien,
|
||||
* et la portée serait inéchappable depuis l'index.
|
||||
*/
|
||||
function reveal(wt: { repoId: string; path: string }): void {
|
||||
if (scoped.value && !inScope.value({ repoId: wt.repoId, wtPath: wt.path })) ide.changesScope = 'all';
|
||||
ide.openChanges(wt.repoId, wt.path);
|
||||
}
|
||||
const groupsStore = useGroupsStore();
|
||||
|
||||
const repos = computed(() => worktrees.visibleRepos);
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
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="ide.openTerminal(s.id)"
|
||||
@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>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<!-- Pas d'index Git sur mobile : c'est une affordance de la sidebar desktop. Sur téléphone,
|
||||
le panneau 'changes' est rendu par EditorArea, qui suit `ide.centerMode`. -->
|
||||
<ProjectTree v-if="ide.mobilePanel === 'explorer'" />
|
||||
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" />
|
||||
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" single />
|
||||
<SessionsPanel v-else-if="ide.mobilePanel === 'sessions'" />
|
||||
<GroupsPanel v-else-if="ide.mobilePanel === 'groups'" />
|
||||
<EditorArea v-else />
|
||||
@@ -50,7 +50,7 @@
|
||||
</main>
|
||||
|
||||
<template v-if="ide.bottomVisible">
|
||||
<PanelSplitter v-model="ide.bottomHeight" axis="y" :min="120" :max="640" invert />
|
||||
<PanelSplitter v-model="ide.bottomHeight" axis="y" :min="DOCK_MIN_HEIGHT" :max="dockMaxHeight(viewportHeight)" invert />
|
||||
<section class="shrink-0 overflow-hidden border-t border-border" :style="{ height: `${ide.bottomHeight}px` }">
|
||||
<TerminalDock />
|
||||
</section>
|
||||
@@ -77,6 +77,7 @@ import { useI18n } from 'vue-i18n';
|
||||
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 { DOCK_MIN_HEIGHT, dockMaxHeight, dockOpenHeight } from '../../lib/dock-model';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import { useModalsStore } from '../../stores/modals';
|
||||
@@ -121,9 +122,14 @@ let mql: MediaQueryList | null = null;
|
||||
const syncMobile = (): void => {
|
||||
isMobile.value = !!mql?.matches;
|
||||
};
|
||||
/** Hauteur de fenêtre, suivie : les bornes du dock en dérivent (un plafond figé était trop bas). */
|
||||
const viewportHeight = ref(1000);
|
||||
// Les tailles de panneaux sont persistées : une fenêtre plus petite qu'à la session précédente
|
||||
// pouvait faire sortir la barre de statut du cadre. On reclampe au montage et à chaque resize.
|
||||
const clampPanels = (): void => ide.clampToViewport(window.innerWidth, window.innerHeight);
|
||||
const clampPanels = (): void => {
|
||||
viewportHeight.value = window.innerHeight;
|
||||
ide.clampToViewport(window.innerWidth, window.innerHeight);
|
||||
};
|
||||
onMounted(() => {
|
||||
mql = window.matchMedia('(max-width: 767px)');
|
||||
syncMobile();
|
||||
@@ -131,6 +137,20 @@ onMounted(() => {
|
||||
clampPanels();
|
||||
window.addEventListener('resize', clampPanels);
|
||||
});
|
||||
|
||||
// Ouverture du dock, ou apparition d'une colonne : on garantit une hauteur exploitable sans JAMAIS
|
||||
// réduire celle que l'utilisateur a réglée à la main. À 240 px un terminal n'affiche qu'une
|
||||
// vingtaine de lignes, ce qui casse le rendu d'un TUI, et devient intenable à deux côte à côte.
|
||||
// Côté vue et pas côté store : le store ne connaît pas le viewport (il tourne aussi sous node).
|
||||
watch(
|
||||
() => [ide.bottomVisible, ide.dockColumns.length] as const,
|
||||
([visible, columns], previous) => {
|
||||
if (!visible) return;
|
||||
const [wasVisible, previousColumns] = previous ?? [false, 0];
|
||||
if (visible === wasVisible && columns <= previousColumns) return;
|
||||
ide.bottomHeight = Math.max(ide.bottomHeight, dockOpenHeight(window.innerHeight));
|
||||
},
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
mql?.removeEventListener('change', syncMobile);
|
||||
window.removeEventListener('resize', clampPanels);
|
||||
|
||||
@@ -32,8 +32,15 @@ const label = props.label ?? t('ide.resizePanel');
|
||||
|
||||
const { onPointerDown } = useSplitter({
|
||||
axis: props.axis,
|
||||
min: props.min,
|
||||
max: props.max,
|
||||
// Bornes en GETTERS, pas par valeur : elles sont désormais dérivées du viewport (plafond du dock)
|
||||
// et de la largeur mesurée de la piste (colonnes de terminaux). Capturées au setup, un drag
|
||||
// continuait de clamper sur les bornes du PREMIER rendu, alors que le clavier lisait les vraies.
|
||||
get min() {
|
||||
return props.min;
|
||||
},
|
||||
get max() {
|
||||
return props.max;
|
||||
},
|
||||
invert: props.invert,
|
||||
get: () => size.value,
|
||||
set: (v) => {
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
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'"
|
||||
:title="sessionTitle(s)"
|
||||
@click="ide.openTerminal(s.id)"
|
||||
@click="(e: MouseEvent) => ide.openTerminal(s.id, { split: e.altKey })"
|
||||
>
|
||||
<SessionStateBadge :session="s" />
|
||||
<span class="min-w-0 truncate font-mono">{{ s.title ?? s.command }}</span>
|
||||
@@ -113,6 +113,7 @@ import { sessionBelongsToWorktree, type RepoSummary, type SessionSummary, type W
|
||||
import { ApiError } from '../../lib/api';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useContextScope } from '../../composables/useContextScope';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import { useGroupsStore } from '../../stores/groups';
|
||||
import { useToastsStore } from '../../stores/toasts';
|
||||
@@ -130,6 +131,7 @@ const props = defineProps<{ repo: RepoSummary }>();
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const { inScope, scoped } = useContextScope();
|
||||
const sessions = useSessionsStore();
|
||||
const groups = useGroupsStore();
|
||||
const toasts = useToastsStore();
|
||||
@@ -249,6 +251,8 @@ function openWtMenu(e: MouseEvent, wt: WorktreeSummary): void {
|
||||
// Le détail git vit désormais dans la zone centrale : on rend le worktree actif, on déplie son bloc
|
||||
// et on bascule la vue Changements au premier plan (la sidebar n'en garde que l'index).
|
||||
function openGitPanel(wt: WorktreeSummary): void {
|
||||
// Hors portée courante (on suit un terminal ailleurs) : on élargit, sinon le clic n'ouvre rien.
|
||||
if (scoped.value && !inScope.value({ repoId: wt.repoId, wtPath: wt.path })) ide.changesScope = 'all';
|
||||
ide.openChanges(wt.repoId, wt.path);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
MoreHorizontal,
|
||||
MoreVertical,
|
||||
SquareTerminal,
|
||||
SquareSplitHorizontal,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
@@ -100,7 +101,9 @@ const rows = computed(() => store.sessions);
|
||||
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
|
||||
|
||||
function onRowClick(e: MouseEvent, s: SessionSummary): void {
|
||||
if (s.live || s.attachable) ide.openTerminal(s.id);
|
||||
// Alt+clic : ouvrir dans une colonne à côté plutôt qu'en onglet (raccourci du dock, sans
|
||||
// raccourci clavier global : un TUI mange les combos, et le handler de la palette est en capture).
|
||||
if (s.live || s.attachable) ide.openTerminal(s.id, { split: e.altKey });
|
||||
else openRowMenu(e, s);
|
||||
}
|
||||
|
||||
@@ -118,6 +121,9 @@ function menuFor(s: SessionSummary): ContextMenuItem[] {
|
||||
const items: ContextMenuItem[] = [];
|
||||
if (s.attachable) {
|
||||
items.push({ label: t('sessions.open'), icon: SquareTerminal, onSelect: () => ide.openTerminal(s.id) });
|
||||
if (ide.canSplitTerminal || ide.dockSessionIds.length === 0) {
|
||||
items.push({ label: t('ide.openTerminalBeside'), icon: SquareSplitHorizontal, onSelect: () => ide.openTerminal(s.id, { split: true }) });
|
||||
}
|
||||
items.push({ label: t('sessions.kill'), icon: Trash2, danger: true, onSelect: () => confirmKill(s) });
|
||||
} else if (s.live) {
|
||||
items.push({ label: t('sessions.observe'), icon: Eye, onSelect: () => ide.openTerminal(s.id) });
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col bg-surface-0">
|
||||
<TerminalDockTabs />
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<!-- toutes les sessions restent montées (v-show) pour préserver l'attache WS ; seule l'active
|
||||
est visible. Fermer un onglet démonte réellement la TerminalView -> détache propre. -->
|
||||
<div
|
||||
v-for="sid in ide.dockSessionIds"
|
||||
v-show="sid === ide.activeDockSessionId"
|
||||
:key="sid"
|
||||
class="absolute inset-0 flex flex-col"
|
||||
>
|
||||
<DialogPrompt v-if="isWaiting(sid)" :session="sessionOf(sid)!" class="px-2 pt-2" />
|
||||
<div class="min-h-0 flex-1">
|
||||
<TerminalView :session-id="sid" :mode="modeOf(sid)" />
|
||||
<!-- Mobile : le dock occupe tout l'écran, une seule colonne visuelle. L'ÉTAT des colonnes n'est
|
||||
pas touché, sinon une rotation d'écran détruirait la mise en page du bureau. -->
|
||||
<template v-if="single">
|
||||
<TerminalDockTabs :session-ids="ide.dockSessionIds" :active-session-id="ide.activeDockSessionId" />
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div v-for="sid in ide.dockSessionIds" v-show="sid === ide.activeDockSessionId" :key="sid" class="absolute inset-0">
|
||||
<TerminalPane :session-id="sid" />
|
||||
</div>
|
||||
<EmptyState v-if="ide.dockSessionIds.length === 0" :icon="SquareTerminal" :title="t('ide.noTerminal')" :hint="t('ide.noTerminalHint')" class="m-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Desktop : une grille de colonnes redimensionnables. Les parts sont en `fr` (l'état est en
|
||||
ratios) et les poignées en `auto` : aucune arithmétique de pixels dans le template. -->
|
||||
<div v-else ref="track" class="grid min-h-0 flex-1" :style="{ gridTemplateColumns: gridTemplate }">
|
||||
<template v-for="(column, i) in ide.dockColumns" :key="column.id">
|
||||
<PanelSplitter
|
||||
v-if="i > 0"
|
||||
axis="x"
|
||||
:model-value="splitPx(i - 1)"
|
||||
:min="splitMin(i - 1)"
|
||||
:max="splitMax(i - 1)"
|
||||
:label="t('ide.resizeColumns')"
|
||||
@update:model-value="(px: number) => ide.setDockColumnSplit(i - 1, px / usableWidth)"
|
||||
/>
|
||||
<TerminalDockColumn
|
||||
:column="column"
|
||||
:active="column.id === ide.activeDockColumnId"
|
||||
:index="i"
|
||||
:last="i === ide.dockColumns.length - 1"
|
||||
/>
|
||||
</template>
|
||||
<EmptyState
|
||||
v-if="ide.dockSessionIds.length === 0"
|
||||
v-if="ide.dockColumns.length === 0"
|
||||
:icon="SquareTerminal"
|
||||
:title="t('ide.noTerminal')"
|
||||
:hint="t('ide.noTerminalHint')"
|
||||
@@ -27,24 +44,49 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Conteneur du dock terminaux : N colonnes côte à côte (plafond MAX_DOCK_COLUMNS), séparées par des
|
||||
// poignées. La hauteur du dock est pilotée par IdeShell ; la zone centrale au-dessus se réajuste
|
||||
// seule (flex-1), il n'y a donc rien à recalculer ici.
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SquareTerminal } from '@lucide/vue';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import { MIN_DOCK_COLUMN_RATIO } from '../../lib/dock-model';
|
||||
import TerminalDockTabs from './TerminalDockTabs.vue';
|
||||
import TerminalView from '../TerminalView.vue';
|
||||
import DialogPrompt from '../DialogPrompt.vue';
|
||||
import TerminalDockColumn from './TerminalDockColumn.vue';
|
||||
import TerminalPane from './TerminalPane.vue';
|
||||
import PanelSplitter from './PanelSplitter.vue';
|
||||
import EmptyState from '../ui/EmptyState.vue';
|
||||
|
||||
withDefaults(defineProps<{ single?: boolean }>(), { single: false });
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const sessions = useSessionsStore();
|
||||
const track = useTemplateRef<HTMLDivElement>('track');
|
||||
/** Largeur mesurée de la grille : le pont entre l'état (ratios) et le splitter (pixels). */
|
||||
const trackWidth = ref(0);
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
const sessionOf = (sid: string): SessionSummary | null => sessions.sessions.find((s) => s.id === sid) ?? null;
|
||||
const modeOf = (sid: string): 'interactive' | 'observer' => (sessionOf(sid)?.attachable ? 'interactive' : 'observer');
|
||||
const isWaiting = (sid: string): boolean => {
|
||||
const s = sessionOf(sid);
|
||||
return !!s && s.live && s.activity === 'waiting' && !!s.dialog;
|
||||
};
|
||||
onMounted(() => {
|
||||
if (!track.value) return;
|
||||
trackWidth.value = track.value.clientWidth;
|
||||
observer = new ResizeObserver((entries) => {
|
||||
for (const e of entries) trackWidth.value = e.contentRect.width;
|
||||
});
|
||||
observer.observe(track.value);
|
||||
});
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
|
||||
const gridTemplate = computed(() => ide.dockColumns.map((c) => `${c.ratio}fr`).join(' auto '));
|
||||
|
||||
/** Largeur du trait d'une poignée (`w-1` de PanelSplitter) : elle ne fait pas partie des colonnes. */
|
||||
const SPLITTER_PX = 4;
|
||||
/** Largeur réellement partagée par les colonnes (les poignées prennent leur propre place). */
|
||||
const usableWidth = computed(() => Math.max(1, trackWidth.value - Math.max(0, ide.dockColumns.length - 1) * SPLITTER_PX));
|
||||
|
||||
const cumulative = (index: number): number => ide.dockColumns.slice(0, index + 1).reduce((sum, c) => sum + c.ratio, 0);
|
||||
const splitPx = (index: number): number => Math.round(cumulative(index) * usableWidth.value);
|
||||
const splitMin = (index: number): number =>
|
||||
Math.round((cumulative(index - 1) + MIN_DOCK_COLUMN_RATIO) * usableWidth.value);
|
||||
const splitMax = (index: number): number => Math.round((cumulative(index + 1) - MIN_DOCK_COLUMN_RATIO) * usableWidth.value);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<!-- L'anneau suit l'ÉTAT (colonne active), pas seulement `focus-within` : il doit rester visible
|
||||
quand on va cliquer dans la sidebar, puisque la frappe reviendra bien dans ce terminal. -->
|
||||
<div
|
||||
class="flex h-full min-h-0 min-w-0 flex-col"
|
||||
:class="[
|
||||
active ? 'ring-1 ring-inset ring-accent/60' : 'focus-within:ring-1 focus-within:ring-inset focus-within:ring-accent/30',
|
||||
// Filet visuel entre colonnes : la poignée de redimensionnement ne se voit qu'au survol, deux
|
||||
// terminaux se touchaient donc sans frontière lisible.
|
||||
index > 0 ? 'border-l border-border' : '',
|
||||
]"
|
||||
:aria-label="t('ide.terminalColumn', { n: index + 1 })"
|
||||
@pointerdown.capture="ide.focusDockColumn(column.id)"
|
||||
>
|
||||
<TerminalDockTabs :session-ids="column.sessionIds" :active-session-id="column.activeSessionId" :first="index === 0" :last="last" />
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<!-- Tous les onglets de la colonne restent MONTÉS (v-show) : l'attache WS et le scrollback
|
||||
survivent au changement d'onglet. Fermer un onglet démonte, donc détache proprement. -->
|
||||
<div v-for="sid in column.sessionIds" v-show="sid === column.activeSessionId" :key="sid" class="absolute inset-0">
|
||||
<TerminalPane :ref="(el) => registerPane(sid, el)" :session-id="sid" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Une colonne du dock : sa barre d'onglets et ses panes superposés. La frappe doit toujours arriver
|
||||
// dans le terminal visible de la colonne ACTIVE, y compris quand l'activation vient d'un clic
|
||||
// ailleurs (arbre, panneau, palette) : d'où le focus impératif ci-dessous.
|
||||
import { watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { DockColumn } from '../../lib/dock-model';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import TerminalDockTabs from './TerminalDockTabs.vue';
|
||||
import TerminalPane from './TerminalPane.vue';
|
||||
|
||||
const props = defineProps<{ column: DockColumn; active: boolean; index: number; last: boolean }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
|
||||
interface PaneApi {
|
||||
focus: () => void;
|
||||
refit: () => void;
|
||||
}
|
||||
const panes = new Map<string, PaneApi>();
|
||||
function registerPane(sid: string, el: unknown): void {
|
||||
if (el) panes.set(sid, el as PaneApi);
|
||||
else panes.delete(sid);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.active, props.column.activeSessionId] as const,
|
||||
([isActive, sid]) => {
|
||||
if (!isActive) return;
|
||||
// Le pane vient peut-être d'être révélé : re-mesurer avant de rendre la frappe (xterm mesuré à
|
||||
// 0 tant qu'il était caché donnerait un TUI déformé).
|
||||
const pane = panes.get(sid);
|
||||
pane?.refit();
|
||||
pane?.focus();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div class="no-scrollbar flex h-[var(--ide-tab-h)] shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface-1">
|
||||
<div class="flex shrink-0 items-center gap-1.5 px-2 label-mono">
|
||||
<!-- Le titre et les feux tricolores n'appartiennent qu'à la première colonne : répétés, ils
|
||||
mangeraient la place des onglets et brouilleraient la lecture du dock. -->
|
||||
<div v-if="first" class="flex shrink-0 items-center gap-1.5 px-2 label-mono">
|
||||
<span class="flex items-center gap-1" aria-hidden="true">
|
||||
<span class="h-2 w-2 rounded-full bg-danger" />
|
||||
<span class="h-2 w-2 rounded-full bg-warn" />
|
||||
@@ -10,16 +12,27 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="sid in ide.dockSessionIds"
|
||||
v-for="sid in sessionIds"
|
||||
:key="sid"
|
||||
class="group flex cursor-pointer items-center gap-1.5 border-l border-border px-2.5 text-xs select-none"
|
||||
:class="sid === ide.activeDockSessionId ? 'bg-surface-0 text-fg' : 'text-fg-muted hover:bg-surface-2/50'"
|
||||
:class="sid === activeSessionId ? 'bg-surface-0 text-fg' : 'text-fg-muted hover:bg-surface-2/50'"
|
||||
:title="titleFor(sid)"
|
||||
@click="ide.focusTerminal(sid)"
|
||||
@mousedown.middle.prevent="ide.closeTerminal(sid)"
|
||||
>
|
||||
<SessionStateBadge v-if="sessionOf(sid)" :session="sessionOf(sid)!" />
|
||||
<span class="truncate font-mono">{{ titleFor(sid) }}</span>
|
||||
<button
|
||||
v-if="sessionIds.length > 1"
|
||||
type="button"
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-surface-3"
|
||||
:class="canSplit ? '' : 'cursor-not-allowed opacity-40 group-hover:opacity-40'"
|
||||
:disabled="!canSplit"
|
||||
:title="canSplit ? t('ide.splitTerminal') : t('ide.splitTerminalFull')"
|
||||
@click.stop="ide.moveTerminal(sid, { split: true })"
|
||||
>
|
||||
<SquareSplitHorizontal :size="13" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-surface-3"
|
||||
@@ -30,9 +43,21 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Fusion et repli ne concernent que la dernière colonne : sinon trois chevrons s'alignent. -->
|
||||
<button
|
||||
v-if="last && ide.dockColumns.length > 1"
|
||||
type="button"
|
||||
class="ml-auto shrink-0 px-2 text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
:title="t('ide.mergeColumns')"
|
||||
@click="ide.mergeDockColumns()"
|
||||
>
|
||||
<Columns2 :size="14" />
|
||||
</button>
|
||||
<button
|
||||
v-if="last"
|
||||
type="button"
|
||||
class="shrink-0 px-2 text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
:class="ide.dockColumns.length > 1 ? '' : 'ml-auto'"
|
||||
:title="t('common.close')"
|
||||
@click="ide.toggleBottom()"
|
||||
>
|
||||
@@ -42,8 +67,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Barre d'onglets d'UNE colonne du dock. En mode `single` (mobile), la colonne couvre tout le dock :
|
||||
// on lui passe alors la liste complète des sessions.
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ChevronDown, X } from '@lucide/vue';
|
||||
import { ChevronDown, Columns2, SquareSplitHorizontal, X } from '@lucide/vue';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
@@ -51,11 +79,27 @@ import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { sessionLabel } from '../../lib/session-label';
|
||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sessionIds: string[];
|
||||
activeSessionId: string | null;
|
||||
/** première colonne : porte le titre du dock. */
|
||||
first?: boolean;
|
||||
/** dernière colonne : porte les actions globales (fusion, repli). */
|
||||
last?: boolean;
|
||||
}>(),
|
||||
{ first: true, last: true },
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const sessions = useSessionsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
|
||||
const canSplit = computed(() => ide.canSplitTerminal);
|
||||
const sessionIds = computed(() => props.sessionIds);
|
||||
const activeSessionId = computed(() => props.activeSessionId);
|
||||
|
||||
const sessionOf = (sid: string): SessionSummary | null => sessions.sessions.find((s) => s.id === sid) ?? null;
|
||||
|
||||
// Libellé compact, mutualisé avec les panneaux (lib/session-label) : le calcul était dupliqué ici, avec
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<!-- Tant que la liste des sessions n'est pas revenue, on n'attache RIEN : `attachable` serait lu
|
||||
sur une liste vide, l'attache partirait en mode observateur (lecture seule) et le terminal
|
||||
resterait muet pour toujours, sans que rien ne le dise. -->
|
||||
<div v-if="!sessions.loaded" class="flex h-full items-center justify-center px-4">
|
||||
<SkeletonRow class="w-full max-w-md" />
|
||||
</div>
|
||||
<EmptyState v-else-if="!session" :icon="SquareTerminal" :title="t('terminal.sessionEnded')" class="m-6" />
|
||||
<div v-else class="flex h-full min-h-0 flex-col">
|
||||
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
||||
<div class="min-h-0 flex-1">
|
||||
<TerminalView ref="view" :key="`${sessionId}|${mode}`" :session-id="sessionId" :mode="mode" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Un pane du dock : le dialogue en attente (réponse sans clavier) au-dessus du terminal.
|
||||
// Extrait de TerminalDock pour qu'une colonne puisse en empiler plusieurs, et pour exposer au
|
||||
// parent le focus du terminal (la frappe doit suivre la colonne active).
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SquareTerminal } from '@lucide/vue';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import TerminalView from '../TerminalView.vue';
|
||||
import DialogPrompt from '../DialogPrompt.vue';
|
||||
import EmptyState from '../ui/EmptyState.vue';
|
||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||
|
||||
const props = defineProps<{ sessionId: string }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessions = useSessionsStore();
|
||||
const view = useTemplateRef<{ focus: () => void; refit: () => void }>('view');
|
||||
|
||||
const session = computed(() => sessions.sessions.find((s) => s.id === props.sessionId) ?? null);
|
||||
// Une session non attachable (découverte, ou morte) reste lisible : on l'ouvre en observateur.
|
||||
const mode = computed<'interactive' | 'observer'>(() => (session.value?.attachable ? 'interactive' : 'observer'));
|
||||
const isWaiting = computed(() => {
|
||||
const s = session.value;
|
||||
return !!s && s.live && s.activity === 'waiting' && !!s.dialog;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
focus: (): void => view.value?.focus(),
|
||||
refit: (): void => view.value?.refit(),
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user