23 KiB
Phase 2 - Pagination et Virtual Scrolling pour ObsiViewer
🎯 Objectif
Implémenter la pagination curseur-based et le virtual scrolling pour permettre à ObsiViewer de gérer efficacement des vaults contenant 10,000+ fichiers tout en maintenant des performances optimales.
📋 Contexte
✅ Ce qui a été accompli en Phase 1
- Metadata-first loading : Chargement ultra-rapide des métadonnées uniquement
- Lazy loading : Contenu chargé à la demande lors de la sélection d'une note
- Optimisations serveur : Cache intelligent et indexation différée
- Résultat : 75% d'amélioration du temps de démarrage (15-25s → 2-4s)
❌ Limites de la Phase 1
- Mémoire client : Toutes les métadonnées (~1000 fichiers) chargées en mémoire
- Rendu UI : Liste complète rendue même avec virtual scrolling partiel
- Scalabilité : Performances dégradées au-delà de 1000 fichiers
- UX : Scroll lag avec de gros volumes de données
🎯 Pourquoi la Phase 2 est nécessaire
Pour supporter des vaults avec 10,000+ fichiers, nous devons implémenter :
- Pagination côté serveur : Charger les données par pages
- Virtual scrolling côté client : Ne rendre que les éléments visibles
- Gestion intelligente de la mémoire : Éviter de charger toutes les métadonnées
📊 Spécifications Techniques
1. Pagination Côté Serveur (Cursor-Based)
Endpoint /api/vault/metadata/paginated
GET /api/vault/metadata/paginated?limit=100&cursor=500
Response:
{
"items": [
{
"id": "note-1",
"title": "Titre de la note",
"filePath": "folder/note.md",
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-01-01T00:00:00Z"
}
// ... 99 autres items
],
"nextCursor": "600",
"hasMore": true,
"total": 12500
}
Paramètres
limit: Nombre d'items par page (défaut: 100, max: 500)cursor: Offset pour la pagination (défaut: 0)search: Terme de recherche optionnel
Implémentation Meilisearch
const result = await index.search(searchQuery, {
limit: limit + 1, // +1 pour détecter s'il y a plus de résultats
offset: parseInt(cursor) || 0,
attributesToRetrieve: ['id', 'title', 'path', 'createdAt', 'updatedAt']
});
2. Virtual Scrolling Côté Client
Composant NotesListComponent avec CDK Virtual Scrolling
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
template: `
<cdk-virtual-scroll-viewport
itemSize="60"
class="h-full"
(scrolledIndexChange)="onScroll($event)">
<div *cdkVirtualFor="let note of paginatedNotes; trackBy: trackByFn"
class="note-item"
[class.selected]="note.id === selectedNoteId()">
{{ note.title }}
</div>
</cdk-virtual-scroll-viewport>
`
})
export class NotesListComponent {
paginatedNotes = signal<NoteMetadata[]>([]);
currentPage = signal(0);
hasMore = signal(true);
// Charger plus de données lors du scroll
async onScroll(index: number) {
if (index > this.paginatedNotes().length - 50 && this.hasMore()) {
await this.loadNextPage();
}
}
}
3. Gestion d'État Client
PaginationService
@Injectable({ providedIn: 'root' })
export class PaginationService {
private pages = new Map<number, NoteMetadata[]>();
private currentPage = signal(0);
private totalItems = signal(0);
// Cache des pages chargées
getPage(page: number): NoteMetadata[] | undefined {
return this.pages.get(page);
}
setPage(page: number, items: NoteMetadata[]) {
this.pages.set(page, items);
}
// Invalider cache lors de changements
invalidateCache() {
this.pages.clear();
this.currentPage.set(0);
}
}
🛠️ Plan d'Implémentation (2-3 jours)
Jour 1 : Pagination Côté Serveur (4-6 heures)
1.1 Créer l'endpoint paginé
Fichier : server/index.mjs
Lignes : Après l'endpoint /api/vault/metadata
// NOUVEL ENDPOINT - Pagination curseur-based
app.get('/api/vault/metadata/paginated', async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const cursor = req.query.cursor || '0';
const search = req.query.search || '';
console.time(`[Pagination] Load page cursor=${cursor}, limit=${limit}`);
// Utiliser Meilisearch avec pagination
const client = meiliClient();
const indexUid = vaultIndexName(vaultDir);
const index = await ensureIndexSettings(client, indexUid);
const result = await index.search(search, {
limit: limit + 1, // +1 pour détecter s'il y a plus
offset: parseInt(cursor),
attributesToRetrieve: ['id', 'title', 'path', 'createdAt', 'updatedAt'],
sort: ['updatedAt:desc'] // Trier par date de modification
});
const hasMore = result.hits.length > limit;
const items = result.hits.slice(0, limit);
const nextCursor = hasMore ? (parseInt(cursor) + limit).toString() : null;
// Convertir au format NoteMetadata
const metadata = items.map(item => ({
id: item.id,
title: item.title,
filePath: item.path,
createdAt: item.createdAt,
updatedAt: item.updatedAt
}));
console.timeEnd(`[Pagination] Load page cursor=${cursor}, limit=${limit}`);
res.json({
items: metadata,
nextCursor,
hasMore,
total: result.estimatedTotalHits || result.hits.length
});
} catch (error) {
console.error('[Pagination] Error:', error);
// Fallback: pagination simple sur filesystem
try {
const allMetadata = await getMetadataFromCache();
const offset = parseInt(cursor);
const paginatedItems = allMetadata.slice(offset, offset + limit);
const hasMore = offset + limit < allMetadata.length;
res.json({
items: paginatedItems,
nextCursor: hasMore ? (offset + limit).toString() : null,
hasMore,
total: allMetadata.length
});
} catch (fallbackError) {
res.status(500).json({ error: 'Pagination failed' });
}
}
});
1.2 Mettre à jour le cache pour supporter la pagination
Fichier : server/performance-config.mjs
export class MetadataCache {
constructor() {
this.metadata = null;
this.lastUpdate = 0;
this.ttl = 5 * 60 * 1000; // 5 minutes
}
async getMetadata() {
const now = Date.now();
if (this.metadata && (now - this.lastUpdate) < this.ttl) {
return this.metadata;
}
// Recharger depuis Meilisearch ou filesystem
this.metadata = await loadVaultMetadataOnly(process.env.VAULT_PATH);
this.lastUpdate = now;
return this.metadata;
}
invalidate() {
this.metadata = null;
this.lastUpdate = 0;
}
}
1.3 Tests de l'endpoint paginé
# Test pagination simple
curl "http://localhost:4000/api/vault/metadata/paginated?limit=10"
# Test avec curseur
curl "http://localhost:4000/api/vault/metadata/paginated?limit=10&cursor=10"
# Test avec recherche
curl "http://localhost:4000/api/vault/metadata/paginated?limit=10&search=projet"
Jour 2 : Virtual Scrolling Côté Client (4-6 heures)
2.1 Créer PaginationService
Fichier : src/app/services/pagination.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export interface NoteMetadata {
id: string;
title: string;
filePath: string;
createdAt: string;
updatedAt: string;
}
export interface PaginationResponse {
items: NoteMetadata[];
nextCursor: string | null;
hasMore: boolean;
total: number;
}
@Injectable({ providedIn: 'root' })
export class PaginationService {
private http = inject(HttpClient);
// État de la pagination
private pages = signal<Map<number, NoteMetadata[]>>(new Map());
private currentCursor = signal<string | null>(null);
private hasMorePages = signal(true);
private isLoading = signal(false);
private searchTerm = signal('');
// Liste concaténée de toutes les pages chargées
readonly allItems = computed(() => {
const pages = this.pages();
const result: NoteMetadata[] = [];
for (const page of pages.values()) {
result.push(...page);
}
return result;
});
readonly totalLoaded = computed(() => this.allItems().length);
readonly canLoadMore = computed(() => this.hasMorePages() && !this.isLoading());
// Charger la première page
async loadInitial(search = ''): Promise<void> {
this.searchTerm.set(search);
this.pages.set(new Map());
this.currentCursor.set(null);
this.hasMorePages.set(true);
await this.loadNextPage();
}
// Charger la page suivante
async loadNextPage(): Promise<void> {
if (this.isLoading() || !this.hasMorePages()) return;
this.isLoading.set(true);
try {
const params: any = {
limit: 100,
search: this.searchTerm()
};
if (this.currentCursor()) {
params.cursor = this.currentCursor();
}
const response = await firstValueFrom(
this.http.get<PaginationResponse>('/api/vault/metadata/paginated', { params })
);
// Ajouter la page au cache
const pageIndex = this.pages().size;
this.pages.update(pages => {
const newPages = new Map(pages);
newPages.set(pageIndex, response.items);
return newPages;
});
// Mettre à jour l'état
this.currentCursor.set(response.nextCursor);
this.hasMorePages.set(response.hasMore);
} catch (error) {
console.error('[PaginationService] Failed to load page:', error);
throw error;
} finally {
this.isLoading.set(false);
}
}
// Rechercher avec un nouveau terme
async search(term: string): Promise<void> {
await this.loadInitial(term);
}
// Invalider le cache (après modifications)
invalidateCache(): void {
this.pages.set(new Map());
this.currentCursor.set(null);
this.hasMorePages.set(true);
}
}
2.2 Mettre à jour NotesListComponent avec virtual scrolling
Fichier : src/app/features/list/notes-list.component.ts
import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { PaginationService, NoteMetadata } from '../../services/pagination.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-notes-list',
standalone: true,
imports: [CommonModule, ScrollingModule],
template: `
<div class="notes-list-container h-full">
<!-- Virtual Scroll Viewport -->
<cdk-virtual-scroll-viewport
itemSize="60"
class="h-full"
(scrolledIndexChange)="onScroll($event)">
<div class="notes-list">
<!-- Items virtuels -->
<div
*cdkVirtualFor="let note of paginatedNotes(); trackBy: trackByFn"
class="note-item p-3 border-b border-surface2 hover:bg-surface1 cursor-pointer transition-colors"
[class.selected]="note.id === selectedNoteId()"
(click)="selectNote(note)">
<div class="note-title truncate font-medium">
{{ note.title }}
</div>
<div class="note-meta text-xs text-muted mt-1 flex justify-between">
<span class="note-path truncate opacity-60">
{{ getRelativePath(note.filePath) }}
</span>
<span class="note-date opacity-60">
{{ formatDate(note.updatedAt) }}
</span>
</div>
</div>
<!-- Loading indicator -->
<div *ngIf="isLoadingMore()" class="p-4 text-center text-muted">
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span class="ml-2">Chargement...</span>
</div>
<!-- End of list indicator -->
<div *ngIf="!hasMorePages() && totalLoaded() > 0" class="p-4 text-center text-muted">
{{ totalLoaded() }} notes chargées
</div>
</div>
</cdk-virtual-scroll-viewport>
</div>
`,
styles: [`
.notes-list-container {
min-height: 400px;
}
.note-item {
min-height: 60px;
display: flex;
flex-direction: column;
justify-content: center;
}
.note-item.selected {
background-color: var(--surface1);
border-left: 3px solid var(--primary);
}
.cdk-virtual-scroll-viewport {
height: 100%;
}
`]
})
export class NotesListComponent implements OnInit, OnDestroy {
private paginationService = inject(PaginationService);
private router = inject(Router);
// État local
selectedNoteId = signal<string | null>(null);
// Données paginées
paginatedNotes = this.paginationService.allItems;
isLoadingMore = this.paginationService.isLoading;
hasMorePages = this.paginationService.hasMorePages;
totalLoaded = this.paginationService.totalLoaded;
canLoadMore = this.paginationService.canLoadMore;
// Subscription pour les changements de recherche
private searchSubscription?: Subscription;
ngOnInit() {
// Charger la première page
this.paginationService.loadInitial();
// Écouter les changements de recherche depuis le parent
// (à connecter au composant parent)
}
ngOnDestroy() {
this.searchSubscription?.unsubscribe();
}
// Gestion du scroll virtuel
onScroll(index: number) {
// Charger plus de données quand on approche de la fin
if (index > this.paginatedNotes().length - 20 && this.canLoadMore()) {
this.paginationService.loadNextPage();
}
}
// Sélection d'une note
async selectNote(note: NoteMetadata) {
this.selectedNoteId.set(note.id);
// Naviguer vers la note (lazy loading du contenu)
await this.router.navigate(['/note', note.id]);
}
// Utilitaires
trackByFn(index: number, item: NoteMetadata): string {
return item.id;
}
getRelativePath(filePath: string): string {
// Extraire le chemin relatif depuis le vault
return filePath.replace(/^.*?\//, '');
}
formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'aujourd\'hui';
if (diffDays === 1) return 'hier';
if (diffDays < 7) return `il y a ${diffDays}j`;
return date.toLocaleDateString('fr-FR', {
month: 'short',
day: 'numeric'
});
}
// Méthode pour recevoir les changements de recherche
onSearchChange(searchTerm: string) {
this.paginationService.search(searchTerm);
}
}
2.3 Intégrer la recherche
Modification : Mettre à jour le composant parent pour connecter la recherche
// Dans le composant parent (ex: sidebar)
export class SidebarComponent {
private paginationService = inject(PaginationService);
onSearchInput(event: Event) {
const searchTerm = (event.target as HTMLInputElement).value;
this.paginationService.search(searchTerm);
}
}
Jour 3 : Intégration et Tests (4-6 heures)
3.1 Mettre à jour les composants parents
- Modifier
SidebarComponentpour utiliserNotesListComponentavec pagination - Connecter la recherche au
PaginationService - Gérer les événements de sélection de notes
3.2 Gestion des événements de fichiers
Fichier : src/app/services/vault-events.service.ts
// Invalider le cache de pagination lors de changements
private handleFileChange(event: VaultEvent) {
switch (event.type) {
case 'add':
case 'change':
case 'unlink':
this.paginationService.invalidateCache();
// Recharger la première page
this.paginationService.loadInitial();
break;
}
}
3.3 Tests de performance
Fichier : scripts/test-pagination.mjs
#!/usr/bin/env node
const BASE_URL = process.env.BASE_URL || 'http://localhost:4000';
async function testPagination() {
console.log('🧪 Testing Pagination Performance\n');
// Test 1: Première page
console.log('📄 Test 1: Loading first page...');
const start1 = performance.now();
const response1 = await fetch(`${BASE_URL}/api/vault/metadata/paginated?limit=50`);
const data1 = await response1.json();
const time1 = performance.now() - start1;
console.log(`✅ First page: ${data1.items.length} items in ${time1.toFixed(2)}ms`);
console.log(`📊 Total available: ${data1.total} items\n`);
// Test 2: Pagination complète (simuler scroll)
console.log('📜 Test 2: Simulating scroll through 5 pages...');
let totalTime = 0;
let totalItems = 0;
let cursor = null;
for (let page = 0; page < 5; page++) {
const start = performance.now();
const params = new URLSearchParams({ limit: '50' });
if (cursor) params.set('cursor', cursor);
const response = await fetch(`${BASE_URL}/api/vault/metadata/paginated?${params}`);
const data = await response.json();
const time = performance.now() - start;
totalTime += time;
totalItems += data.items.length;
cursor = data.nextCursor;
console.log(` Page ${page + 1}: ${data.items.length} items in ${time.toFixed(2)}ms`);
if (!data.hasMore) break;
}
console.log(`\n📊 Pagination Results:`);
console.log(` Total items loaded: ${totalItems}`);
console.log(` Total time: ${totalTime.toFixed(2)}ms`);
console.log(` Average per page: ${(totalTime / 5).toFixed(2)}ms`);
console.log(` Memory efficient: Only ${totalItems} items in memory`);
}
testPagination().catch(console.error);
3.4 Tests d'intégration
- Tester avec un vault de 10,000+ fichiers
- Vérifier le virtual scrolling
- Tester la recherche paginée
- Mesurer les performances mémoire
✅ Critères d'Acceptation
Fonctionnels
- Pagination serveur : Endpoint retourne des pages de 100 items max
- Curseur-based : Navigation correcte avec curseurs
- Virtual scrolling : Seuls les éléments visibles sont rendus
- Recherche : Recherche fonctionne avec pagination
- Lazy loading : Contenu chargé à la demande (hérité de Phase 1)
Performances
- Temps de première page : < 500ms
- Temps de pages suivantes : < 300ms
- Mémoire client : < 50MB pour 10,000+ fichiers
- Scroll fluide : 60fps minimum
- Recherche : < 200ms pour résultats paginés
UX
- Scroll infini : Chargement automatique lors du scroll
- Indicateurs : Loading states et "fin de liste"
- Sélection : Navigation vers notes préserve l'état
- Responsive : Fonctionne sur mobile et desktop
Robustesse
- Cache invalidation : Mise à jour lors de changements de fichiers
- Erreur handling : Fallback gracieux en cas d'erreur
- Rétrocompatibilité : Anciens endpoints toujours fonctionnels
- Meilisearch fallback : Fonctionne sans recherche avancée
📊 Métriques de Succès
Avant Phase 2 (avec Phase 1)
Vault de 1,000 fichiers:
- Mémoire: 50-100MB
- Temps d'affichage: 2-4s
- Scroll: Lag au-delà de 500 items
Après Phase 2
Vault de 10,000 fichiers:
- Mémoire: 5-10MB (90% de réduction)
- Temps d'affichage: 1-2s (50% plus rapide)
- Scroll: Fluide à 60fps
- Pagination: Chargement par pages de 100 items
Tests de Charge
- 10,000 fichiers : Mémoire < 50MB, scroll fluide
- 50,000 fichiers : Mémoire < 100MB, pagination fonctionnelle
- 100,000+ fichiers : Support théorique illimité
🔧 Dépendances et Prérequis
Dépendances Techniques
- Angular CDK : Pour virtual scrolling (
@angular/cdk/scrolling) - Meilisearch : Pour recherche paginée (recommandé)
- Node.js 18+ : Pour fetch API natif
Prérequis
- ✅ Phase 1 terminée : Metadata-first loading opérationnel
- ✅ Cache serveur : Implémentation du cache de métadonnées
- ✅ Lazy loading : Contenu chargé à la demande
🚨 Points d'Attention
Performance
- Taille d'itemSize : 60px est optimal pour la plupart des notes
- Seuil de chargement : Précharger 20-30 items avant la fin visible
- Cache de pages : Garder 3-5 pages en mémoire pour le scroll rapide
UX
- Indicateurs visuels : Montrer clairement le chargement
- États d'erreur : Gestion gracieuse des échecs de chargement
- Scroll to top : Bouton pour revenir en haut de longues listes
Robustesse
- Connexion réseau : Retry automatique en cas d'échec
- Cache stale : Invalidation intelligente du cache
- Memory leaks : Nettoyer les subscriptions et caches
🧪 Plan de Test
Tests Unitaires
// PaginationService tests
describe('PaginationService', () => {
it('should load first page', async () => {
// Test chargement initial
});
it('should load next page on scroll', async () => {
// Test pagination automatique
});
it('should handle search correctly', async () => {
// Test recherche avec pagination
});
});
Tests d'Intégration
// End-to-end tests
describe('Pagination E2E', () => {
it('should display first 100 notes', () => {
// Vérifier affichage initial
});
it('should load more on scroll', () => {
// Simuler scroll et vérifier chargement
});
it('should handle large vault (10k+ files)', () => {
// Test avec gros volume
});
});
Tests de Performance
# Script de benchmark
npm run test:pagination-performance
# Résultats attendus:
# - First page: < 500ms
# - Subsequent pages: < 300ms
# - Memory: < 50MB for 10k files
🎯 Livrables
Code
- ✅ Endpoint
/api/vault/metadata/paginated - ✅
PaginationServiceavec cache intelligent - ✅
NotesListComponentavec virtual scrolling - ✅ Intégration avec recherche existante
Documentation
- ✅ Guide d'implémentation détaillé
- ✅ Tests automatisés
- ✅ Métriques de performance
- ✅ Guide de maintenance
Tests
- ✅ Tests unitaires pour services
- ✅ Tests d'intégration pour composants
- ✅ Tests de performance avec gros volumes
- ✅ Tests de régression
🚀 Résumé
La Phase 2 transforme ObsiViewer en une application capable de gérer des vaults de taille illimitée avec des performances constantes. L'approche pagination + virtual scrolling permet de maintenir une UX fluide même avec 100,000+ fichiers.
Effort : 2-3 jours
Risque : Faible
Impact : Support illimité de fichiers
ROI : Transformation complète de la scalabilité
Prêt pour implémentation ! 🎯