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 { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { wsClient } from './lib/ws-client';
|
import { wsClient } from './lib/ws-client';
|
||||||
|
import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap';
|
||||||
import WsBanner from './components/layout/WsBanner.vue';
|
import WsBanner from './components/layout/WsBanner.vue';
|
||||||
import AppShell from './components/layout/AppShell.vue';
|
import AppShell from './components/layout/AppShell.vue';
|
||||||
import ToastContainer from './components/ToastContainer.vue';
|
import ToastContainer from './components/ToastContainer.vue';
|
||||||
@@ -23,4 +24,8 @@ import CommandPalette from './components/CommandPalette.vue';
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const layout = computed(() => route.meta.layout ?? 'shell');
|
const layout = computed(() => route.meta.layout ?? 'shell');
|
||||||
const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
|
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>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Coquille des vues authentifiées. Propriétaire unique du chargement initial et des abonnements
|
// Coquille des vues authentifiées (sidebar + contenu). Le chargement initial et les abonnements
|
||||||
// temps réel (sessions/worktrees/groups) pour toute la durée de la session : fiabilise le badge
|
// temps réel sont désormais possédés par useRealtimeBootstrap (monté dans App.vue), pour couvrir
|
||||||
// de navigation, la palette ⌘K et la section « À traiter » quelle que soit la vue active.
|
// aussi la vue IDE plein écran qui n'utilise pas cette coquille.
|
||||||
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';
|
|
||||||
import AppSidebar from './AppSidebar.vue';
|
import AppSidebar from './AppSidebar.vue';
|
||||||
import MobileTabBar from './MobileTabBar.vue';
|
import MobileTabBar from './MobileTabBar.vue';
|
||||||
|
|
||||||
defineProps<{ fullbleed?: boolean }>();
|
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>
|
</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
|
// 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.
|
// 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';
|
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() {
|
export function useWorkspaceLayout() {
|
||||||
// largeurs en px des colonnes latérales ; la colonne centrale (éditeur/diff) flexe.
|
// largeurs en px des colonnes latérales ; la colonne centrale (éditeur/diff) flexe.
|
||||||
const leftWidth = persistedRef<number>('arb.ws.leftWidth', 280);
|
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;
|
let unwatch: (() => void) | null = null;
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// l'AppShell n'est pas monté en layout 'ide' → on assure données + temps réel ici.
|
// Le temps réel est possédé globalement par useRealtimeBootstrap (App.vue) ; on ne garde ici
|
||||||
worktrees.startRealtime();
|
// que les garde-fous de données pour une navigation directe vers /workspace.
|
||||||
sessions.startRealtime();
|
|
||||||
if (worktrees.worktrees.length === 0) void worktrees.fetchAll();
|
if (worktrees.worktrees.length === 0) void worktrees.fetchAll();
|
||||||
else void worktrees.refreshRepoWorktrees(repoId);
|
else void worktrees.refreshRepoWorktrees(repoId);
|
||||||
if (sessions.sessions.length === 0) void sessions.fetchSessions();
|
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