Deux défauts d'usage de la zone centrale de l'IDE. 1. Ouvrir un fichier affichait une page vide. Le conteneur de l'unique instance Monaco vivait sous le `v-if="activeTab"` : monté sans onglet actif (premier usage, ou après avoir tout fermé), `onMounted` sortait en silence sur `!host.value`, `ready` restait faux pour toujours, et les deux watchers d'affichage étaient gardés par lui. Le conteneur est désormais toujours monté (v-show, jamais v-if : sous un v-if, fermer le dernier onglet détruisait le DOM et l'éditeur gardait une référence sur un noeud détaché) et l'éditeur est créé paresseusement depuis `showTab`, devenu l'unique entonnoir d'affichage. `useMonaco` ne mémoïse plus un échec, l'attente et l'erreur sont visibles, et la relecture disque ne se tait plus. Sur mobile, ouvrir un fichier amène enfin la zone centrale au premier plan et la valeur morte `mobilePanel = 'sidebar'` disparaît (migrée). Trouvé par l'E2E : seul l'`editor.worker` était fourni, donc ouvrir un `.ts` lançait le service TypeScript contre un worker qui n'en connaissait pas les méthodes, et jetait un rejet non géré à chaque ouverture. Les workers de langage sont fournis (chunks à la demande), diagnostics sémantiques coupés (sans tsconfig ni node_modules, ils inventeraient des erreurs). 2. Le statut git était à l'étroit et mono-projet. La zone centrale porte un mode « Fichiers / Changements » (bascule à icônes dans la barre d'onglets, donc accessible même sans onglet ouvert). La vue Changements montre un bloc par worktree pour TOUS les projets ouverts : fichiers indexés et non indexés, diff déplié sur place, commit, push, fetch, pull et historique par bloc. Le panneau Git de la barre latérale devient l'index (compteurs déjà diffusés par WS, donc zéro requête) et le badge de la barre d'activité, codé à 0, compte enfin les worktrees à traiter. Au passage : `canPush` était faux dans les deux sens (actif sans rien à pousser, bloqué en étant seulement en retard), l'amend d'un message seul est accepté comme le serveur le permettait déjà, le rebase est proposé dès qu'on est en retard, une confirmation d'abandon ne survit plus au rechargement de la liste, et `commit`/`fetch`/`push` émettent enfin `worktree_changes` (le compteur « n non poussés » restait périmé juste après un push). Vérifié : 528 tests, acceptations P7/P9/P14/P15, `verify-ui` (4 captures nouvelles) et un `verify-editor` qui prouve par clics réels l'ouverture d'un fichier sans onglet persisté, la fermeture/réouverture, la bascule mobile et le chunk éditeur absent. Ce dernier échoue bien si l'on remet le conteneur sous un v-if.
216 lines
8.3 KiB
TypeScript
216 lines
8.3 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);
|
||
});
|
||
|
||
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.pruneDeadResources(new Set(['sLive']), new Set([wtKey('r', '/w1')]));
|
||
|
||
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();
|
||
});
|
||
});
|