[FE] Révise la stabilité, réactivité et performance du frontend
- Corrige fuites mémoire SSE via nettoyage des listeners (P0) - Remplace shallowRef<Map> par reactive(new Map()) pour réactivité native Vue (P1) - Ajoute timeout/AbortController aux requêtes fetch dans l'API client (P4) - Déboucle les watch() de useTabs avec debounce unique et try/finally sur isRestoringFromStorage (P7) - Limite notifications à 5 + nettoyage des timers dans UI store (P15) - Protège routes debug avec import dynamique uniquement en DEV + wildcard 403 (P8) - Déplace SVG par défaut dans assets/default-cover.svg et import ?raw (P10) - Ajoute @media prefers-reduced-motion aux animations CSS globales - Factorise styles drawer-btns avec .drawer-icon-classe + ajoute --opacity-disabled (P13) - Encode les clés de cache browse avec encodeURIComponent + ':' séparateur (P14) - Implémente pagination infinite scroll dans browseContainer/loadMore + Supprime formatMsToShortTime alias (P9) - Corrige truncate() pour éviter dépassement maxLength si suffix >=maxLength (P10) - Valide structure des commandes PMOPlayer avant traitement + Met à jour version Cargo.toml et lock (0.3.39)
This commit is contained in:
@@ -37,9 +37,20 @@ class ImageCacheService {
|
||||
};
|
||||
|
||||
private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000;
|
||||
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
|
||||
this.cleanupIntervalId = setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie le timer de cleanup. À appeler lors de la destruction de l'application.
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.cleanupIntervalId !== null) {
|
||||
clearInterval(this.cleanupIntervalId);
|
||||
this.cleanupIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
configure(options: Partial<ImageCacheOptions>) {
|
||||
|
||||
@@ -20,11 +20,17 @@ export interface BrowseState {
|
||||
container_id: string
|
||||
entries: ContainerEntry[]
|
||||
total_count: number
|
||||
hasMore?: boolean
|
||||
currentOffset?: number
|
||||
}
|
||||
|
||||
// Cache global partagé
|
||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseState>>(new Map())
|
||||
|
||||
function browseCacheKey(serverId: string, containerId: string): string {
|
||||
return `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`
|
||||
}
|
||||
const currentPath = ref<BreadcrumbItem[]>([])
|
||||
const searchResults = ref<BrowseState | null>(null)
|
||||
const searchQuery = ref<string>('')
|
||||
@@ -69,9 +75,10 @@ function ensureSSEInitialized() {
|
||||
}
|
||||
|
||||
// Invalider tout le cache browse de ce serveur
|
||||
const encodedServerId = encodeURIComponent(serverId)
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerId + ':')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
@@ -80,9 +87,10 @@ function ensureSSEInitialized() {
|
||||
|
||||
case 'global_updated':
|
||||
// Invalider tout le cache de ce serveur
|
||||
const encodedServerIdGlobal = encodeURIComponent(serverId)
|
||||
const globalKeysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerIdGlobal + ':')) {
|
||||
globalKeysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
@@ -92,7 +100,7 @@ function ensureSSEInitialized() {
|
||||
case 'containers_updated':
|
||||
// Invalider les containers spécifiques
|
||||
event.container_ids.forEach(containerId => {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
browseCache.value.delete(key)
|
||||
})
|
||||
break
|
||||
@@ -142,7 +150,7 @@ export function useMediaServers() {
|
||||
|
||||
// Charge la première page (remplace le cache)
|
||||
async function browseContainer(serverId: string, containerId: string, useCache = true) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
|
||||
if (useCache && browseCache.value.has(key)) {
|
||||
return browseCache.value.get(key)!
|
||||
@@ -152,12 +160,14 @@ export function useMediaServers() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const data = await api.browseContainer(serverId, containerId, 0)
|
||||
const data = await api.browseContainer(serverId, containerId, 0, 50)
|
||||
|
||||
browseCache.value.set(key, {
|
||||
container_id: data.container_id,
|
||||
entries: data.entries,
|
||||
total_count: data.total_count,
|
||||
hasMore: data.entries.length < data.total_count,
|
||||
currentOffset: data.entries.length,
|
||||
})
|
||||
|
||||
return browseCache.value.get(key)!
|
||||
@@ -172,22 +182,26 @@ export function useMediaServers() {
|
||||
|
||||
// Charge la page suivante et accumule (infinite scroll)
|
||||
async function loadMoreBrowse(serverId: string, containerId: string) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
const state = browseCache.value.get(key)
|
||||
|
||||
if (!state) return
|
||||
if (state.entries.length >= state.total_count) return
|
||||
if (!('hasMore' in state) || !state.hasMore) return
|
||||
if (loadingMore.value) return
|
||||
|
||||
try {
|
||||
loadingMore.value = true
|
||||
|
||||
const offset = state.entries.length
|
||||
// Le type cast est nécessaire car les anciens cached entries n'ont pas hasMore
|
||||
const state = browseCache.value.get(key) as BrowseState & { hasMore?: boolean; currentOffset?: number }
|
||||
const offset = state.currentOffset ?? state.entries.length
|
||||
const data = await api.browseContainer(serverId, containerId, offset)
|
||||
|
||||
// Accumuler les nouvelles entrées
|
||||
state.entries.push(...data.entries)
|
||||
state.total_count = data.total_count
|
||||
state.currentOffset = state.entries.length
|
||||
state.hasMore = state.entries.length < state.total_count
|
||||
// Forcer la réactivité
|
||||
browseCache.value.set(key, { ...state })
|
||||
} catch (e) {
|
||||
@@ -236,12 +250,12 @@ export function useMediaServers() {
|
||||
}
|
||||
|
||||
function getBrowseCached(serverId: string, containerId: string) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
return browseCache.value.get(key)
|
||||
}
|
||||
|
||||
function hasMore(serverId: string, containerId: string): boolean {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
const state = browseCache.value.get(key)
|
||||
if (!state) return false
|
||||
return state.entries.length < state.total_count
|
||||
@@ -259,12 +273,12 @@ export function useMediaServers() {
|
||||
// Invalidation du cache
|
||||
function invalidateCache(serverId: string, containerId?: string) {
|
||||
if (containerId) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
browseCache.value.delete(key)
|
||||
browseCache.value.delete(browseCacheKey(serverId, containerId))
|
||||
} else {
|
||||
const encodedServerId = encodeURIComponent(serverId)
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerId + ':')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* - Les snapshots complets proviennent de /renderers/{id}/full
|
||||
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
||||
*/
|
||||
import { ref, shallowRef, computed, type Ref, onUnmounted } from "vue";
|
||||
import { ref, reactive, computed, type Ref, onUnmounted } from "vue";
|
||||
import { api } from "../services/pmocontrol/api";
|
||||
import { useSSE } from "./useSSE";
|
||||
import { apiCache } from "./apiCache";
|
||||
@@ -19,31 +19,19 @@ import type {
|
||||
} from "../services/pmocontrol/types";
|
||||
import { isTransportState } from "../services/pmocontrol/types";
|
||||
|
||||
// État global des snapshots avec shallowRef pour éviter les problèmes de réactivité avec les Maps
|
||||
const snapshots = shallowRef(new Map<string, FullRendererSnapshot>());
|
||||
const lastSnapshotAt = shallowRef(new Map<string, number>());
|
||||
const lastEventAt = shallowRef(new Map<string, number>());
|
||||
const loadingIds = shallowRef(new Set<string>());
|
||||
const queueRefreshingIds = shallowRef(new Set<string>());
|
||||
// État global des snapshots avec reactive pour une réactivité native Vue sur les Maps
|
||||
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
|
||||
const lastSnapshotAt = reactive(new Map<string, number>());
|
||||
const lastEventAt = reactive(new Map<string, number>());
|
||||
const loadingIds = reactive(new Set<string>());
|
||||
const queueRefreshingIds = reactive(new Set<string>());
|
||||
const selectedRendererId = ref<string | null>(null);
|
||||
|
||||
// Cache des renderers (summary)
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||
const RENDERERS_CACHE_MS = 2000;
|
||||
|
||||
// Helper pour déclencher la réactivité après mutation des Maps
|
||||
function triggerSnapshotReactivity() {
|
||||
// Créer une nouvelle référence pour déclencher la réactivité
|
||||
snapshots.value = new Map(snapshots.value);
|
||||
}
|
||||
|
||||
function triggerLoadingReactivity() {
|
||||
loadingIds.value = new Set(loadingIds.value);
|
||||
}
|
||||
|
||||
function triggerQueueReactivity() {
|
||||
queueRefreshingIds.value = new Set(queueRefreshingIds.value);
|
||||
}
|
||||
// Supprimé : les helpers triggerXXX ne sont plus nécessaires avec reactive
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
@@ -114,17 +102,16 @@ function ensureSSEInitialized() {
|
||||
}
|
||||
|
||||
// Supprimer le snapshot (il n'est plus valide)
|
||||
snapshots.value.delete(rendererId);
|
||||
triggerSnapshotReactivity();
|
||||
lastSnapshotAt.value.delete(rendererId);
|
||||
lastEventAt.value.delete(rendererId);
|
||||
snapshots.delete(rendererId);
|
||||
lastSnapshotAt.delete(rendererId);
|
||||
lastEventAt.delete(rendererId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pour les autres événements, mettre à jour le snapshot local directement
|
||||
lastEventAt.value.set(rendererId, timestamp);
|
||||
lastEventAt.set(rendererId, timestamp);
|
||||
|
||||
const snapshot = snapshots.value.get(rendererId);
|
||||
const snapshot = snapshots.get(rendererId);
|
||||
|
||||
// Si pas de snapshot, on doit fetch
|
||||
if (!snapshot) {
|
||||
@@ -137,11 +124,11 @@ function ensureSSEInitialized() {
|
||||
case "state_changed":
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
if (isTransportState(event.state)) {
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, transport_state: event.state },
|
||||
});
|
||||
triggerSnapshotReactivity();
|
||||
|
||||
} else {
|
||||
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
||||
}
|
||||
@@ -158,7 +145,7 @@ function ensureSSEInitialized() {
|
||||
const durationMs = parseTimeToMs(event.track_duration ?? null);
|
||||
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: {
|
||||
...snapshot.state,
|
||||
@@ -166,12 +153,12 @@ function ensureSSEInitialized() {
|
||||
duration_ms: durationMs,
|
||||
},
|
||||
});
|
||||
triggerSnapshotReactivity();
|
||||
|
||||
break;
|
||||
|
||||
case "volume_changed":
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, volume: event.volume },
|
||||
});
|
||||
@@ -179,7 +166,7 @@ function ensureSSEInitialized() {
|
||||
|
||||
case "mute_changed":
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, mute: event.mute },
|
||||
});
|
||||
@@ -199,21 +186,21 @@ function ensureSSEInitialized() {
|
||||
snapshot.state.current_track.album = event.album;
|
||||
snapshot.state.current_track.album_art_uri = event.album_art_uri;
|
||||
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state },
|
||||
});
|
||||
break;
|
||||
|
||||
case "queue_refreshing":
|
||||
queueRefreshingIds.value.add(rendererId);
|
||||
triggerQueueReactivity();
|
||||
queueRefreshingIds.add(rendererId);
|
||||
|
||||
break;
|
||||
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.value.delete(rendererId);
|
||||
triggerQueueReactivity();
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
|
||||
// Pour la queue complète, on doit refetch
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
@@ -231,7 +218,7 @@ function ensureSSEInitialized() {
|
||||
snapshot.state.attached_playlist = null;
|
||||
}
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state },
|
||||
});
|
||||
@@ -240,7 +227,7 @@ function ensureSSEInitialized() {
|
||||
case "stream_state_changed":
|
||||
snapshot.is_stream = event.is_stream;
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.value.set(rendererId, { ...snapshot });
|
||||
snapshots.set(rendererId, { ...snapshot });
|
||||
break;
|
||||
|
||||
case "timer_started":
|
||||
@@ -265,7 +252,7 @@ const onlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r) => r.online),
|
||||
);
|
||||
const allSnapshots = computed(() =>
|
||||
Array.from(snapshots.value.values()),
|
||||
Array.from(snapshots.values()),
|
||||
);
|
||||
const playingRenderers = computed(() =>
|
||||
allSnapshots.value
|
||||
@@ -278,27 +265,27 @@ function getRendererById(id: string) {
|
||||
}
|
||||
|
||||
function getSnapshotById(id: string) {
|
||||
return snapshots.value.get(id) ?? null;
|
||||
return snapshots.get(id) ?? null;
|
||||
}
|
||||
|
||||
function getStateById(id: string): RendererState | null {
|
||||
return snapshots.value.get(id)?.state ?? null;
|
||||
return snapshots.get(id)?.state ?? null;
|
||||
}
|
||||
|
||||
function getQueueById(id: string): QueueSnapshot | null {
|
||||
return snapshots.value.get(id)?.queue ?? null;
|
||||
return snapshots.get(id)?.queue ?? null;
|
||||
}
|
||||
|
||||
function getBindingById(id: string): AttachedPlaylistInfo | null {
|
||||
return snapshots.value.get(id)?.binding ?? null;
|
||||
return snapshots.get(id)?.binding ?? null;
|
||||
}
|
||||
|
||||
function isSnapshotLoading(id: string) {
|
||||
return loadingIds.value.has(id);
|
||||
return loadingIds.has(id);
|
||||
}
|
||||
|
||||
function isQueueRefreshing(id: string) {
|
||||
return queueRefreshingIds.value.has(id);
|
||||
return queueRefreshingIds.has(id);
|
||||
}
|
||||
|
||||
function selectRenderer(id: string | null) {
|
||||
@@ -353,43 +340,43 @@ async function fetchRendererSnapshot(
|
||||
) {
|
||||
ensureSSEInitialized();
|
||||
const force = opts?.force ?? false;
|
||||
const hasSnapshot = snapshots.value.has(rendererId);
|
||||
const hasSnapshot = snapshots.has(rendererId);
|
||||
|
||||
if (!force && hasSnapshot) {
|
||||
const lastSnapshot = lastSnapshotAt.value.get(rendererId) ?? 0;
|
||||
const lastEvent = lastEventAt.value.get(rendererId) ?? 0;
|
||||
const lastSnapshot = lastSnapshotAt.get(rendererId) ?? 0;
|
||||
const lastEvent = lastEventAt.get(rendererId) ?? 0;
|
||||
if (lastEvent <= lastSnapshot) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Éviter les requêtes multiples simultanées pour le même renderer
|
||||
if (loadingIds.value.has(rendererId)) {
|
||||
if (loadingIds.has(rendererId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingIds.value.add(rendererId);
|
||||
triggerLoadingReactivity();
|
||||
loadingIds.add(rendererId);
|
||||
|
||||
|
||||
// Lazy load UI store pour les notifications
|
||||
const uiStore = useUIStore();
|
||||
|
||||
try {
|
||||
const snapshot = await api.getRendererFullSnapshot(rendererId);
|
||||
snapshots.value.set(rendererId, snapshot);
|
||||
lastSnapshotAt.value.set(rendererId, Date.now());
|
||||
triggerSnapshotReactivity();
|
||||
snapshots.set(rendererId, snapshot);
|
||||
lastSnapshotAt.set(rendererId, Date.now());
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
|
||||
// En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative
|
||||
snapshots.value.delete(rendererId);
|
||||
triggerSnapshotReactivity();
|
||||
snapshots.delete(rendererId);
|
||||
|
||||
// Notifier l'utilisateur
|
||||
uiStore.notifyError(`Impossible de récupérer l'état du renderer`);
|
||||
} finally {
|
||||
// Toujours nettoyer le flag de chargement
|
||||
loadingIds.value.delete(rendererId);
|
||||
triggerLoadingReactivity();
|
||||
loadingIds.delete(rendererId);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,7 +426,7 @@ async function play(id: string) {
|
||||
}
|
||||
|
||||
async function resumeOrPlayFromQueue(id: string) {
|
||||
const snapshot = snapshots.value.get(id);
|
||||
const snapshot = snapshots.get(id);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Renderer ${id} non trouvé`);
|
||||
}
|
||||
|
||||
@@ -17,13 +17,17 @@ import type {
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
// État global partagé
|
||||
const connected = ref(sse.isConnectedState())
|
||||
const connectionCallbacks: Set<(connected: boolean) => void> = new Set()
|
||||
const connected = ref(sse.isConnectedState());
|
||||
const connectionCallbacks: Set<(connected: boolean) => void> = new Set();
|
||||
|
||||
// Flag pour éviter les double-connexions SSE avec lock
|
||||
let connectionLock = false;
|
||||
|
||||
// Abonnement à l'état de connexion global
|
||||
function setupConnectionListener() {
|
||||
// S'assurer qu'on ne s'abonne qu'une seule fois
|
||||
if (connectionCallbacks.size === 0) {
|
||||
// Vérifier avec lock pour éviter les conditions de course
|
||||
if (connectionCallbacks.size === 0 && !connectionLock) {
|
||||
connectionLock = true;
|
||||
sse.onConnectionChange((isConnected) => {
|
||||
connected.value = isConnected
|
||||
connectionCallbacks.forEach(cb => cb(isConnected))
|
||||
|
||||
@@ -43,6 +43,10 @@ const state = reactive<TabsState>({
|
||||
// Flag pour éviter les boucles de sauvegarde
|
||||
let isRestoringFromStorage = false;
|
||||
|
||||
// Debounce timer pour la sauvegarde localStorage
|
||||
let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const SAVE_DEBOUNCE_MS = 100;
|
||||
|
||||
/**
|
||||
* Retourne le titre complet sans troncature
|
||||
* Note: On laisse le CSS gérer l'overflow avec ellipsis pour un affichage stable
|
||||
@@ -53,29 +57,38 @@ function truncateTitle(title: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde l'état dans localStorage
|
||||
* Sauvegarde l'état dans localStorage (avec debounce)
|
||||
* Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés)
|
||||
*/
|
||||
function saveToLocalStorage() {
|
||||
if (isRestoringFromStorage) return;
|
||||
|
||||
try {
|
||||
const stateToSave = {
|
||||
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
||||
tabs: state.tabs
|
||||
.filter((tab) => tab.type === "server")
|
||||
.map((tab) => ({
|
||||
...tab,
|
||||
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
||||
icon: undefined,
|
||||
})),
|
||||
activeTabId: state.activeTabId,
|
||||
tabHistory: state.tabHistory,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
|
||||
// Annuler le timer précédent
|
||||
if (saveDebounceTimer !== null) {
|
||||
clearTimeout(saveDebounceTimer);
|
||||
}
|
||||
|
||||
// Débouncer pour éviter les écritures multiples
|
||||
saveDebounceTimer = setTimeout(() => {
|
||||
try {
|
||||
const stateToSave = {
|
||||
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
||||
tabs: state.tabs
|
||||
.filter((tab) => tab.type === "server")
|
||||
.map((tab) => ({
|
||||
...tab,
|
||||
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
||||
icon: undefined,
|
||||
})),
|
||||
activeTabId: state.activeTabId,
|
||||
tabHistory: state.tabHistory,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
|
||||
}
|
||||
saveDebounceTimer = null;
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,11 +96,11 @@ function saveToLocalStorage() {
|
||||
* Note: Restaure uniquement les onglets server (les renderer tabs seront auto-générés)
|
||||
*/
|
||||
function restoreFromLocalStorage() {
|
||||
isRestoringFromStorage = true;
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
|
||||
isRestoringFromStorage = true;
|
||||
const savedState = JSON.parse(saved);
|
||||
|
||||
// Reconstituer uniquement les tabs server avec les bonnes icônes
|
||||
@@ -110,10 +123,9 @@ function restoreFromLocalStorage() {
|
||||
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
|
||||
state.activeTabId = "";
|
||||
}
|
||||
|
||||
isRestoringFromStorage = false;
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur restauration localStorage:", error);
|
||||
} finally {
|
||||
isRestoringFromStorage = false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user