feat(web): infra IDE réutilisable (persisted-ref, splitter, bootstrap temps réel)
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled

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:
2026-07-17 16:55:14 +02:00
parent 9a496abd00
commit cf60fa3a56
10 changed files with 244 additions and 55 deletions

View File

@@ -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>

View 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>

View File

@@ -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>

View 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 },
);
}

View 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 };
}

View File

@@ -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);

View 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;
}

View File

@@ -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();