mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix(desktop): improve in-app browser preview menus (#761)
Tested: cd desktop && bun run test -- src/components/common/OpenWithMenu.test.tsx src/components/workspace/WorkspaceFileOpenWith.test.tsx src/components/browser/BrowserSurface.test.tsx src/lib/handlePreviewLink.test.ts src/lib/openWithContextForHref.test.ts Tested: bun run check:desktop Confidence: high Scope-risk: narrow
This commit is contained in:
parent
8c99b0f892
commit
34fe0761d2
@ -1,5 +1,5 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
@ -22,6 +22,7 @@ import { useOverlayStore } from '../../stores/overlayStore'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
Object.values(bridge).forEach((f) => f.mockReset())
|
||||
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
|
||||
// browserPanelStore.open() now also opens the unified workbench; keep it isolated.
|
||||
@ -36,6 +37,28 @@ describe('BrowserSurface', () => {
|
||||
expect(bridge.open).toHaveBeenCalledWith('http://localhost:5173/', expect.objectContaining({ width: expect.any(Number) }))
|
||||
})
|
||||
|
||||
it('waits for local preview URLs before opening the native preview', async () => {
|
||||
const url = 'http://127.0.0.1:59028/preview-fs/s1/66estmutl_files/index.html'
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }))
|
||||
useBrowserPanelStore.getState().open('s1', url)
|
||||
render(<BrowserSurface sessionId="s1" />)
|
||||
|
||||
expect(within(screen.getByTestId('preview-host')).getByLabelText('加载中')).toBeInTheDocument()
|
||||
expect(bridge.open).not.toHaveBeenCalled()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith(url, expect.objectContaining({
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
}))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(bridge.open).toHaveBeenCalledWith(url, expect.objectContaining({ width: expect.any(Number) }))
|
||||
})
|
||||
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('renders an empty address bar without opening a preview for a blank session', () => {
|
||||
useBrowserPanelStore.getState().ensureBlank('s1')
|
||||
render(<BrowserSurface sessionId="s1" />)
|
||||
@ -131,7 +154,9 @@ describe('BrowserSurface', () => {
|
||||
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
|
||||
render(<BrowserSurface sessionId="s1" />)
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)
|
||||
vi.advanceTimersByTime(15000)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000)
|
||||
})
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
|
||||
@ -1,14 +1,46 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { Camera, MousePointer2 } from 'lucide-react'
|
||||
import { Camera, Loader2, MousePointer2 } from 'lucide-react'
|
||||
import { BrowserAddressBar } from './BrowserAddressBar'
|
||||
import { computeWebviewBounds } from './computeWebviewBounds'
|
||||
import { isLoopbackHostname } from '../../lib/desktopRuntime'
|
||||
import { previewBridge } from '../../lib/previewBridge'
|
||||
import { subscribePreviewEvents } from '../../lib/previewEvents'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
|
||||
const LOCAL_PREVIEW_PATH_PREFIXES = ['/preview-fs/', '/local-file/']
|
||||
const LOCAL_PREVIEW_READY_TIMEOUT_MS = 2500
|
||||
|
||||
function shouldWaitForLocalPreview(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return isLoopbackHostname(parsed.hostname) &&
|
||||
LOCAL_PREVIEW_PATH_PREFIXES.some((prefix) => parsed.pathname.startsWith(prefix))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForLocalPreview(url: string): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), LOCAL_PREVIEW_READY_TIMEOUT_MS)
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch {
|
||||
// Best-effort warmup only. The native webview still navigates so users can
|
||||
// see the server's own error page or use Reload if the first probe raced.
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const loadSeqRef = useRef(0)
|
||||
const session = useBrowserPanelStore((s) => s.bySession[sessionId])
|
||||
const store = useBrowserPanelStore.getState()
|
||||
const overlayCount = useOverlayStore((s) => s.count)
|
||||
@ -19,12 +51,36 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
previewBridge.setBounds(computeWebviewBounds(el.getBoundingClientRect()))
|
||||
}
|
||||
|
||||
const loadNativePreview = (
|
||||
url: string,
|
||||
action: () => Promise<void>,
|
||||
) => {
|
||||
const seq = loadSeqRef.current + 1
|
||||
loadSeqRef.current = seq
|
||||
void (async () => {
|
||||
if (shouldWaitForLocalPreview(url)) {
|
||||
await waitForLocalPreview(url)
|
||||
}
|
||||
if (loadSeqRef.current !== seq) return
|
||||
await action()
|
||||
})().catch(() => {
|
||||
if (loadSeqRef.current === seq) {
|
||||
useBrowserPanelStore.getState().setLoading(sessionId, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = hostRef.current
|
||||
if (el && session?.url) {
|
||||
previewBridge.open(session.url, computeWebviewBounds(el.getBoundingClientRect()))
|
||||
const bounds = computeWebviewBounds(el.getBoundingClientRect())
|
||||
const url = session.url
|
||||
loadNativePreview(url, () => previewBridge.open(url, bounds))
|
||||
}
|
||||
return () => {
|
||||
loadSeqRef.current += 1
|
||||
previewBridge.close()
|
||||
}
|
||||
return () => { previewBridge.close() }
|
||||
// The visibility-sync effect below owns setVisible() — including the
|
||||
// initial reveal — so it always factors in overlayCount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@ -75,14 +131,15 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
const current = useBrowserPanelStore.getState().bySession[sessionId]
|
||||
store.navigate(sessionId, url)
|
||||
if (current?.url) {
|
||||
previewBridge.navigate(url)
|
||||
loadNativePreview(url, () => previewBridge.navigate(url))
|
||||
return
|
||||
}
|
||||
const el = hostRef.current
|
||||
if (el) {
|
||||
previewBridge.open(url, computeWebviewBounds(el.getBoundingClientRect()))
|
||||
const bounds = computeWebviewBounds(el.getBoundingClientRect())
|
||||
loadNativePreview(url, () => previewBridge.open(url, bounds))
|
||||
} else {
|
||||
previewBridge.navigate(url)
|
||||
loadNativePreview(url, () => previewBridge.navigate(url))
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,16 +192,32 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
canGoForward={session.canGoForward}
|
||||
loading={session.loading}
|
||||
onNavigate={openOrNavigate}
|
||||
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) }}
|
||||
onBack={() => {
|
||||
store.goBack(sessionId)
|
||||
store.setLoading(sessionId, true)
|
||||
const url = useBrowserPanelStore.getState().bySession[sessionId]!.url
|
||||
loadNativePreview(url, () => previewBridge.navigate(url))
|
||||
}}
|
||||
onForward={() => {
|
||||
store.goForward(sessionId)
|
||||
store.setLoading(sessionId, true)
|
||||
const url = useBrowserPanelStore.getState().bySession[sessionId]!.url
|
||||
loadNativePreview(url, () => previewBridge.navigate(url))
|
||||
}}
|
||||
onReload={() => {
|
||||
if (!session.url) return
|
||||
store.setLoading(sessionId, true)
|
||||
previewBridge.navigate(session.url)
|
||||
loadNativePreview(session.url, () => previewBridge.navigate(session.url))
|
||||
}}
|
||||
rightActions={previewActions}
|
||||
/>
|
||||
<div ref={hostRef} className="flex-1" data-testid="preview-host" />
|
||||
<div ref={hostRef} className="relative flex-1 overflow-hidden" data-testid="preview-host">
|
||||
{session.loading && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-tertiary)]">
|
||||
<Loader2 size={18} className="animate-spin" aria-label="加载中" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -69,6 +69,13 @@ describe('OpenWithMenu', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('scrolling the viewport calls onClose', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<OpenWithMenu items={makeItems()} anchor={anchor} onClose={onClose} />)
|
||||
fireEvent.scroll(window)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
describe('triggerEl exclusion (re-click-trigger toggle support)', () => {
|
||||
it('does NOT call onClose when mousedown lands inside triggerEl', () => {
|
||||
// Set up a trigger element + a child within it in the document.
|
||||
|
||||
@ -61,9 +61,17 @@ export function OpenWithMenu({ items, anchor, onClose, triggerEl }: Props) {
|
||||
onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
const onViewportMove = () => onClose()
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
|
||||
window.addEventListener('scroll', onViewportMove, true)
|
||||
window.addEventListener('resize', onViewportMove)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onViewportMove, true)
|
||||
window.removeEventListener('resize', onViewportMove)
|
||||
}
|
||||
}, [onClose, triggerEl])
|
||||
|
||||
return createPortal(
|
||||
|
||||
@ -7,6 +7,8 @@ import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
const openTarget = vi.hoisted(() => vi.fn())
|
||||
const shellOpen = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
const hostOpenPath = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
const browserOpen = vi.hoisted(() => vi.fn())
|
||||
const openPreview = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
vi.mock('../../stores/openTargetStore', () => ({
|
||||
useOpenTargetStore: (sel: (s: unknown) => unknown) =>
|
||||
@ -27,6 +29,18 @@ vi.mock('../../i18n', () => ({
|
||||
v?.target ? `${k}:${v.target}` : k,
|
||||
}))
|
||||
|
||||
vi.mock('../../stores/browserPanelStore', () => ({
|
||||
useBrowserPanelStore: {
|
||||
getState: () => ({ open: browserOpen }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../stores/workspacePanelStore', () => ({
|
||||
useWorkspacePanelStore: {
|
||||
getState: () => ({ openPreview }),
|
||||
},
|
||||
}))
|
||||
|
||||
import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith'
|
||||
|
||||
describe('WorkspaceFileOpenWith', () => {
|
||||
@ -82,4 +96,60 @@ describe('WorkspaceFileOpenWith', () => {
|
||||
expect(shellOpen).not.toHaveBeenCalled()
|
||||
expect(hostOpenPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers workspace preview and in-app browser for generated html files with session context', () => {
|
||||
const { getAllByRole } = render(
|
||||
<WorkspaceFileOpenWith
|
||||
absolutePath="/w/66estmutl_files/index.html"
|
||||
sessionId="s1"
|
||||
workspacePath="66estmutl_files/index.html"
|
||||
/>,
|
||||
)
|
||||
|
||||
const menuItems = getAllByRole('menuitem')
|
||||
const labels = menuItems.map((el) => el.textContent)
|
||||
expect(labels.some((l) => l?.includes('openWith.workspacePreview'))).toBe(true)
|
||||
expect(labels.some((l) => l?.includes('openWith.inAppBrowser'))).toBe(true)
|
||||
expect(labels.some((l) => l?.includes('VS Code'))).toBe(true)
|
||||
expect(labels.some((l) => l?.includes('Finder'))).toBe(true)
|
||||
})
|
||||
|
||||
it('opens the in-app browser preview URL from workspace html files', () => {
|
||||
const { getAllByRole } = render(
|
||||
<WorkspaceFileOpenWith
|
||||
absolutePath="/w/66estmutl_files/index.html"
|
||||
sessionId="s1"
|
||||
workspacePath="66estmutl_files/index.html"
|
||||
/>,
|
||||
)
|
||||
|
||||
const menuItems = getAllByRole('menuitem')
|
||||
const inAppItem = menuItems.find((el) => el.textContent?.includes('openWith.inAppBrowser'))
|
||||
if (!inAppItem) throw new Error('In-app browser menu item not found')
|
||||
|
||||
fireEvent.click(inAppItem)
|
||||
|
||||
expect(browserOpen).toHaveBeenCalledWith(
|
||||
's1',
|
||||
'http://127.0.0.1:3456/preview-fs/s1/66estmutl_files/index.html',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the workspace preview for workspace files with session context', () => {
|
||||
const { getAllByRole } = render(
|
||||
<WorkspaceFileOpenWith
|
||||
absolutePath="/w/report.md"
|
||||
sessionId="s1"
|
||||
workspacePath="report.md"
|
||||
/>,
|
||||
)
|
||||
|
||||
const menuItems = getAllByRole('menuitem')
|
||||
const previewItem = menuItems.find((el) => el.textContent?.includes('openWith.workspacePreview'))
|
||||
if (!previewItem) throw new Error('Workspace preview menu item not found')
|
||||
|
||||
fireEvent.click(previewItem)
|
||||
|
||||
expect(openPreview).toHaveBeenCalledWith('s1', 'report.md', 'file')
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,6 +4,10 @@ import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||
import { buildOpenWithItems, type OpenWithItem, type OpenWithDeps } from '../../lib/openWithItems'
|
||||
import { getDesktopHost } from '../../lib/desktopHost'
|
||||
import { getServerBaseUrl } from '../../lib/desktopRuntime'
|
||||
import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
import { TargetIcon } from '../common/TargetIcon'
|
||||
|
||||
function openExternal(path: string) {
|
||||
@ -12,9 +16,13 @@ function openExternal(path: string) {
|
||||
|
||||
export function WorkspaceFileOpenWith({
|
||||
absolutePath,
|
||||
sessionId,
|
||||
workspacePath,
|
||||
onAfterSelect,
|
||||
}: {
|
||||
absolutePath: string
|
||||
sessionId?: string
|
||||
workspacePath?: string
|
||||
onAfterSelect?: () => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
@ -30,8 +38,14 @@ export function WorkspaceFileOpenWith({
|
||||
// but OpenWithDeps.t expects (key: string, vars?: Record<string, string>) => string.
|
||||
// All keys buildOpenWithItems calls are valid TranslationKeys, so the cast is safe.
|
||||
const deps: OpenWithDeps = {
|
||||
openInAppBrowser: () => {},
|
||||
openWorkspacePreview: () => {},
|
||||
openInAppBrowser: (url) => {
|
||||
if (!sessionId) return
|
||||
useBrowserPanelStore.getState().open(sessionId, url)
|
||||
},
|
||||
openWorkspacePreview: (relPath) => {
|
||||
if (!sessionId) return
|
||||
void useWorkspacePanelStore.getState().openPreview(sessionId, relPath, 'file')
|
||||
},
|
||||
openSystem: (p) => openExternal(p),
|
||||
openTarget: (id, p) => {
|
||||
void openTarget(id, p)
|
||||
@ -39,7 +53,12 @@ export function WorkspaceFileOpenWith({
|
||||
t: (key, vars) => t(key as TranslationKey, vars),
|
||||
}
|
||||
const items: OpenWithItem[] = buildOpenWithItems(
|
||||
{ kind: 'file', absolutePath, previewable: false },
|
||||
sessionId && workspacePath
|
||||
? openWithContextForWorkspaceFile(workspacePath, absolutePath, {
|
||||
sessionId,
|
||||
serverBaseUrl: getServerBaseUrl(),
|
||||
})
|
||||
: { kind: 'file', absolutePath, previewable: false },
|
||||
targets,
|
||||
deps,
|
||||
)
|
||||
|
||||
@ -1516,6 +1516,8 @@ export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelPr
|
||||
</button>
|
||||
<WorkspaceFileOpenWith
|
||||
absolutePath={resolveWorkspaceAttachmentPath(status?.workDir, fileContextMenu.path)}
|
||||
sessionId={sessionId}
|
||||
workspacePath={fileContextMenu.isDirectory ? undefined : fileContextMenu.path}
|
||||
onAfterSelect={() => setFileContextMenu(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -64,6 +64,17 @@ describe('handlePreviewLink', () => {
|
||||
expect(deps.openFilePreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes generated *_files index.html to openBrowser with the preview-fs url', () => {
|
||||
const deps = makeDeps()
|
||||
const handled = handlePreviewLink('66estmutl_files/index.html', deps)
|
||||
expect(handled).toBe(true)
|
||||
expect(deps.openBrowser).toHaveBeenCalledWith(
|
||||
's1',
|
||||
'http://127.0.0.1:8787/preview-fs/s1/66estmutl_files/index.html',
|
||||
)
|
||||
expect(deps.openFilePreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a frontend project index.html to workspace preview instead of static browser preview', () => {
|
||||
const deps = makeDeps()
|
||||
const handled = handlePreviewLink('todo-app/index.html', deps)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const HTML_EXT = /\.(html?|xhtml)$/i
|
||||
const INDEX_HTML_RE = /(^|\/)index\.html?$/i
|
||||
const GENERATED_STATIC_FILES_DIR_RE = /(^|\/)[^/]+_files\/index\.html?$/i
|
||||
|
||||
const STATIC_OUTPUT_DIRS = new Set([
|
||||
'build',
|
||||
@ -28,6 +29,7 @@ export function shouldOfferStaticHtmlPreview(filePath: string): boolean {
|
||||
const normalized = normalizePathForPolicy(filePath)
|
||||
if (!HTML_EXT.test(normalized)) return false
|
||||
if (!INDEX_HTML_RE.test(normalized)) return true
|
||||
if (GENERATED_STATIC_FILES_DIR_RE.test(normalized)) return true
|
||||
|
||||
return normalized
|
||||
.split('/')
|
||||
|
||||
@ -68,6 +68,17 @@ describe('openWithContextForWorkspaceFile', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('generated *_files index.html rel path → has inAppBrowserUrl equal to previewFsUrl', () => {
|
||||
const result = openWithContextForWorkspaceFile('66estmutl_files/index.html', '/w/proj/66estmutl_files/index.html', { sessionId: SESSION, serverBaseUrl: BASE })
|
||||
expect(result).toEqual({
|
||||
kind: 'file',
|
||||
absolutePath: '/w/proj/66estmutl_files/index.html',
|
||||
relPath: '66estmutl_files/index.html',
|
||||
previewable: true,
|
||||
inAppBrowserUrl: previewFsUrl(BASE, SESSION, '66estmutl_files/index.html'),
|
||||
})
|
||||
})
|
||||
|
||||
it('built dist index.html rel path → has inAppBrowserUrl equal to previewFsUrl', () => {
|
||||
const result = openWithContextForWorkspaceFile('todo-app/dist/index.html', '/w/proj/todo-app/dist/index.html', { sessionId: SESSION, serverBaseUrl: BASE })
|
||||
expect(result).toEqual({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user