feat(web): infra IDE réutilisable (persisted-ref, splitter, bootstrap temps réel)
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).
This commit is contained in:
@@ -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();
|
||||
</script>
|
||||
|
||||
29
packages/web/src/components/ide/PanelSplitter.vue
Normal file
29
packages/web/src/components/ide/PanelSplitter.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
class="shrink-0 bg-transparent transition-colors hover:bg-accent/40"
|
||||
:class="axis === 'x' ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize'"
|
||||
role="separator"
|
||||
:aria-orientation="axis === 'x' ? 'vertical' : 'horizontal'"
|
||||
@pointerdown="onPointerDown"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Poignée de redimensionnement réutilisable pour les panneaux de l'IDE.
|
||||
// La taille est un v-model (px) que le parent persiste via le store IDE.
|
||||
import { useSplitter } from '../../composables/useSplitter';
|
||||
|
||||
const size = defineModel<number>({ required: true });
|
||||
const props = defineProps<{ axis: 'x' | 'y'; min: number; max: number; invert?: boolean }>();
|
||||
|
||||
const { onPointerDown } = useSplitter({
|
||||
axis: props.axis,
|
||||
min: props.min,
|
||||
max: props.max,
|
||||
invert: props.invert,
|
||||
get: () => size.value,
|
||||
set: (v) => {
|
||||
size.value = v;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -18,40 +18,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Coquille des vues authentifiées. Propriétaire unique du chargement initial et des abonnements
|
||||
// temps réel (sessions/worktrees/groups) pour toute la durée de la session : fiabilise le badge
|
||||
// de navigation, la palette ⌘K et la section « À traiter » quelle que soit la vue active.
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useGroupsStore } from '../../stores/groups';
|
||||
import { useSettingsStore } from '../../stores/settings';
|
||||
// Coquille des vues authentifiées (sidebar + contenu). Le chargement initial et les abonnements
|
||||
// temps réel sont désormais possédés par useRealtimeBootstrap (monté dans App.vue), pour couvrir
|
||||
// aussi la vue IDE plein écran qui n'utilise pas cette coquille.
|
||||
import AppSidebar from './AppSidebar.vue';
|
||||
import MobileTabBar from './MobileTabBar.vue';
|
||||
|
||||
defineProps<{ fullbleed?: boolean }>();
|
||||
|
||||
const sessions = useSessionsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const groups = useGroupsStore();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
onMounted(() => {
|
||||
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(); // P11 : réglages synchronisés en temps réel (multi-onglets/clients)
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
worktrees.stopRealtime();
|
||||
sessions.stopRealtime();
|
||||
groups.stopRealtime();
|
||||
settings.stopRealtime();
|
||||
});
|
||||
</script>
|
||||
|
||||
53
packages/web/src/composables/useRealtimeBootstrap.ts
Normal file
53
packages/web/src/composables/useRealtimeBootstrap.ts
Normal file
@@ -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 },
|
||||
);
|
||||
}
|
||||
56
packages/web/src/composables/useSplitter.ts
Normal file
56
packages/web/src/composables/useSplitter.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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<T>(key: string, initial: T): Ref<T> {
|
||||
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<T>;
|
||||
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<number>('arb.ws.leftWidth', 280);
|
||||
|
||||
23
packages/web/src/lib/persisted-ref.ts
Normal file
23
packages/web/src/lib/persisted-ref.ts
Normal file
@@ -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<T>(key: string, initial: T): Ref<T> {
|
||||
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<T>;
|
||||
watch(r, (v) => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(v));
|
||||
} catch {
|
||||
/* quota / mode privé : silencieux */
|
||||
}
|
||||
});
|
||||
return r;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
47
packages/web/test/persisted-ref.test.ts
Normal file
47
packages/web/test/persisted-ref.test.ts
Normal file
@@ -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<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('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);
|
||||
});
|
||||
});
|
||||
25
packages/web/test/use-splitter.test.ts
Normal file
25
packages/web/test/use-splitter.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user