Files
tolaria/src/components/PulseView.tsx
Test 826cda852a Pulse: lazy pagination with IntersectionObserver infinite scroll
- 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
2026-03-06 21:04:24 +01:00

326 lines
12 KiB
TypeScript

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'
import { relativeDate } from '../utils/noteListHelpers'
import {
Plus, Minus, PencilSimple, GitCommit, ArrowSquareOut,
FileText, CaretDown, CaretRight, Pulse,
} from '@phosphor-icons/react'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
interface PulseViewProps {
vaultPath: string
onOpenNote?: (relativePath: string) => void
sidebarCollapsed?: boolean
onExpandSidebar?: () => void
}
function groupCommitsByDay(commits: PulseCommit[]): Map<string, PulseCommit[]> {
const groups = new Map<string, PulseCommit[]>()
for (const commit of commits) {
const date = new Date(commit.date * 1000)
const key = date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
const existing = groups.get(key)
if (existing) {
existing.push(commit)
} else {
groups.set(key, [commit])
}
}
return groups
}
function isToday(dateKey: string): boolean {
const today = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
return dateKey === today
}
function isYesterday(dateKey: string): boolean {
const yesterday = new Date(Date.now() - 86400000)
.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
return dateKey === yesterday
}
function formatDayLabel(dateKey: string): string {
if (isToday(dateKey)) return 'Today'
if (isYesterday(dateKey)) return 'Yesterday'
return dateKey
}
const STATUS_ICON = {
added: Plus,
modified: PencilSimple,
deleted: Minus,
} as const
const STATUS_COLOR = {
added: 'var(--accent-green, #16a34a)',
modified: 'var(--accent-orange, #ea580c)',
deleted: 'var(--destructive, #dc2626)',
} as const
function SummaryBadges({ added, modified, deleted }: { added: number; modified: number; deleted: number }) {
return (
<div className="flex items-center" style={{ gap: 8 }}>
{added > 0 && <span className="text-[11px] font-medium" style={{ color: STATUS_COLOR.added }}>+{added}</span>}
{modified > 0 && <span className="text-[11px] font-medium" style={{ color: STATUS_COLOR.modified }}>~{modified}</span>}
{deleted > 0 && <span className="text-[11px] font-medium" style={{ color: STATUS_COLOR.deleted }}>-{deleted}</span>}
</div>
)
}
function FileItem({ file, onOpenNote }: { file: PulseFile; onOpenNote?: (path: string) => void }) {
const Icon = STATUS_ICON[file.status] ?? FileText
const color = STATUS_COLOR[file.status] ?? 'var(--muted-foreground)'
const isDeleted = file.status === 'deleted'
return (
<div
className={`flex items-center rounded transition-colors ${isDeleted ? '' : 'cursor-pointer hover:bg-accent'}`}
style={{ gap: 6, padding: '3px 8px' }}
onClick={isDeleted ? undefined : () => onOpenNote?.(file.path)}
title={file.path}
>
<Icon size={12} style={{ color, flexShrink: 0 }} weight="bold" />
<span
className={`truncate text-[12px] ${isDeleted ? 'text-muted-foreground line-through' : 'text-foreground'}`}
>
{file.title}
</span>
</div>
)
}
function CommitCard({ commit, onOpenNote }: { commit: PulseCommit; onOpenNote?: (path: string) => void }) {
const [expanded, setExpanded] = useState(false)
const Chevron = expanded ? CaretDown : CaretRight
return (
<div className="border-b border-border" style={{ padding: '10px 16px' }}>
<div className="flex items-start justify-between" style={{ gap: 8 }}>
<div className="min-w-0 flex-1">
<div className="flex items-center" style={{ gap: 6, marginBottom: 2 }}>
<GitCommit size={13} className="text-muted-foreground" style={{ flexShrink: 0 }} />
<span className="truncate text-[13px] font-medium text-foreground">{commit.message}</span>
</div>
<div className="flex items-center" style={{ gap: 8 }}>
<span className="text-[11px] text-muted-foreground">{relativeDate(commit.date)}</span>
{commit.githubUrl ? (
<a
className="flex items-center text-[11px] font-mono text-primary no-underline hover:underline"
style={{ gap: 3 }}
href={commit.githubUrl}
onClick={(e) => {
e.preventDefault()
if (isTauri()) {
import('@tauri-apps/plugin-opener').then((mod) => mod.openUrl(commit.githubUrl!))
} else {
window.open(commit.githubUrl!, '_blank')
}
}}
title="Open on GitHub"
>
{commit.shortHash}
<ArrowSquareOut size={10} />
</a>
) : (
<span className="text-[11px] font-mono text-muted-foreground">{commit.shortHash}</span>
)}
<SummaryBadges added={commit.added} modified={commit.modified} deleted={commit.deleted} />
</div>
</div>
<button
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground hover:text-foreground"
style={{ width: 20, height: 20 }}
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? 'Collapse files' : 'Expand files'}
>
<Chevron size={12} />
</button>
</div>
{expanded && commit.files.length > 0 && (
<div style={{ marginTop: 6, marginLeft: 4 }}>
{commit.files.map((file) => (
<FileItem key={file.path} file={file} onOpenNote={onOpenNote} />
))}
</div>
)}
</div>
)
}
function DayGroup({ label, commits, onOpenNote }: {
label: string; commits: PulseCommit[]; onOpenNote?: (path: string) => void
}) {
const [collapsed, setCollapsed] = useState(false)
const Chevron = collapsed ? CaretRight : CaretDown
return (
<div>
<div
className="flex cursor-pointer select-none items-center border-b border-border bg-muted/50 transition-colors hover:bg-muted"
style={{ padding: '6px 16px', gap: 6 }}
onClick={() => setCollapsed((v) => !v)}
>
<Chevron size={12} className="text-muted-foreground" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<span className="text-[11px] text-muted-foreground">
({commits.length} {commits.length === 1 ? 'commit' : 'commits'})
</span>
</div>
{!collapsed && commits.map((commit) => (
<CommitCard key={commit.hash} commit={commit} onOpenNote={onOpenNote} />
))}
</div>
)
}
function EmptyState() {
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<Pulse size={32} style={{ marginBottom: 8, opacity: 0.5 }} />
<p className="text-[13px]">No activity yet</p>
<p className="text-[12px]" style={{ marginTop: 4 }}>
Commit changes to see your vault's pulse
</p>
</div>
)
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<p className="text-[13px]">{message}</p>
<button
className="mt-2 cursor-pointer rounded border border-border bg-transparent px-3 py-1 text-[12px] text-foreground transition-colors hover:bg-accent"
onClick={onRetry}
>
Retry
</button>
</div>
)
}
const PAGE_SIZE = 20
export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sidebarCollapsed, onExpandSidebar }: PulseViewProps) {
const [commits, setCommits] = useState<PulseCommit[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasMore, setHasMore] = useState(true)
const [skip, setSkip] = useState(0)
const sentinelRef = useRef<HTMLDivElement | null>(null)
// Initial load
const loadInitial = useCallback(async () => {
setLoading(true)
setError(null)
setCommits([])
setSkip(0)
setHasMore(true)
try {
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit: PAGE_SIZE, skip: 0 })
setCommits(result)
setHasMore(result.length >= PAGE_SIZE)
setSkip(result.length)
} catch (err) {
const msg = typeof err === 'string' ? err : 'Failed to load activity'
setError(msg)
} finally {
setLoading(false)
}
}, [vaultPath])
// Append next page
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore) return
setLoadingMore(true)
try {
const result = await tauriCall<PulseCommit[]>('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])
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)
return (
<div className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-background">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-border" style={{ height: 52, padding: '0 16px' }}>
<div className="flex items-center" style={{ gap: 8 }}>
{sidebarCollapsed && onExpandSidebar && (
<button
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ width: 24, height: 24 }}
onClick={onExpandSidebar}
aria-label="Expand sidebar"
>
<CaretRight size={14} weight="bold" />
</button>
)}
<Pulse size={16} className="text-primary" />
<span className="text-[14px] font-semibold text-foreground">Pulse</span>
</div>
</div>
{/* Feed */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center" style={{ padding: 32 }}>
<span className="text-[13px] text-muted-foreground">Loading activity</span>
</div>
) : error ? (
<ErrorState message={error} onRetry={loadInitial} />
) : commits.length === 0 ? (
<EmptyState />
) : (
<>
{Array.from(dayGroups.entries()).map(([day, dayCommits]) => (
<DayGroup
key={day}
label={formatDayLabel(day)}
commits={dayCommits}
onOpenNote={onOpenNote}
/>
))}
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} style={{ height: 1 }} />
{loadingMore && (
<div className="flex items-center justify-center" style={{ padding: 12 }}>
<span className="text-[12px] text-muted-foreground">Loading</span>
</div>
)}
</>
)}
</div>
</div>
)
})