From 66f58446b480410ff32a11fe52fb24c3e89effd9 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 6 Mar 2026 21:04:24 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Pulse:=20lazy=20pagination=20with?= =?UTF-8?q?=20IntersectionObserver=20infinite=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Page size reduced from 30 → 20 commits per fetch - Backend: add `skip` param to get_vault_pulse (git log --skip) → true pagination instead of re-fetching everything - Frontend: IntersectionObserver sentinel at bottom of feed → auto-loads next page when user scrolls near end - Append-only updates (no full re-render on load more) - Add IntersectionObserver mock to test setup --- src-tauri/src/commands.rs | 6 ++- src-tauri/src/git/pulse.rs | 22 ++++++---- src/components/PulseView.test.tsx | 14 ++----- src/components/PulseView.tsx | 70 +++++++++++++++++++++---------- src/test/setup.ts | 7 ++++ 5 files changed, 77 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a451384b..32861ea5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -221,10 +221,12 @@ pub fn get_file_diff_at_commit( pub fn get_vault_pulse( vault_path: String, limit: Option, + skip: Option, ) -> Result, String> { let vault_path = expand_tilde(&vault_path); - let limit = limit.unwrap_or(30); - git::get_vault_pulse(&vault_path, limit) + let limit = limit.unwrap_or(20); + let skip = skip.unwrap_or(0); + git::get_vault_pulse(&vault_path, limit, skip) } #[tauri::command] diff --git a/src-tauri/src/git/pulse.rs b/src-tauri/src/git/pulse.rs index 1e3cc7c5..a747bff8 100644 --- a/src-tauri/src/git/pulse.rs +++ b/src-tauri/src/git/pulse.rs @@ -53,7 +53,8 @@ fn parse_file_status(code: &str) -> &str { } /// Get the pulse (commit activity feed) for a vault, showing only .md file changes. -pub fn get_vault_pulse(vault_path: &str, limit: usize) -> Result, String> { +/// `skip` offsets into the commit list for pagination; `limit` caps how many to return. +pub fn get_vault_pulse(vault_path: &str, limit: usize, skip: usize) -> Result, String> { let vault = Path::new(vault_path); if !vault.join(".git").exists() { @@ -61,6 +62,7 @@ pub fn get_vault_pulse(vault_path: &str, limit: usize) -> Result Result { expect(screen.getByText('Retry')).toBeInTheDocument() }) - it('shows Load more button when hasMore is true', async () => { - const manyCommits = Array.from({ length: 30 }, (_, i) => ({ - ...mockCommits[0], - hash: `hash${i}`, - shortHash: `h${i}`, - message: `Commit ${i}`, - })) - mockInvokeFn.mockResolvedValue(manyCommits) + it('calls get_vault_pulse with skip=0 on initial load and passes correct page size', async () => { + mockInvokeFn.mockResolvedValue([]) render() await waitFor(() => { - expect(screen.getByText('Load more')).toBeInTheDocument() + expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/test/vault', limit: 20, skip: 0 }) }) }) @@ -205,7 +199,7 @@ describe('PulseView', () => { render() await waitFor(() => { - expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/my/vault', limit: 30 }) + expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/my/vault', limit: 20, skip: 0 }) }) }) diff --git a/src/components/PulseView.tsx b/src/components/PulseView.tsx index 75e198d6..a072c3cc 100644 --- a/src/components/PulseView.tsx +++ b/src/components/PulseView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, memo } from 'react' +import { useState, useEffect, useCallback, useRef, memo } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import type { PulseCommit, PulseFile } from '../types' @@ -207,20 +207,29 @@ function ErrorState({ message, onRetry }: { message: string; onRetry: () => void ) } +const PAGE_SIZE = 20 + export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sidebarCollapsed, onExpandSidebar }: PulseViewProps) { const [commits, setCommits] = useState([]) const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) const [error, setError] = useState(null) const [hasMore, setHasMore] = useState(true) - const batchSize = 30 + const [skip, setSkip] = useState(0) + const sentinelRef = useRef(null) - const loadPulse = useCallback(async (limit: number) => { + // Initial load + const loadInitial = useCallback(async () => { setLoading(true) setError(null) + setCommits([]) + setSkip(0) + setHasMore(true) try { - const result = await tauriCall('get_vault_pulse', { vaultPath, limit }) + const result = await tauriCall('get_vault_pulse', { vaultPath, limit: PAGE_SIZE, skip: 0 }) setCommits(result) - setHasMore(result.length >= limit) + setHasMore(result.length >= PAGE_SIZE) + setSkip(result.length) } catch (err) { const msg = typeof err === 'string' ? err : 'Failed to load activity' setError(msg) @@ -229,12 +238,35 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba } }, [vaultPath]) - useEffect(() => { loadPulse(batchSize) }, [loadPulse]) + // Append next page + const loadMore = useCallback(async () => { + if (loadingMore || !hasMore) return + setLoadingMore(true) + try { + const result = await tauriCall('get_vault_pulse', { vaultPath, limit: PAGE_SIZE, skip }) + setCommits((prev) => [...prev, ...result]) + setHasMore(result.length >= PAGE_SIZE) + setSkip((s) => s + result.length) + } catch { + // silently fail for pagination — user can scroll up/retry + } finally { + setLoadingMore(false) + } + }, [vaultPath, skip, loadingMore, hasMore]) - const handleLoadMore = useCallback(() => { - const nextLimit = commits.length + batchSize - loadPulse(nextLimit) - }, [commits.length, loadPulse]) + useEffect(() => { loadInitial() }, [loadInitial]) + + // Intersection Observer for infinite scroll + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel) return + const observer = new IntersectionObserver( + (entries) => { if (entries[0].isIntersecting) loadMore() }, + { threshold: 0.1 }, + ) + observer.observe(sentinel) + return () => observer.disconnect() + }, [loadMore]) const dayGroups = groupCommitsByDay(commits) @@ -260,12 +292,12 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba {/* Feed */}
- {loading && commits.length === 0 ? ( + {loading ? (
Loading activity…
) : error ? ( - loadPulse(batchSize)} /> + ) : commits.length === 0 ? ( ) : ( @@ -278,15 +310,11 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba onOpenNote={onOpenNote} /> ))} - {hasMore && ( -
- + {/* Sentinel for infinite scroll */} +
+ {loadingMore && ( +
+ Loading…
)} diff --git a/src/test/setup.ts b/src/test/setup.ts index 3fd5595b..4850672d 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -45,6 +45,13 @@ globalThis.ResizeObserver = class { disconnect() {} } as unknown as typeof ResizeObserver +// Mock IntersectionObserver for jsdom (not implemented) +globalThis.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof IntersectionObserver + // Mock @tauri-apps/plugin-opener for test environment vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: vi.fn(),