feat(web): store IDE (onglets, dock terminaux, panneaux, réconciliation)
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled

stores/ide.ts : état de vue de l'IDE (onglets éditeur, dock de sessions, activité, contexte worktree actif, tailles/visibilité des panneaux, expansion de l'arbre), en références uniquement (ids/chemins), persisté par persistedRef via mises à jour immuables. dirty reste volatile.

pruneDeadResources réconcilie au bootstrap (retire onglets/terminaux dont worktree/session n'existent plus). Store pur, sans couplage aux stores de données (résolution côté composants). 11 tests unitaires.
This commit is contained in:
2026-07-17 16:59:44 +02:00
parent cf60fa3a56
commit 6acd4a16fd
2 changed files with 139 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
// Store IDE : onglets (ouverture/dedup/fermeture), dock terminaux, activité, réconciliation.
import { describe, it, expect, beforeEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { 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 et reset dirty', () => {
const ide = useIdeStore();
ide.openFile('r', '/w1', 'a');
ide.openFile('r', '/w2', 'b');
ide.setTabDirty(tabId('r', '/w1', 'a'), 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']); // /w2 inconnu -> retiré
expect(ide.dockSessionIds).toEqual(['sLive']); // sDead retiré
expect(ide.hasUnsaved).toBe(false); // dirty remis à zéro
});
});