From 6acd4a16fdef01ab32518e78d0c431a815072f60 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 17 Jul 2026 16:59:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20store=20IDE=20(onglets,=20dock=20t?= =?UTF-8?q?erminaux,=20panneaux,=20r=C3=A9conciliation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/web/src/stores/ide.ts | Bin 0 -> 9952 bytes packages/web/test/ide-store.test.ts | 139 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 packages/web/src/stores/ide.ts create mode 100644 packages/web/test/ide-store.test.ts diff --git a/packages/web/src/stores/ide.ts b/packages/web/src/stores/ide.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d7d3e764c1287e5e1ee9ba22072eb2211ed5615 GIT binary patch literal 9952 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 MdF20W<*OG5`Po literal 0 HcmV?d00001 diff --git a/packages/web/test/ide-store.test.ts b/packages/web/test/ide-store.test.ts new file mode 100644 index 0000000..94d6fa2 --- /dev/null +++ b/packages/web/test/ide-store.test.ts @@ -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(); + 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 + }); +});