mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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('后退'))
|
fireEvent.click(screen.getByLabelText('后退'))
|
||||||
expect(onBack).toHaveBeenCalled()
|
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 { useEffect, useState } from 'react'
|
||||||
import { ArrowLeft, ArrowRight, RotateCw } from 'lucide-react'
|
import { ArrowLeft, ArrowRight, Loader2, RotateCw } from 'lucide-react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
canGoBack: boolean
|
canGoBack: boolean
|
||||||
canGoForward: boolean
|
canGoForward: boolean
|
||||||
|
loading?: boolean
|
||||||
onNavigate: (url: string) => void
|
onNavigate: (url: string) => void
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onForward: () => void
|
onForward: () => void
|
||||||
onReload: () => 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)
|
const [draft, setDraft] = useState(url)
|
||||||
useEffect(() => { setDraft(url) }, [url])
|
useEffect(() => { setDraft(url) }, [url])
|
||||||
|
|
||||||
return (
|
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={!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="前进" 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()) }}>
|
<form className="flex-1" onSubmit={(e) => { e.preventDefault(); onNavigate(draft.trim()) }}>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)]"
|
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}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,4 +61,43 @@ describe('BrowserSurface', () => {
|
|||||||
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(false)
|
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(false)
|
||||||
expect(bridge.eval).toHaveBeenLastCalledWith(expect.stringContaining('exit-picker'))
|
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) }, [])
|
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
|
if (!session) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -52,10 +63,11 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
|||||||
url={session.url}
|
url={session.url}
|
||||||
canGoBack={session.canGoBack}
|
canGoBack={session.canGoBack}
|
||||||
canGoForward={session.canGoForward}
|
canGoForward={session.canGoForward}
|
||||||
|
loading={session.loading}
|
||||||
onNavigate={(url) => { store.navigate(sessionId, url); previewBridge.navigate(url) }}
|
onNavigate={(url) => { store.navigate(sessionId, url); previewBridge.navigate(url) }}
|
||||||
onBack={() => { store.goBack(sessionId); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }}
|
onBack={() => { store.goBack(sessionId); store.setLoading(sessionId, true); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }}
|
||||||
onForward={() => { store.goForward(sessionId); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }}
|
onForward={() => { store.goForward(sessionId); store.setLoading(sessionId, true); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }}
|
||||||
onReload={() => previewBridge.navigate(session.url)}
|
onReload={() => { store.setLoading(sessionId, true); previewBridge.navigate(session.url) }}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1 border-b px-2 py-1">
|
<div className="flex items-center gap-1 border-b px-2 py-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -49,12 +49,37 @@ describe('browserPanelStore', () => {
|
|||||||
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(true)
|
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()
|
const st = useBrowserPanelStore.getState()
|
||||||
st.open('s1', 'http://x/a')
|
st.open('s1', 'http://x/a')
|
||||||
|
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)
|
||||||
st.setNavigated('s1', 'http://x/b', 'B')
|
st.setNavigated('s1', 'http://x/b', 'B')
|
||||||
const s = useBrowserPanelStore.getState().bySession['s1']!
|
const s = useBrowserPanelStore.getState().bySession['s1']!
|
||||||
expect(s.url).toBe('http://x/b')
|
expect(s.url).toBe('http://x/b')
|
||||||
expect(s.title).toBe('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) => ({
|
export const useBrowserPanelStore = create<BrowserPanelState>((set) => ({
|
||||||
bySession: {},
|
bySession: {},
|
||||||
open: (sessionId, url) => set((st) => ({
|
open: (sessionId, url) => set((st) => ({
|
||||||
bySession: { ...st.bySession, [sessionId]: empty(url) },
|
bySession: { ...st.bySession, [sessionId]: { ...empty(url), loading: true } },
|
||||||
})),
|
})),
|
||||||
navigate: (sessionId, url) => set((st) => {
|
navigate: (sessionId, url) => set((st) => {
|
||||||
const cur = st.bySession[sessionId] ?? empty(url)
|
const cur = st.bySession[sessionId] ?? empty(url)
|
||||||
const history = [...cur.history.slice(0, cur.historyIndex + 1), 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) => {
|
goBack: (sessionId) => set((st) => {
|
||||||
const cur = st.bySession[sessionId]; if (!cur || cur.historyIndex <= 0) return 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 {
|
@keyframes progress-fill {
|
||||||
from { width: 0%; }
|
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