fix(desktop): open remote file rows in the in-app preview
A plain click on a composer file row in remote mode handed the backend's file:// URL to the local browser bridge, which cannot resolve a path that only exists on the gateway host. Route remote non-HTML file targets to the gateway-backed in-app preview pane instead; local files, ordinary URLs, and remote HTML (staged locally by openPreviewTargetInBrowser) keep their existing browser path. Supersedes #70296 and #57878. Co-authored-by: lesterlxt <153183032+lesterlxt@users.noreply.github.com> Co-authored-by: cj52973 <cjenkins@scacpa.org>
This commit is contained in:
parent
069551d19b
commit
17a5a95871
|
|
@ -1,11 +1,22 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $previewTabs, closeRightRail } from '@/store/preview'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { PreviewStatusRow } from './preview-row'
|
||||
|
||||
describe('PreviewStatusRow', () => {
|
||||
beforeEach(() => {
|
||||
$connection.set(null)
|
||||
closeRightRail()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
closeRightRail()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('keeps the preview tooltip label inline inside the portaled decoration', async () => {
|
||||
|
|
@ -27,4 +38,118 @@ describe('PreviewStatusRow', () => {
|
|||
expect(label?.classList.contains('inline-flex')).toBe(true)
|
||||
expect(label?.classList.contains('flex')).toBe(false)
|
||||
})
|
||||
|
||||
it('opens remote non-HTML file artifacts in the in-app preview instead of the local browser bridge', async () => {
|
||||
const remotePath = '/home/agent/report.pdf'
|
||||
const openPreviewInBrowser = vi.fn(async () => undefined)
|
||||
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
api: vi.fn(async () => ({ binary: true, byteSize: 42, mimeType: 'application/pdf' })),
|
||||
normalizePreviewTarget: vi.fn(async () => ({
|
||||
kind: 'file',
|
||||
label: 'report.pdf',
|
||||
path: remotePath,
|
||||
previewKind: 'binary',
|
||||
source: remotePath,
|
||||
url: 'file:///home/agent/report.pdf'
|
||||
})),
|
||||
openPreviewInBrowser
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<PreviewStatusRow
|
||||
item={{ cwd: '/home/agent', id: remotePath, label: 'report.pdf', target: remotePath }}
|
||||
onDismiss={() => undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('report.pdf'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTabs.get()).toEqual([
|
||||
expect.objectContaining({ target: expect.objectContaining({ kind: 'file', path: remotePath }) })
|
||||
])
|
||||
})
|
||||
expect(openPreviewInBrowser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps local file artifacts on the browser bridge', async () => {
|
||||
const localPath = '/Users/alice/report.pdf'
|
||||
const openPreviewInBrowser = vi.fn(async () => undefined)
|
||||
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
normalizePreviewTarget: vi.fn(async () => ({
|
||||
kind: 'file',
|
||||
label: 'report.pdf',
|
||||
path: localPath,
|
||||
previewKind: 'binary',
|
||||
source: localPath,
|
||||
url: 'file:///Users/alice/report.pdf'
|
||||
})),
|
||||
openPreviewInBrowser
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<PreviewStatusRow
|
||||
item={{ cwd: '/Users/alice', id: localPath, label: 'report.pdf', target: localPath }}
|
||||
onDismiss={() => undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('report.pdf'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openPreviewInBrowser).toHaveBeenCalledWith('file:///Users/alice/report.pdf')
|
||||
})
|
||||
expect($previewTabs.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps remote HTML on the staged browser-open path, not the in-app pane', async () => {
|
||||
const remotePath = '/home/agent/index.html'
|
||||
const html = '<!doctype html><html><body>hi</body></html>'
|
||||
const dataUrl = `data:text/html;base64,${btoa(html)}`
|
||||
const openPreviewInBrowser = vi.fn(async () => undefined)
|
||||
const saveImageBuffer = vi.fn(async () => '/tmp/staged.html')
|
||||
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
api: vi.fn(async () => dataUrl),
|
||||
normalizePreviewTarget: vi.fn(async () => ({
|
||||
kind: 'file',
|
||||
label: 'index.html',
|
||||
path: remotePath,
|
||||
previewKind: 'html',
|
||||
source: remotePath,
|
||||
url: 'file:///home/agent/index.html'
|
||||
})),
|
||||
openPreviewInBrowser,
|
||||
saveImageBuffer
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<PreviewStatusRow
|
||||
item={{ cwd: '/home/agent', id: remotePath, label: 'index.html', target: remotePath }}
|
||||
onDismiss={() => undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('index.html'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveImageBuffer).toHaveBeenCalled()
|
||||
expect(openPreviewInBrowser).toHaveBeenCalledWith('file:///tmp/staged.html')
|
||||
})
|
||||
expect($previewTabs.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget, openPreviewTargetInBrowser } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PREVIEW_PANE_ID } from '@/store/layout'
|
||||
|
|
@ -59,9 +60,23 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
|
|||
}
|
||||
}
|
||||
|
||||
const openInBrowser = async () => {
|
||||
const openDefaultTarget = async () => {
|
||||
try {
|
||||
await openPreviewTargetInBrowser(await resolveTarget())
|
||||
const target = await resolveTarget()
|
||||
|
||||
// A file:// URL resolved in remote mode names a file on the backend
|
||||
// host, not on the machine running Electron. Keep local files and
|
||||
// ordinary URLs on the browser path, but route remote files through the
|
||||
// in-app preview pane so its filesystem adapter reads via the gateway.
|
||||
// (Remote HTML stays on openPreviewTargetInBrowser, which stages a
|
||||
// sanitized local copy before opening it.)
|
||||
if (target.kind === 'file' && target.previewKind !== 'html' && isDesktopFsRemoteMode()) {
|
||||
openPreview(target, 'tool-result')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await openPreviewTargetInBrowser(target)
|
||||
} catch (error) {
|
||||
notifyError(error, t.preview.unavailable)
|
||||
}
|
||||
|
|
@ -77,13 +92,14 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
|
|||
size="0.8rem"
|
||||
/>
|
||||
}
|
||||
// Plain click opens the link in the browser; ⌘/Ctrl-click opens it in the
|
||||
// in-app preview pane instead. (isOpen still toggles the pane closed.)
|
||||
// Plain click opens the link in the browser, except remote files which
|
||||
// only the in-app gateway-backed preview can read. ⌘/Ctrl-click always
|
||||
// uses the in-app preview pane. (isOpen still toggles the pane closed.)
|
||||
onActivate={event => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
void togglePreview()
|
||||
} else {
|
||||
void openInBrowser()
|
||||
void openDefaultTarget()
|
||||
}
|
||||
}}
|
||||
trailing={
|
||||
|
|
|
|||
Loading…
Reference in New Issue