From cf60fa3a5686b6a0f7af51d18b8b771255b27c55 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 17 Jul 2026 16:55:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20infra=20IDE=20r=C3=A9utilisable=20?= =?UTF-8?q?(persisted-ref,=20splitter,=20bootstrap=20temps=20r=C3=A9el)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extrait persistedRef dans lib/persisted-ref.ts (partagé, testable en node). Ajoute useSplitter + components/ide/PanelSplitter.vue (poignée de resize générique par pointer events, largeur/hauteur persistable), généralisant le resize souris de WorkspaceView. Hisse le chargement initial et les abonnements temps réel dans useRealtimeBootstrap (monté par App.vue) : un seul propriétaire actif sur TOUS les layouts, y compris la vue IDE plein écran. AppShell et WorkspaceView n'en portent plus la responsabilité. Tests unitaires persisted-ref et useSplitter (10). --- packages/web/src/App.vue | 5 ++ .../web/src/components/ide/PanelSplitter.vue | 29 ++++++++++ .../web/src/components/layout/AppShell.vue | 35 +----------- .../src/composables/useRealtimeBootstrap.ts | 53 ++++++++++++++++++ packages/web/src/composables/useSplitter.ts | 56 +++++++++++++++++++ .../web/src/composables/useWorkspaceLayout.ts | 21 +------ packages/web/src/lib/persisted-ref.ts | 23 ++++++++ packages/web/src/views/WorkspaceView.vue | 5 +- packages/web/test/persisted-ref.test.ts | 47 ++++++++++++++++ packages/web/test/use-splitter.test.ts | 25 +++++++++ 10 files changed, 244 insertions(+), 55 deletions(-) create mode 100644 packages/web/src/components/ide/PanelSplitter.vue create mode 100644 packages/web/src/composables/useRealtimeBootstrap.ts create mode 100644 packages/web/src/composables/useSplitter.ts create mode 100644 packages/web/src/lib/persisted-ref.ts create mode 100644 packages/web/test/persisted-ref.test.ts create mode 100644 packages/web/test/use-splitter.test.ts diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index b7e80a7..0564a34 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -15,6 +15,7 @@ import { computed } from 'vue'; import { useRoute } from 'vue-router'; import { wsClient } from './lib/ws-client'; +import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap'; import WsBanner from './components/layout/WsBanner.vue'; import AppShell from './components/layout/AppShell.vue'; import ToastContainer from './components/ToastContainer.vue'; @@ -23,4 +24,8 @@ import CommandPalette from './components/CommandPalette.vue'; const route = useRoute(); const layout = computed(() => route.meta.layout ?? 'shell'); const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting'); + +// Propriétaire unique du chargement initial + temps réel : hissé ici (App.vue toujours monté) +// pour couvrir TOUS les layouts, y compris la vue IDE plein écran sans AppShell. +useRealtimeBootstrap(); diff --git a/packages/web/src/components/ide/PanelSplitter.vue b/packages/web/src/components/ide/PanelSplitter.vue new file mode 100644 index 0000000..b6bd4c1 --- /dev/null +++ b/packages/web/src/components/ide/PanelSplitter.vue @@ -0,0 +1,29 @@ + + + diff --git a/packages/web/src/components/layout/AppShell.vue b/packages/web/src/components/layout/AppShell.vue index 3d6a1e4..75578bd 100644 --- a/packages/web/src/components/layout/AppShell.vue +++ b/packages/web/src/components/layout/AppShell.vue @@ -18,40 +18,11 @@ diff --git a/packages/web/src/composables/useRealtimeBootstrap.ts b/packages/web/src/composables/useRealtimeBootstrap.ts new file mode 100644 index 0000000..0f96680 --- /dev/null +++ b/packages/web/src/composables/useRealtimeBootstrap.ts @@ -0,0 +1,53 @@ +import { watch } from 'vue'; +import { useAuthStore } from '../stores/auth'; +import { useSessionsStore } from '../stores/sessions'; +import { useWorktreesStore } from '../stores/worktrees'; +import { useGroupsStore } from '../stores/groups'; +import { useSettingsStore } from '../stores/settings'; + +/** + * Propriétaire unique du chargement initial et des abonnements temps réel (sessions, worktrees, + * groupes, réglages). Monté dans App.vue, il est donc actif quel que soit le layout, y compris + * la vue IDE plein écran (qui n'a pas d'AppShell). Démarre à l'authentification, s'arrête à la + * déconnexion. Les stores gardent leurs abonnements par `??=`, donc l'appel est idempotent. + */ +export function useRealtimeBootstrap(): void { + const auth = useAuthStore(); + const sessions = useSessionsStore(); + const worktrees = useWorktreesStore(); + const groups = useGroupsStore(); + const settings = useSettingsStore(); + + let started = false; + + function start(): void { + if (started) return; + started = true; + void worktrees.fetchAll(); + void sessions.fetchSessions(); + void groups.fetchGroups(); + void settings.fetch(); // alimente l'icône de nav Gitea et la vue Réglages + worktrees.startRealtime(); + sessions.startRealtime(); + groups.startRealtime(); + settings.startRealtime(); + } + + function stop(): void { + if (!started) return; + started = false; + worktrees.stopRealtime(); + sessions.stopRealtime(); + groups.stopRealtime(); + settings.stopRealtime(); + } + + watch( + () => auth.authenticated, + (v) => { + if (v) start(); + else if (v === false) stop(); + }, + { immediate: true }, + ); +} diff --git a/packages/web/src/composables/useSplitter.ts b/packages/web/src/composables/useSplitter.ts new file mode 100644 index 0000000..d3b0c35 --- /dev/null +++ b/packages/web/src/composables/useSplitter.ts @@ -0,0 +1,56 @@ +import { onBeforeUnmount } from 'vue'; + +export interface SplitterOptions { + axis: 'x' | 'y'; + min: number; + max: number; + /** Poignée à droite / en bas du panneau : le sens du drag est inversé. */ + invert?: boolean; + get: () => number; + set: (v: number) => void; +} + +/** + * Calcule la nouvelle taille (fonction pure, testable) : delta de position depuis le début du + * drag, inversion éventuelle, puis clamp entre min et max. + */ +export function nextSize( + startSize: number, + startPos: number, + curPos: number, + o: { min: number; max: number; invert?: boolean }, +): number { + let delta = curPos - startPos; + if (o.invert) delta = -delta; + return Math.min(o.max, Math.max(o.min, startSize + delta)); +} + +/** + * Poignée de redimensionnement générique (pointer events : souris, tactile, stylet). + * Généralise le resize à la souris de l'ancienne WorkspaceView. Écrit via `opts.set` + * (typiquement une ref persistée), ce qui permet de persister largeurs/hauteurs de panneaux. + */ +export function useSplitter(opts: SplitterOptions): { onPointerDown: (ev: PointerEvent) => void } { + let stop: (() => void) | null = null; + + function onPointerDown(ev: PointerEvent): void { + ev.preventDefault(); + const startPos = opts.axis === 'x' ? ev.clientX : ev.clientY; + const startSize = opts.get(); + const onMove = (e: PointerEvent): void => { + const cur = opts.axis === 'x' ? e.clientX : e.clientY; + opts.set(nextSize(startSize, startPos, cur, opts)); + }; + const onUp = (): void => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + stop = null; + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + stop = onUp; + } + + onBeforeUnmount(() => stop?.()); + return { onPointerDown }; +} diff --git a/packages/web/src/composables/useWorkspaceLayout.ts b/packages/web/src/composables/useWorkspaceLayout.ts index 073c0ce..ef55243 100644 --- a/packages/web/src/composables/useWorkspaceLayout.ts +++ b/packages/web/src/composables/useWorkspaceLayout.ts @@ -1,28 +1,9 @@ // Disposition de la vue IDE persistée en localStorage : largeurs des colonnes (desktop) et // panneau actif (mobile). Refs réactives ; chaque changement est réécrit en localStorage. -import { ref, watch, type Ref } from 'vue'; +import { persistedRef } from '../lib/persisted-ref'; export type MobilePanel = 'files' | 'editor' | 'diff' | 'terminal'; -function persistedRef(key: string, initial: T): Ref { - let start = initial; - try { - const raw = localStorage.getItem(key); - if (raw != null) start = JSON.parse(raw) as T; - } catch { - /* localStorage indisponible / JSON corrompu : on garde le défaut */ - } - const r = ref(start) as Ref; - watch(r, (v) => { - try { - localStorage.setItem(key, JSON.stringify(v)); - } catch { - /* quota / mode privé : silencieux */ - } - }); - return r; -} - export function useWorkspaceLayout() { // largeurs en px des colonnes latérales ; la colonne centrale (éditeur/diff) flexe. const leftWidth = persistedRef('arb.ws.leftWidth', 280); diff --git a/packages/web/src/lib/persisted-ref.ts b/packages/web/src/lib/persisted-ref.ts new file mode 100644 index 0000000..14f74ff --- /dev/null +++ b/packages/web/src/lib/persisted-ref.ts @@ -0,0 +1,23 @@ +import { ref, watch, type Ref } from 'vue'; + +// Ref réactive persistée en localStorage : lue au démarrage (défaut si absente ou corrompue), +// réécrite à chaque changement. Gardée par try/catch, donc utilisable sans localStorage +// (tests en environnement node, navigation privée, quota dépassé). +export function persistedRef(key: string, initial: T): Ref { + let start = initial; + try { + const raw = localStorage.getItem(key); + if (raw != null) start = JSON.parse(raw) as T; + } catch { + /* localStorage indisponible / JSON corrompu : on garde le défaut */ + } + const r = ref(start) as Ref; + watch(r, (v) => { + try { + localStorage.setItem(key, JSON.stringify(v)); + } catch { + /* quota / mode privé : silencieux */ + } + }); + return r; +} diff --git a/packages/web/src/views/WorkspaceView.vue b/packages/web/src/views/WorkspaceView.vue index 6147213..3163785 100644 --- a/packages/web/src/views/WorkspaceView.vue +++ b/packages/web/src/views/WorkspaceView.vue @@ -196,9 +196,8 @@ function startResize(which: 'left' | 'right', ev: MouseEvent): void { let unwatch: (() => void) | null = null; onMounted(() => { - // l'AppShell n'est pas monté en layout 'ide' → on assure données + temps réel ici. - worktrees.startRealtime(); - sessions.startRealtime(); + // Le temps réel est possédé globalement par useRealtimeBootstrap (App.vue) ; on ne garde ici + // que les garde-fous de données pour une navigation directe vers /workspace. if (worktrees.worktrees.length === 0) void worktrees.fetchAll(); else void worktrees.refreshRepoWorktrees(repoId); if (sessions.sessions.length === 0) void sessions.fetchSessions(); diff --git a/packages/web/test/persisted-ref.test.ts b/packages/web/test/persisted-ref.test.ts new file mode 100644 index 0000000..e069b87 --- /dev/null +++ b/packages/web/test/persisted-ref.test.ts @@ -0,0 +1,47 @@ +// persistedRef : lecture initiale depuis localStorage, repli sur défaut, réécriture, robustesse. +import { describe, it, expect, beforeEach } from 'vitest'; +import { nextTick } from 'vue'; +import { persistedRef } from '../src/lib/persisted-ref'; + +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('persistedRef', () => { + beforeEach(() => { + (globalThis as unknown as { localStorage: unknown }).localStorage = fakeStorage(); + }); + + it('lit la valeur initiale depuis localStorage si présente', () => { + localStorage.setItem('k', JSON.stringify(42)); + expect(persistedRef('k', 0).value).toBe(42); + }); + + it('retombe sur le défaut si la clé est absente', () => { + expect(persistedRef('missing', 7).value).toBe(7); + }); + + it('retombe sur le défaut si le JSON est corrompu', () => { + localStorage.setItem('bad', '{not json'); + expect(persistedRef('bad', 'def').value).toBe('def'); + }); + + it('réécrit en localStorage au changement', async () => { + const r = persistedRef('w', 1); + r.value = 99; + await nextTick(); + expect(JSON.parse(localStorage.getItem('w') as string)).toBe(99); + }); + + it('ne jette pas si localStorage est absent (env node sans DOM)', () => { + delete (globalThis as unknown as { localStorage?: unknown }).localStorage; + expect(() => persistedRef('x', 5)).not.toThrow(); + expect(persistedRef('x', 5).value).toBe(5); + }); +}); diff --git a/packages/web/test/use-splitter.test.ts b/packages/web/test/use-splitter.test.ts new file mode 100644 index 0000000..d7bdf11 --- /dev/null +++ b/packages/web/test/use-splitter.test.ts @@ -0,0 +1,25 @@ +// nextSize : calcul de la nouvelle taille d'un panneau (delta, inversion, clamp min/max). +import { describe, it, expect } from 'vitest'; +import { nextSize } from '../src/composables/useSplitter'; + +describe('useSplitter · nextSize', () => { + it('ajoute le delta de position (sens direct)', () => { + expect(nextSize(200, 100, 150, { min: 100, max: 400 })).toBe(250); + }); + + it('soustrait le delta quand invert (poignée à droite/bas)', () => { + expect(nextSize(200, 100, 150, { min: 100, max: 400, invert: true })).toBe(150); + }); + + it('clampe au maximum', () => { + expect(nextSize(380, 100, 200, { min: 100, max: 400 })).toBe(400); + }); + + it('clampe au minimum', () => { + expect(nextSize(120, 200, 100, { min: 100, max: 400 })).toBe(100); + }); + + it('taille inchangée si la position ne bouge pas', () => { + expect(nextSize(300, 150, 150, { min: 100, max: 400 })).toBe(300); + }); +});