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.
294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
// Store IDE : onglets (ouverture/dedup/fermeture), dock terminaux, activité, réconciliation.
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import { createPinia, setActivePinia } from 'pinia';
|
||
import { MOBILE_PANELS, parseWtKey, useIdeStore, tabId, wtKey } from '../src/stores/ide';
|
||
|
||
function fakeStorage() {
|
||
const m = new Map<string, string>();
|
||
return {
|
||
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
|
||
setItem: (k: string, v: string) => void m.set(k, v),
|
||
removeItem: (k: string) => void m.delete(k),
|
||
clear: () => m.clear(),
|
||
};
|
||
}
|
||
|
||
describe('store IDE', () => {
|
||
beforeEach(() => {
|
||
(globalThis as unknown as { localStorage: unknown }).localStorage = fakeStorage();
|
||
setActivePinia(createPinia());
|
||
});
|
||
|
||
it('openFile ouvre un onglet, fixe actif + contexte', () => {
|
||
const ide = useIdeStore();
|
||
ide.openFile('r1', '/wt/a', 'src/x.ts');
|
||
expect(ide.editorTabs).toHaveLength(1);
|
||
expect(ide.activeTabId).toBe(tabId('r1', '/wt/a', 'src/x.ts'));
|
||
expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' });
|
||
expect(ide.activeTab?.file).toBe('src/x.ts');
|
||
});
|
||
|
||
it('openFile est idempotent (pas de doublon), refocalise l onglet existant', () => {
|
||
const ide = useIdeStore();
|
||
ide.openFile('r1', '/wt/a', 'x.ts');
|
||
ide.openFile('r1', '/wt/b', 'y.ts');
|
||
ide.openFile('r1', '/wt/a', 'x.ts'); // ré-ouverture
|
||
expect(ide.editorTabs).toHaveLength(2);
|
||
expect(ide.activeTabId).toBe(tabId('r1', '/wt/a', 'x.ts'));
|
||
});
|
||
|
||
it('closeTab recalcule l onglet actif sur le voisin', () => {
|
||
const ide = useIdeStore();
|
||
ide.openFile('r', '/w', 'a');
|
||
ide.openFile('r', '/w', 'b');
|
||
ide.openFile('r', '/w', 'c'); // actif = c
|
||
ide.setActiveTab(tabId('r', '/w', 'b')); // actif = b (index 1)
|
||
ide.closeTab(tabId('r', '/w', 'b'));
|
||
expect(ide.editorTabs).toHaveLength(2);
|
||
// le voisin qui a glissé à l index 1 est c
|
||
expect(ide.activeTabId).toBe(tabId('r', '/w', 'c'));
|
||
});
|
||
|
||
it('closeTab sur le dernier onglet vide l actif', () => {
|
||
const ide = useIdeStore();
|
||
ide.openFile('r', '/w', 'a');
|
||
ide.closeTab(tabId('r', '/w', 'a'));
|
||
expect(ide.editorTabs).toHaveLength(0);
|
||
expect(ide.activeTabId).toBeNull();
|
||
});
|
||
|
||
it('closeTabsForWorktree retire tous les onglets d un worktree', () => {
|
||
const ide = useIdeStore();
|
||
ide.openFile('r', '/w1', 'a');
|
||
ide.openFile('r', '/w1', 'b');
|
||
ide.openFile('r', '/w2', 'c');
|
||
ide.closeTabsForWorktree('r', '/w1');
|
||
expect(ide.editorTabs.map((t) => t.file)).toEqual(['c']);
|
||
});
|
||
|
||
it('moveTab réordonne', () => {
|
||
const ide = useIdeStore();
|
||
ide.openFile('r', '/w', 'a');
|
||
ide.openFile('r', '/w', 'b');
|
||
ide.openFile('r', '/w', 'c');
|
||
ide.moveTab(0, 2); // a -> fin
|
||
expect(ide.editorTabs.map((t) => t.file)).toEqual(['b', 'c', 'a']);
|
||
});
|
||
|
||
it('setTabDirty alimente hasUnsaved / isDirty', () => {
|
||
const ide = useIdeStore();
|
||
ide.openFile('r', '/w', 'a');
|
||
const id = tabId('r', '/w', 'a');
|
||
expect(ide.hasUnsaved).toBe(false);
|
||
ide.setTabDirty(id, true);
|
||
expect(ide.isDirty(id)).toBe(true);
|
||
expect(ide.hasUnsaved).toBe(true);
|
||
ide.setTabDirty(id, false);
|
||
expect(ide.hasUnsaved).toBe(false);
|
||
});
|
||
|
||
it('dock : openTerminal ajoute + focus + ouvre le dock ; sans doublon', () => {
|
||
const ide = useIdeStore();
|
||
ide.openTerminal('s1');
|
||
ide.openTerminal('s2');
|
||
ide.openTerminal('s1'); // déjà présent
|
||
expect(ide.dockSessionIds).toEqual(['s1', 's2']);
|
||
expect(ide.activeDockSessionId).toBe('s1');
|
||
expect(ide.bottomVisible).toBe(true);
|
||
});
|
||
|
||
it('dock : closeTerminal recalcule l actif et referme si vide', () => {
|
||
const ide = useIdeStore();
|
||
ide.openTerminal('s1');
|
||
ide.openTerminal('s2');
|
||
ide.focusTerminal('s2');
|
||
ide.closeTerminal('s2');
|
||
expect(ide.dockSessionIds).toEqual(['s1']);
|
||
expect(ide.activeDockSessionId).toBe('s1');
|
||
ide.closeTerminal('s1');
|
||
expect(ide.dockSessionIds).toEqual([]);
|
||
expect(ide.activeDockSessionId).toBeNull();
|
||
expect(ide.bottomVisible).toBe(false);
|
||
});
|
||
|
||
// --- dock en colonnes (plusieurs terminaux visibles côte à côte) ---
|
||
|
||
it('dock : openTerminal { split } crée une seconde colonne, les deux panes restent visibles', () => {
|
||
const ide = useIdeStore();
|
||
ide.openTerminal('s1');
|
||
ide.openTerminal('s2', { split: true });
|
||
expect(ide.dockColumns).toHaveLength(2);
|
||
expect(ide.dockSessionIds).toEqual(['s1', 's2']);
|
||
expect(ide.visibleDockSessionIds).toEqual(['s1', 's2']);
|
||
expect(ide.activeDockSessionId).toBe('s2');
|
||
expect(ide.canSplitTerminal).toBe(true);
|
||
});
|
||
|
||
it('dock : splitTerminal détache le pane focalisé, mergeDockColumns ramène tout en une colonne', () => {
|
||
const ide = useIdeStore();
|
||
ide.openTerminal('s1');
|
||
ide.openTerminal('s2');
|
||
ide.splitTerminal(); // s2 est focalisé
|
||
expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1'], ['s2']]);
|
||
ide.mergeDockColumns();
|
||
expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1', 's2']]);
|
||
expect(ide.activeDockSessionId).toBe('s2');
|
||
});
|
||
|
||
it('dock : focusDockColumn change la colonne active, donc le terminal focalisé', () => {
|
||
const ide = useIdeStore();
|
||
ide.openTerminal('s1');
|
||
ide.openTerminal('s2', { split: true });
|
||
ide.focusDockColumn(ide.dockColumns[0]!.id);
|
||
expect(ide.activeDockSessionId).toBe('s1');
|
||
});
|
||
|
||
it('dock : une session n’est jamais dans deux colonnes (2e attache = PTY figé)', () => {
|
||
const ide = useIdeStore();
|
||
ide.openTerminal('s1');
|
||
ide.openTerminal('s2');
|
||
ide.openTerminal('s2', { split: true }); // déjà ouvert : déplacé, pas dupliqué
|
||
expect(ide.dockSessionIds.filter((s) => s === 's2')).toHaveLength(1);
|
||
expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1'], ['s2']]);
|
||
});
|
||
|
||
it('dock : fermer le dernier pane d’une colonne supprime la colonne, pas le dock', () => {
|
||
const ide = useIdeStore();
|
||
ide.openTerminal('s1');
|
||
ide.openTerminal('s2', { split: true });
|
||
ide.closeTerminal('s2');
|
||
expect(ide.dockColumns).toHaveLength(1);
|
||
expect(ide.bottomVisible).toBe(true);
|
||
ide.closeTerminal('s1');
|
||
expect(ide.dockColumns).toEqual([]);
|
||
expect(ide.bottomVisible).toBe(false);
|
||
});
|
||
|
||
it('dock : setDockColumnSplit redistribue la paire saisie', () => {
|
||
const ide = useIdeStore();
|
||
ide.openTerminal('s1');
|
||
ide.openTerminal('s2', { split: true });
|
||
ide.setDockColumnSplit(0, 0.7);
|
||
expect(ide.dockColumns[0]!.ratio).toBeCloseTo(0.7);
|
||
expect(ide.dockColumns[1]!.ratio).toBeCloseTo(0.3);
|
||
});
|
||
|
||
it('dock : migration silencieuse de l’ancien état mono-colonne, et oubli des clés legacy', () => {
|
||
localStorage.setItem('arb.ide.dock', JSON.stringify(['a', 'b']));
|
||
localStorage.setItem('arb.ide.activeDock', JSON.stringify('b'));
|
||
setActivePinia(createPinia());
|
||
const ide = useIdeStore();
|
||
expect(ide.dockColumns).toHaveLength(1);
|
||
expect(ide.dockSessionIds).toEqual(['a', 'b']);
|
||
expect(ide.activeDockSessionId).toBe('b');
|
||
expect(localStorage.getItem('arb.ide.dock')).toBeNull();
|
||
expect(localStorage.getItem('arb.ide.activeDock')).toBeNull();
|
||
});
|
||
|
||
it('toggleActivity : re-clic sur la vue active replie le panneau', () => {
|
||
const ide = useIdeStore();
|
||
expect(ide.activeActivity).toBe('explorer');
|
||
expect(ide.leftVisible).toBe(true);
|
||
ide.toggleActivity('explorer'); // même vue, ouverte -> repli
|
||
expect(ide.leftVisible).toBe(false);
|
||
ide.toggleActivity('git'); // autre vue -> ouvre
|
||
expect(ide.activeActivity).toBe('git');
|
||
expect(ide.leftVisible).toBe(true);
|
||
});
|
||
|
||
it('pruneDeadResources retire onglets/terminaux morts ; dirty gardé pour les conservés, nettoyé pour les retirés', () => {
|
||
const ide = useIdeStore();
|
||
ide.openFile('r', '/w1', 'a'); // worktree connu -> gardé
|
||
ide.openFile('r', '/w2', 'b'); // worktree inconnu -> retiré
|
||
ide.setTabDirty(tabId('r', '/w1', 'a'), true);
|
||
ide.setTabDirty(tabId('r', '/w2', 'b'), true);
|
||
ide.openTerminal('sLive');
|
||
ide.openTerminal('sDead');
|
||
ide.openTerminal('sDeadAlone', { split: true }); // colonne entière à supprimer
|
||
|
||
ide.pruneDeadResources(new Set(['sLive']), new Set([wtKey('r', '/w1')]));
|
||
|
||
expect(ide.dockColumns).toHaveLength(1);
|
||
expect(ide.dockColumns[0]!.ratio).toBeCloseTo(1);
|
||
|
||
expect(ide.editorTabs.map((t) => t.wtPath)).toEqual(['/w1']);
|
||
expect(ide.dockSessionIds).toEqual(['sLive']); // sDead retiré
|
||
expect(ide.isDirty(tabId('r', '/w1', 'a'))).toBe(true); // onglet gardé : dirty conservé
|
||
expect(ide.isDirty(tabId('r', '/w2', 'b'))).toBe(false); // onglet retiré : dirty nettoyé
|
||
expect(ide.hasUnsaved).toBe(true); // il reste l'onglet /w1 modifié
|
||
});
|
||
|
||
// --- zone centrale : mode fichiers / changements, et cohérence du layout mobile ---
|
||
|
||
it('setCenterMode amène le bon panneau au premier plan sur mobile', () => {
|
||
const ide = useIdeStore();
|
||
ide.setCenterMode('changes');
|
||
expect(ide.centerMode).toBe('changes');
|
||
expect(ide.mobilePanel).toBe('changes');
|
||
ide.setCenterMode('files');
|
||
expect(ide.mobilePanel).toBe('editor');
|
||
});
|
||
|
||
it('ouvrir un fichier ou un onglet ramène la zone centrale aux fichiers', () => {
|
||
const ide = useIdeStore();
|
||
ide.setCenterMode('changes');
|
||
ide.openFile('r1', '/wt/a', 'src/x.ts');
|
||
expect(ide.centerMode).toBe('files');
|
||
expect(ide.mobilePanel).toBe('editor');
|
||
|
||
ide.setCenterMode('changes');
|
||
ide.setActiveTab(tabId('r1', '/wt/a', 'src/x.ts'));
|
||
expect(ide.centerMode).toBe('files');
|
||
});
|
||
|
||
it('openChanges rend le worktree actif, déplie son bloc et bascule la vue', () => {
|
||
const ide = useIdeStore();
|
||
ide.openChanges('r1', '/wt/a');
|
||
expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' });
|
||
expect(ide.changesExpanded).toEqual([wtKey('r1', '/wt/a')]);
|
||
expect(ide.centerMode).toBe('changes');
|
||
ide.openChanges('r1', '/wt/a'); // idempotent
|
||
expect(ide.changesExpanded).toHaveLength(1);
|
||
});
|
||
|
||
it('toggleChangesWt bascule et réassigne le tableau (persistedRef non profond)', () => {
|
||
const ide = useIdeStore();
|
||
const before = ide.changesExpanded;
|
||
ide.toggleChangesWt('k1');
|
||
expect(ide.changesExpanded).toEqual(['k1']);
|
||
expect(ide.changesExpanded).not.toBe(before); // nouvelle référence, donc persistée
|
||
ide.toggleChangesWt('k1');
|
||
expect(ide.changesExpanded).toEqual([]);
|
||
});
|
||
|
||
it('setActivity pose un panneau mobile que le layout sait rendre', () => {
|
||
const ide = useIdeStore();
|
||
for (const view of ['explorer', 'sessions', 'groups'] as const) {
|
||
ide.setActivity(view);
|
||
expect(ide.mobilePanel).toBe(view);
|
||
expect(MOBILE_PANELS).toContain(ide.mobilePanel);
|
||
}
|
||
// 'git' est l'index de la sidebar desktop : sur mobile, c'est la vue Changements qui le remplace.
|
||
ide.setActivity('git');
|
||
expect(ide.mobilePanel).toBe('changes');
|
||
expect(ide.centerMode).toBe('changes');
|
||
expect(MOBILE_PANELS).toContain(ide.mobilePanel);
|
||
});
|
||
|
||
it('un mobilePanel persisté obsolète est ramené à une valeur gérée', () => {
|
||
localStorage.setItem('arb.ide.mobilePanel', JSON.stringify('sidebar')); // persistedRef stocke du JSON
|
||
setActivePinia(createPinia());
|
||
expect(useIdeStore().mobilePanel).toBe('explorer');
|
||
|
||
localStorage.setItem('arb.ide.mobilePanel', JSON.stringify('git'));
|
||
setActivePinia(createPinia());
|
||
expect(useIdeStore().mobilePanel).toBe('changes');
|
||
});
|
||
|
||
it('parseWtKey est l’inverse de wtKey', () => {
|
||
expect(parseWtKey(wtKey('r1', '/wt/mon projet'))).toEqual({ repoId: 'r1', wtPath: '/wt/mon projet' });
|
||
expect(parseWtKey('sans-separateur')).toBeNull();
|
||
expect(parseWtKey('')).toBeNull();
|
||
});
|
||
});
|