diff --git a/packages/web/src/stores/ide.ts b/packages/web/src/stores/ide.ts new file mode 100644 index 0000000..5d7d3e7 Binary files /dev/null and b/packages/web/src/stores/ide.ts differ 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 + }); +});