mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat(desktop): browser loading indicator (progress bar + spinner) with timeout fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d7d7cffddb
commit
41d1cb5c00
@ -34,4 +34,16 @@ describe('BrowserAddressBar', () => {
|
||||
fireEvent.click(screen.getByLabelText('后退'))
|
||||
expect(onBack).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the loading progress bar and a busy reload button while loading', () => {
|
||||
render(<BrowserAddressBar {...baseProps} loading />)
|
||||
expect(screen.getByTestId('browser-loading-bar')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('刷新')).toHaveAttribute('aria-busy', 'true')
|
||||
})
|
||||
|
||||
it('hides the loading progress bar when not loading', () => {
|
||||
render(<BrowserAddressBar {...baseProps} loading={false} />)
|
||||
expect(screen.queryByTestId('browser-loading-bar')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('刷新')).toHaveAttribute('aria-busy', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,25 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, RotateCw } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowRight, Loader2, RotateCw } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
loading?: boolean
|
||||
onNavigate: (url: string) => void
|
||||
onBack: () => void
|
||||
onForward: () => void
|
||||
onReload: () => void
|
||||
}
|
||||
|
||||
export function BrowserAddressBar({ url, canGoBack, canGoForward, onNavigate, onBack, onForward, onReload }: Props) {
|
||||
export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = false, onNavigate, onBack, onForward, onReload }: Props) {
|
||||
const [draft, setDraft] = useState(url)
|
||||
useEffect(() => { setDraft(url) }, [url])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-[var(--color-border)]">
|
||||
<div className="relative flex items-center gap-1 px-2 py-1.5 border-b border-[var(--color-border)]">
|
||||
<button aria-label="后退" disabled={!canGoBack} onClick={onBack} className="p-1 disabled:opacity-40"><ArrowLeft size={16} /></button>
|
||||
<button aria-label="前进" disabled={!canGoForward} onClick={onForward} className="p-1 disabled:opacity-40"><ArrowRight size={16} /></button>
|
||||
<button aria-label="刷新" onClick={onReload} className="p-1"><RotateCw size={16} /></button>
|
||||
<button aria-label="刷新" aria-busy={loading} onClick={onReload} className="p-1">
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : <RotateCw size={16} />}
|
||||
</button>
|
||||
<form className="flex-1" onSubmit={(e) => { e.preventDefault(); onNavigate(draft.trim()) }}>
|
||||
<input
|
||||
className="w-full rounded-md bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)]"
|
||||
@ -28,6 +31,14 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, onNavigate, on
|
||||
spellCheck={false}
|
||||
/>
|
||||
</form>
|
||||
{loading && (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="加载中"
|
||||
data-testid="browser-loading-bar"
|
||||
className="progress-indeterminate-track pointer-events-none absolute inset-x-0 bottom-0 h-0.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -61,4 +61,43 @@ describe('BrowserSurface', () => {
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(false)
|
||||
expect(bridge.eval).toHaveBeenLastCalledWith(expect.stringContaining('exit-picker'))
|
||||
})
|
||||
|
||||
it('renders the loading indicator while the session is loading (open starts loading)', () => {
|
||||
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
|
||||
render(<BrowserSurface sessionId="s1" />)
|
||||
expect(screen.getByTestId('browser-loading-bar')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('刷新')).toHaveAttribute('aria-busy', 'true')
|
||||
})
|
||||
|
||||
it('hides the loading indicator once the page is ready', () => {
|
||||
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
|
||||
useBrowserPanelStore.getState().setReady('s1')
|
||||
render(<BrowserSurface sessionId="s1" />)
|
||||
expect(screen.queryByTestId('browser-loading-bar')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('刷新')).toHaveAttribute('aria-busy', 'false')
|
||||
})
|
||||
|
||||
it('reload flips the session back into loading and shows the indicator', () => {
|
||||
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
|
||||
useBrowserPanelStore.getState().setReady('s1')
|
||||
render(<BrowserSurface sessionId="s1" />)
|
||||
expect(screen.queryByTestId('browser-loading-bar')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByLabelText('刷新'))
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)
|
||||
expect(bridge.navigate).toHaveBeenCalledWith('http://localhost:5173/')
|
||||
expect(screen.getByTestId('browser-loading-bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forces loading off after the timeout fallback elapses', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
|
||||
render(<BrowserSurface sessionId="s1" />)
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)
|
||||
vi.advanceTimersByTime(15000)
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -44,6 +44,17 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
|
||||
useEffect(() => () => { previewBridge.setVisible(false) }, [])
|
||||
|
||||
// 兜底:navigated/ready 依赖注入脚本,若外站 CSP 拦截则永不回灌。loading 变 true 后 ~15s 强制收尾。
|
||||
const isLoading = session?.loading ?? false
|
||||
const currentUrl = session?.url
|
||||
useEffect(() => {
|
||||
if (!isLoading) return
|
||||
const timer = window.setTimeout(() => {
|
||||
useBrowserPanelStore.getState().setLoading(sessionId, false)
|
||||
}, 15000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [isLoading, currentUrl, sessionId])
|
||||
|
||||
if (!session) return null
|
||||
|
||||
return (
|
||||
@ -52,10 +63,11 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
url={session.url}
|
||||
canGoBack={session.canGoBack}
|
||||
canGoForward={session.canGoForward}
|
||||
loading={session.loading}
|
||||
onNavigate={(url) => { store.navigate(sessionId, url); previewBridge.navigate(url) }}
|
||||
onBack={() => { store.goBack(sessionId); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }}
|
||||
onForward={() => { store.goForward(sessionId); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }}
|
||||
onReload={() => previewBridge.navigate(session.url)}
|
||||
onBack={() => { store.goBack(sessionId); store.setLoading(sessionId, true); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }}
|
||||
onForward={() => { store.goForward(sessionId); store.setLoading(sessionId, true); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }}
|
||||
onReload={() => { store.setLoading(sessionId, true); previewBridge.navigate(session.url) }}
|
||||
/>
|
||||
<div className="flex items-center gap-1 border-b px-2 py-1">
|
||||
<button
|
||||
|
||||
@ -49,12 +49,37 @@ describe('browserPanelStore', () => {
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(true)
|
||||
})
|
||||
|
||||
it('setNavigated updates url/title without growing history', () => {
|
||||
it('open starts a session in the loading state', () => {
|
||||
useBrowserPanelStore.getState().open('s1', 'http://localhost/a')
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)
|
||||
})
|
||||
|
||||
it('navigate flips loading back on', () => {
|
||||
const st = useBrowserPanelStore.getState()
|
||||
st.open('s1', 'http://localhost/a')
|
||||
st.setReady('s1') // simulate the page finishing the first load
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(false)
|
||||
st.navigate('s1', 'http://localhost/b')
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)
|
||||
})
|
||||
|
||||
it('setNavigated clears loading and updates url/title without growing history', () => {
|
||||
const st = useBrowserPanelStore.getState()
|
||||
st.open('s1', 'http://x/a')
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)
|
||||
st.setNavigated('s1', 'http://x/b', 'B')
|
||||
const s = useBrowserPanelStore.getState().bySession['s1']!
|
||||
expect(s.url).toBe('http://x/b')
|
||||
expect(s.title).toBe('B')
|
||||
expect(s.loading).toBe(false)
|
||||
expect(s.history).toEqual(['http://x/a'])
|
||||
})
|
||||
|
||||
it('setReady clears loading', () => {
|
||||
const st = useBrowserPanelStore.getState()
|
||||
st.open('s1', 'http://x/a')
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)
|
||||
st.setReady('s1')
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -40,12 +40,12 @@ const withNav = (s: BrowserSessionState): BrowserSessionState => ({
|
||||
export const useBrowserPanelStore = create<BrowserPanelState>((set) => ({
|
||||
bySession: {},
|
||||
open: (sessionId, url) => set((st) => ({
|
||||
bySession: { ...st.bySession, [sessionId]: empty(url) },
|
||||
bySession: { ...st.bySession, [sessionId]: { ...empty(url), loading: true } },
|
||||
})),
|
||||
navigate: (sessionId, url) => set((st) => {
|
||||
const cur = st.bySession[sessionId] ?? empty(url)
|
||||
const history = [...cur.history.slice(0, cur.historyIndex + 1), url]
|
||||
return { bySession: { ...st.bySession, [sessionId]: withNav({ ...cur, isOpen: true, history, historyIndex: history.length - 1 }) } }
|
||||
return { bySession: { ...st.bySession, [sessionId]: withNav({ ...cur, isOpen: true, loading: true, history, historyIndex: history.length - 1 }) } }
|
||||
}),
|
||||
goBack: (sessionId) => set((st) => {
|
||||
const cur = st.bySession[sessionId]; if (!cur || cur.historyIndex <= 0) return st
|
||||
|
||||
@ -1281,3 +1281,32 @@ button, input, textarea, select, a, [role="button"] {
|
||||
@keyframes progress-fill {
|
||||
from { width: 0%; }
|
||||
}
|
||||
|
||||
/* Indeterminate (browser-style) loading bar: a segment that slides across the track */
|
||||
@keyframes progress-indeterminate {
|
||||
0% { left: -40%; right: 100%; }
|
||||
60% { left: 100%; right: -40%; }
|
||||
100% { left: 100%; right: -40%; }
|
||||
}
|
||||
.progress-indeterminate-track {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-indeterminate-track::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -40%;
|
||||
right: 100%;
|
||||
background: var(--color-primary);
|
||||
animation: progress-indeterminate 1.1s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.progress-indeterminate-track::before {
|
||||
animation: none;
|
||||
left: 0;
|
||||
right: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user