diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx
index 7a57007fe40..bd835390688 100644
--- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx
+++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx
@@ -221,12 +221,16 @@ function getActionPresentation(
showDetail: true
}
}
- if (classification.kind === 'existing-file' || classification.kind === 'absolute-file') {
+ if (
+ classification.kind === 'existing-file' ||
+ classification.kind === 'absolute-file' ||
+ classification.kind === 'html-file-url'
+ ) {
return {
detail:
- classification.kind === 'absolute-file'
- ? classification.filePath
- : classification.relativePath,
+ classification.kind === 'existing-file'
+ ? classification.relativePath
+ : classification.filePath,
icon: ,
label: translate('auto.components.tab.bar.TabBarCreateEntry.25dc1cd653', 'Open file'),
prioritizeFilename: true,
diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts
index 8c3df5adaac..aedf821893e 100644
--- a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts
+++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts
@@ -27,6 +27,7 @@ describe('openTabEntryWithOperations', () => {
return {
createRuntimePath: vi.fn().mockResolvedValue(undefined),
openWorkspaceBrowserTab: vi.fn().mockResolvedValue(undefined),
+ openHtmlFilePreview: vi.fn(),
openFile: vi.fn(),
statRuntimePath: vi.fn().mockResolvedValue({ size: 1, isDirectory: false, mtime: 1 }),
statUserOpenedPath: vi.fn().mockResolvedValue({ isDirectory: false, escapesWorktree: false }),
@@ -35,21 +36,45 @@ describe('openTabEntryWithOperations', () => {
}
}
+ const runtimeContext = {
+ target: { kind: 'local' as const },
+ worktreeId: 'wt-1',
+ worktreePath: '/repo'
+ }
const baseArgs = {
fileList: readyFiles(['src/index.ts']),
worktreeId: 'wt-1',
groupId: 'group-1',
- worktreePath: '/repo',
- runtimeContext: {
- target: { kind: 'local' as const },
- worktreeId: 'wt-1',
- worktreePath: '/repo'
- },
+ getFileContext: () => ({ worktreePath: '/repo', runtimeContext }),
allowAbsolutePaths: true,
localPlatform: 'posix' as const,
searchEngine: 'google' as const
}
+ it.each([true, false])(
+ 'previews HTML file URLs without creating files (local paths: %s)',
+ async (allowAbsolutePaths) => {
+ const operations = makeOperations()
+ await openTabEntryWithOperations({
+ ...baseArgs,
+ allowAbsolutePaths,
+ getFileContext: () => {
+ throw new Error('Preview requested file mutation context')
+ },
+ query: 'file:///repo/docs/report%20%E4%B8%AD.html',
+ operations
+ })
+ expect(operations.openHtmlFilePreview).toHaveBeenCalledWith({
+ filePath: '/repo/docs/report 中.html',
+ worktreeId: 'wt-1',
+ targetGroupId: 'group-1'
+ })
+ expect(operations.createRuntimePath).not.toHaveBeenCalled()
+ expect(operations.statUserOpenedPath).not.toHaveBeenCalled()
+ expect(operations.openWorkspaceBrowserTab).not.toHaveBeenCalled()
+ }
+ )
+
it('stats existing files before opening and rejects directories', async () => {
const operations = makeOperations({
statRuntimePath: vi.fn().mockResolvedValue({ size: 0, isDirectory: true, mtime: 1 })
@@ -67,12 +92,12 @@ describe('openTabEntryWithOperations', () => {
await openTabEntryWithOperations({ ...baseArgs, query: 'docs/new.md', operations })
expect(operations.createRuntimePath).toHaveBeenCalledWith(
- baseArgs.runtimeContext,
+ runtimeContext,
'/repo/docs',
'directory'
)
expect(operations.createRuntimePath).toHaveBeenCalledWith(
- baseArgs.runtimeContext,
+ runtimeContext,
'/repo/docs/new.md',
'file'
)
@@ -119,19 +144,19 @@ describe('openTabEntryWithOperations', () => {
expect(operations.createRuntimePath).toHaveBeenNthCalledWith(
1,
- baseArgs.runtimeContext,
+ runtimeContext,
'/repo/.tmp',
'directory'
)
expect(operations.createRuntimePath).toHaveBeenNthCalledWith(
2,
- baseArgs.runtimeContext,
+ runtimeContext,
'/repo/.tmp/direct-entry-validation',
'directory'
)
expect(operations.createRuntimePath).toHaveBeenNthCalledWith(
3,
- baseArgs.runtimeContext,
+ runtimeContext,
'/repo/.tmp/direct-entry-validation/created.md',
'file'
)
@@ -158,9 +183,9 @@ describe('openTabEntryWithOperations', () => {
await openTabEntryWithOperations({ ...baseArgs, query: 'docs/new.md', operations })
- expect(operations.statRuntimePath).toHaveBeenCalledWith(baseArgs.runtimeContext, '/repo/docs')
+ expect(operations.statRuntimePath).toHaveBeenCalledWith(runtimeContext, '/repo/docs')
expect(operations.createRuntimePath).toHaveBeenLastCalledWith(
- baseArgs.runtimeContext,
+ runtimeContext,
'/repo/docs/new.md',
'file'
)
@@ -188,10 +213,7 @@ describe('openTabEntryWithOperations', () => {
await openTabEntryWithOperations({ ...baseArgs, query: 'docs/race.md', operations })
- expect(operations.statRuntimePath).toHaveBeenCalledWith(
- baseArgs.runtimeContext,
- '/repo/docs/race.md'
- )
+ expect(operations.statRuntimePath).toHaveBeenCalledWith(runtimeContext, '/repo/docs/race.md')
expect(operations.openFile).toHaveBeenCalled()
})
@@ -279,10 +301,7 @@ describe('openTabEntryWithOperations', () => {
operations
})
- expect(operations.statUserOpenedPath).toHaveBeenCalledWith(
- baseArgs.runtimeContext,
- '/tmp/notes.md'
- )
+ expect(operations.statUserOpenedPath).toHaveBeenCalledWith(runtimeContext, '/tmp/notes.md')
expect(operations.openFile).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/tmp/notes.md',
@@ -373,16 +392,13 @@ describe('openTabEntryWithOperations', () => {
await openTabEntryWithOperations({
...baseArgs,
localPlatform: 'windows',
- worktreePath: 'C:/repo',
+ getFileContext: () => ({ worktreePath: 'C:/repo', runtimeContext }),
classification: { kind: 'absolute-file', filePath: 'C:\\tmp\\notes.md' },
query: 'C:\\tmp\\notes.md',
operations
})
- expect(operations.statUserOpenedPath).toHaveBeenCalledWith(
- baseArgs.runtimeContext,
- 'C:/tmp/notes.md'
- )
+ expect(operations.statUserOpenedPath).toHaveBeenCalledWith(runtimeContext, 'C:/tmp/notes.md')
})
it('stops after the file check when ownership becomes remote or ambiguous', async () => {
diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts
index 5cb99e5277b..30c7cf02690 100644
--- a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts
+++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts
@@ -26,8 +26,7 @@ import {
import { openAbsoluteTabEntryFile } from './tab-create-entry-absolute-file'
import {
getTabEntryAllowAbsolutePaths,
- getTabEntryFileOperationContext,
- isTabEntryAbsolutePathAllowed
+ getTabEntryFileOperationContext
} from './tab-create-entry-local-path'
import type { TabEntryLocalPlatform } from './tab-create-entry-path-validation'
export {
@@ -48,6 +47,7 @@ export {
isTabEntryAbsolutePathAllowed
} from './tab-create-entry-local-path'
import { statUserOpenedPath } from '@/lib/user-opened-local-path'
+import { openHtmlFilePreview } from '@/lib/open-html-file-preview'
export type TabCreateEntryArgs = {
classification?: TabEntryActionClassification
@@ -60,6 +60,7 @@ export type TabCreateEntryArgs = {
export type TabEntryOperations = {
createRuntimePath: typeof createRuntimePath
openWorkspaceBrowserTab: typeof openWorkspaceBrowserTab
+ openHtmlFilePreview: typeof openHtmlFilePreview
openFile: (
file: Omit,
options?: { preview?: boolean; targetGroupId?: string }
@@ -74,8 +75,7 @@ type OpenTabEntryWithOperationsArgs = {
fileList: RuntimeFileListState
worktreeId: string
groupId: string
- worktreePath: string
- runtimeContext: RuntimeFileOperationArgs
+ getFileContext: () => { worktreePath: string; runtimeContext: RuntimeFileOperationArgs }
allowAbsolutePaths: boolean
localPlatform: TabEntryLocalPlatform
searchEngine: SearchEngine
@@ -185,11 +185,10 @@ export async function openTabEntryWithOperations({
localPlatform,
operations,
query,
- runtimeContext,
+ getFileContext,
searchEngine,
searchUrlOptions,
- worktreeId,
- worktreePath
+ worktreeId
}: OpenTabEntryWithOperationsArgs): Promise {
const entryContext: TabEntryOptionsContext = { allowAbsolutePaths, localPlatform, searchEngine }
const classification =
@@ -198,6 +197,15 @@ export async function openTabEntryWithOperations({
throw new Error(classification.message)
}
+ if (classification.kind === 'html-file-url') {
+ operations.openHtmlFilePreview({
+ filePath: classification.filePath,
+ worktreeId,
+ targetGroupId: groupId
+ })
+ return
+ }
+
if (isNetworkTabEntry(classification)) {
await operations.openWorkspaceBrowserTab(
getNetworkTabRequest(classification, { worktreeId, groupId }, searchUrlOptions)
@@ -205,6 +213,8 @@ export async function openTabEntryWithOperations({
return
}
+ const { worktreePath, runtimeContext } = getFileContext()
+
if (classification.kind === 'absolute-file') {
if (!allowAbsolutePaths) {
throw new Error(TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE)
@@ -260,33 +270,32 @@ export async function openTabEntryWithOperations({
export async function openTabBarEntry(args: TabCreateEntryArgs): Promise {
const state = useAppStore.getState()
const searchUrlOptions = { kagiSessionLink: state.browserKagiSessionLink }
- if (args.classification && isNetworkTabEntry(args.classification)) {
- await openWorkspaceBrowserTab(getNetworkTabRequest(args.classification, args, searchUrlOptions))
- return
- }
- const searchEngine = state.browserDefaultSearchEngine ?? DEFAULT_SEARCH_ENGINE
- const worktree = state.getKnownWorktreeById(args.worktreeId)
- if (!worktree) {
- throw new Error('No active worktree.')
- }
- const runtimeContext = getTabEntryFileOperationContext(state, args.worktreeId, worktree.path)
- const allowAbsolutePaths = isTabEntryAbsolutePathAllowed(runtimeContext)
+ const allowAbsolutePaths = getTabEntryAllowAbsolutePaths(state, args.worktreeId)
const localPlatform = getRendererAppPlatform() === 'win32' ? 'windows' : 'posix'
await openTabEntryWithOperations({
query: args.query,
fileList: args.fileList,
worktreeId: args.worktreeId,
groupId: args.groupId,
- worktreePath: worktree.path,
- runtimeContext,
+ getFileContext: () => {
+ const worktree = state.getKnownWorktreeById(args.worktreeId)
+ if (!worktree) {
+ throw new Error('No active worktree.')
+ }
+ return {
+ worktreePath: worktree.path,
+ runtimeContext: getTabEntryFileOperationContext(state, args.worktreeId, worktree.path)
+ }
+ },
allowAbsolutePaths,
localPlatform,
- searchEngine,
+ searchEngine: state.browserDefaultSearchEngine ?? DEFAULT_SEARCH_ENGINE,
searchUrlOptions,
classification: args.classification,
operations: {
createRuntimePath,
openWorkspaceBrowserTab,
+ openHtmlFilePreview,
openFile: state.openFile,
statRuntimePath,
statUserOpenedPath,
diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts
index 9fb842747a8..f261c6c18bf 100644
--- a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts
+++ b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts
@@ -19,6 +19,26 @@ it.each(['.env api', 'api .env'])('offers the multi-term file match for %s', (qu
})
describe('tab create entry classification', () => {
+ it.each([true, false])(
+ 'offers HTML URLs while the file list loads (local paths: %s)',
+ (allowAbsolutePaths) => {
+ expect(
+ classifyTabEntryQuery(
+ 'file:///repo/report.html',
+ {
+ files: [],
+ loading: true,
+ loadError: null
+ },
+ { allowAbsolutePaths }
+ )
+ ).toEqual({
+ kind: 'html-file-url',
+ filePath: '/repo/report.html'
+ })
+ }
+ )
+
// Kept word-for-word in step with the omnibox placeholder (see
// TabBarCreateEntry.keyboard.test.tsx), so the two never drift apart.
it('advertises tab search in the empty-query message', () => {
diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts
index 5c1b715d5f1..9bd0a4e7df9 100644
--- a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts
+++ b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts
@@ -16,6 +16,7 @@ import {
validateNewTabEntryRelativePath
} from './tab-create-entry-path-validation'
import { classifyExplicitUrl, classifyHostUrl } from './tab-create-entry-url-classification'
+import { htmlFilePathFromUrl } from '../../../../shared/html-file-url'
export {
isTabEntryAbsolutePathLike,
@@ -40,6 +41,7 @@ export type TabEntryClassification =
| { kind: 'search'; engine: SearchEngine; query: string }
| { kind: 'new-file'; relativePath: string }
| { kind: 'absolute-file'; filePath: string }
+ | { kind: 'html-file-url'; filePath: string }
| { kind: 'blocked'; message: string }
export type TabEntryActionClassification = Exclude<
@@ -58,6 +60,7 @@ function tabEntryActionOptionId(classification: TabEntryActionClassification): s
case 'new-file':
return `${classification.kind}:${classification.relativePath}`
case 'absolute-file':
+ case 'html-file-url':
return `${classification.kind}:${classification.filePath}`
case 'explicit-url':
case 'host-url':
@@ -162,6 +165,11 @@ export function getTabEntryOptions(
return [emptyOption()]
}
+ const htmlFilePath = htmlFilePathFromUrl(trimmed)
+ if (htmlFilePath) {
+ return toOptions([{ kind: 'html-file-url', filePath: htmlFilePath }], limit)
+ }
+
if (isTabEntryAbsolutePathLike(trimmed)) {
if (!context.allowAbsolutePaths) {
return [
diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-history-placement.ts b/src/renderer/src/components/tab-bar/tab-create-entry-history-placement.ts
index 2944d533509..a79b5a31e70 100644
--- a/src/renderer/src/components/tab-bar/tab-create-entry-history-placement.ts
+++ b/src/renderer/src/components/tab-bar/tab-create-entry-history-placement.ts
@@ -20,7 +20,7 @@ function isTypedUrlOption(option: TabEntryOption): boolean {
// remembered page even when the match was fuzzy.
function isFileOption(option: TabEntryOption): boolean {
const { kind } = option.classification
- return kind === 'existing-file' || kind === 'absolute-file'
+ return kind === 'existing-file' || kind === 'absolute-file' || kind === 'html-file-url'
}
function historyInsertIndex(
diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-preview.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-preview.test.ts
new file mode 100644
index 00000000000..f1c443e2647
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/tab-create-entry-preview.test.ts
@@ -0,0 +1,37 @@
+import { expect, it, vi } from 'vitest'
+import { openTabBarEntry } from './tab-create-entry-action'
+
+const preview = vi.hoisted(() => vi.fn())
+vi.mock('@/lib/open-html-file-preview', () => ({ openHtmlFilePreview: preview }))
+vi.mock('@/lib/editor-file-operation-owner', () => ({
+ getEditorFileOperationContext: () => {
+ throw new Error('A preview must not require file mutation authorization')
+ }
+}))
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => ({ getKnownWorktreeById: () => ({ path: '/repo' }) })
+ }
+}))
+
+it.each([true, false])(
+ 'opens an HTML preview without requiring file mutations (selected: %s)',
+ async (selected) => {
+ preview.mockClear()
+ await openTabBarEntry({
+ ...(selected
+ ? { classification: { kind: 'html-file-url' as const, filePath: '/repo/report.html' } }
+ : {}),
+ query: 'file:///repo/report.html',
+ worktreeId: 'remote',
+ groupId: 'group-2',
+ fileList: { files: [], loading: true, loadError: null }
+ })
+ expect(preview).toHaveBeenCalledTimes(1)
+ expect(preview).toHaveBeenCalledWith({
+ filePath: '/repo/report.html',
+ worktreeId: 'remote',
+ targetGroupId: 'group-2'
+ })
+ }
+)
diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts
index fafec2c32f5..4da1680f725 100644
--- a/src/renderer/src/lib/file-preview.test.ts
+++ b/src/renderer/src/lib/file-preview.test.ts
@@ -109,7 +109,49 @@ function docPreviewCall(filePath: string, extra: Record = {}):
]
}
+function mockOpenDocument(filePath: string): void {
+ const docLocation = { kind: 'workspace-doc' as const, worktreeId: 'wt-1', filePath }
+ mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-9', docLocation }] }
+ mocks.browserPagesByWorkspace = {
+ 'browser-9': [{ id: 'page-doc', worktreeId: 'wt-1', docLocation }]
+ }
+}
+
describe('openFileInBrowserTab', () => {
+ it('reuses a document on an inactive page instead of creating a duplicate preview', () => {
+ mocks.connectionId = 'ssh-1'
+ mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-9', docLocation: null }] }
+ mocks.browserPagesByWorkspace = {
+ 'browser-9': [
+ { id: 'page-web', worktreeId: 'wt-1' },
+ {
+ id: 'page-doc',
+ worktreeId: 'wt-1',
+ docLocation: {
+ kind: 'workspace-doc',
+ worktreeId: 'wt-1',
+ filePath: '/home/alice/report.html'
+ }
+ }
+ ]
+ }
+
+ openFileInBrowserTab({ filePath: '/home/alice/report.html', worktreeId: 'wt-1' })
+
+ expect(mocks.setActiveBrowserPage).toHaveBeenCalledWith('browser-9', 'page-doc')
+ expect(mocks.createBrowserTab).not.toHaveBeenCalled()
+ })
+ it('keeps a remote document preview in the requested group', () => {
+ mocks.environmentId = 'env-1'
+ openFileInBrowserTab({
+ filePath: '/srv/repo/docs/report.html',
+ worktreeId: 'wt-1',
+ targetGroupId: 'group-2'
+ })
+ expect(mocks.createBrowserTab).toHaveBeenCalledWith(
+ ...docPreviewCall('/srv/repo/docs/report.html', { activate: true, targetGroupId: 'group-2' })
+ )
+ })
it('opens a local file URL in the Orca browser with the filename as title', () => {
openFileInBrowserTab({
filePath: '/tmp/example file.html',
@@ -141,18 +183,7 @@ describe('openFileInBrowserTab', () => {
// same file, and the reader asked to look at the document, not to open a second copy.
it('activates the tab a document is already open in', () => {
mocks.connectionId = 'ssh-1'
- mocks.browserTabsByWorktree = {
- 'wt-1': [
- {
- id: 'browser-9',
- docLocation: {
- kind: 'workspace-doc',
- worktreeId: 'wt-1',
- filePath: '/home/alice/report.html'
- }
- }
- ]
- }
+ mockOpenDocument('/home/alice/report.html')
openFileInBrowserTab({ filePath: '/home/alice/report.html', worktreeId: 'wt-1' })
@@ -165,18 +196,7 @@ describe('openFileInBrowserTab', () => {
// with the preview "active" behind it.
it('brings an already-open document to the front of its group', () => {
mocks.connectionId = 'ssh-1'
- mocks.browserTabsByWorktree = {
- 'wt-1': [
- {
- id: 'browser-9',
- docLocation: {
- kind: 'workspace-doc',
- worktreeId: 'wt-1',
- filePath: '/home/alice/report.html'
- }
- }
- ]
- }
+ mockOpenDocument('/home/alice/report.html')
mocks.unifiedTabsByWorktree = {
'wt-1': [
{
@@ -208,18 +228,7 @@ describe('openFileInBrowserTab', () => {
// reusing one must not move the reader off what they are working in.
it('leaves the reader in place when a side preview reuses an open document', () => {
mocks.connectionId = 'ssh-1'
- mocks.browserTabsByWorktree = {
- 'wt-1': [
- {
- id: 'browser-9',
- docLocation: {
- kind: 'workspace-doc',
- worktreeId: 'wt-1',
- filePath: '/home/alice/report.html'
- }
- }
- ]
- }
+ mockOpenDocument('/home/alice/report.html')
mocks.unifiedTabsByWorktree = {
'wt-1': [{ id: 'tab-doc', contentType: 'browser', entityId: 'browser-9', groupId: 'group-1' }]
}
@@ -238,18 +247,7 @@ describe('openFileInBrowserTab', () => {
// Why: the split is part of the tab create, so reusing an open document must leave no empty group.
it('creates no split when a side preview reuses an open document', () => {
mocks.connectionId = 'ssh-1'
- mocks.browserTabsByWorktree = {
- 'wt-1': [
- {
- id: 'browser-9',
- docLocation: {
- kind: 'workspace-doc',
- worktreeId: 'wt-1',
- filePath: '/home/alice/report.html'
- }
- }
- ]
- }
+ mockOpenDocument('/home/alice/report.html')
openFilePreviewToSide({
language: 'html',
@@ -265,14 +263,7 @@ describe('openFileInBrowserTab', () => {
it('opens a second document in its own tab', () => {
mocks.connectionId = 'ssh-1'
- mocks.browserTabsByWorktree = {
- 'wt-1': [
- {
- id: 'browser-9',
- docLocation: { kind: 'workspace-doc', worktreeId: 'wt-1', filePath: '/home/alice/a.html' }
- }
- ]
- }
+ mockOpenDocument('/home/alice/a.html')
openFileInBrowserTab({ filePath: '/home/alice/b.html', worktreeId: 'wt-1' })
diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts
index e9d43fd9125..b6f7e0b1213 100644
--- a/src/renderer/src/lib/file-preview.ts
+++ b/src/renderer/src/lib/file-preview.ts
@@ -184,15 +184,17 @@ function openDocPreviewTab(
}
// Why reuse and not a second tab: previewing a document already on screen is a request to look at
// it, and two tabs of one document would each hold their own grant on the same file.
- const existing = (state.browserTabsByWorktree[params.worktreeId] ?? []).find((tab) =>
- browserPageDocLocationsEqual(tab.docLocation ?? null, docLocation)
- )
+ const existing = findWorkspaceShowingDoc(state, docLocation)
if (existing) {
if (
!params.activate ||
- !activateBrowserWorkspaceTab({ worktreeId: params.worktreeId, workspaceId: existing.id })
+ !activateBrowserWorkspaceTab({
+ worktreeId: params.worktreeId,
+ ...existing
+ })
) {
- state.setActiveBrowserTab(existing.id)
+ state.setActiveBrowserTab(existing.workspaceId)
+ state.setActiveBrowserPage(existing.workspaceId, existing.pageId)
}
return
}
@@ -212,6 +214,7 @@ function openDocPreviewTab(
export function openFileInBrowserTab(params: {
filePath: string
worktreeId: string
+ targetGroupId?: string
}): WorkspaceFilePreviewPlan {
const state = useAppStore.getState()
const plan = getWorkspaceFilePreviewPlan(state, params.worktreeId, params.filePath)
@@ -219,12 +222,13 @@ export function openFileInBrowserTab(params: {
return plan
}
if (plan.status === 'doc-preview') {
- openDocPreviewTab(state, { ...params, activate: true })
+ openDocPreviewTab(state, { ...params, activate: true }, { targetGroupId: params.targetGroupId })
return plan
}
state.createBrowserTab(params.worktreeId, plan.url, {
title: plan.title,
+ ...(params.targetGroupId ? { targetGroupId: params.targetGroupId } : {}),
activate: true
})
return plan
diff --git a/src/renderer/src/lib/open-html-file-preview.test.ts b/src/renderer/src/lib/open-html-file-preview.test.ts
new file mode 100644
index 00000000000..d9eed126ebe
--- /dev/null
+++ b/src/renderer/src/lib/open-html-file-preview.test.ts
@@ -0,0 +1,53 @@
+import { beforeEach, expect, it, vi } from 'vitest'
+import { openHtmlFilePreview } from './open-html-file-preview'
+
+const mocks = vi.hoisted(() => ({
+ resolve: vi.fn(),
+ open: vi.fn(),
+ select: vi.fn(),
+ state: { activeWorktreeId: 'current' }
+}))
+vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
+vi.mock('./workspace-doc-address-input', () => ({
+ resolveWorkspaceDocAddressTarget: mocks.resolve
+}))
+vi.mock('./file-preview', () => ({ openFileInBrowserTab: mocks.open }))
+vi.mock('./workspace-surface-reveal', () => ({ selectWorkspaceSurface: mocks.select }))
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.resolve.mockReturnValue({ status: 'not-a-workspace-doc' })
+ mocks.open.mockReturnValue({ status: 'browser-tab' })
+})
+
+const request = {
+ filePath: '/repo/report one.html',
+ worktreeId: 'current',
+ targetGroupId: 'group-2'
+}
+
+it('keeps a local HTML preview in the requested group', () => {
+ openHtmlFilePreview(request)
+ expect(mocks.resolve).toHaveBeenCalledWith(mocks.state, 'current', request.filePath)
+ expect(mocks.open).toHaveBeenCalledWith(request)
+ expect(mocks.select).not.toHaveBeenCalled()
+})
+
+it('opens a sibling remote document in its owning workspace', () => {
+ mocks.resolve.mockReturnValue({ status: 'workspace-doc', docLocation: { worktreeId: 'sibling' } })
+ mocks.open.mockReturnValue({ status: 'doc-preview' })
+ openHtmlFilePreview(request)
+ expect(mocks.select).toHaveBeenCalledWith(mocks.state, 'sibling')
+ expect(mocks.open).toHaveBeenCalledWith({ filePath: request.filePath, worktreeId: 'sibling' })
+})
+
+it('reports a refused host without creating a preview', () => {
+ mocks.resolve.mockReturnValue({ status: 'unsupported', message: 'outside workspace' })
+ expect(() => openHtmlFilePreview(request)).toThrow('outside workspace')
+ expect(mocks.open).not.toHaveBeenCalled()
+})
+
+it('reports a preview plan refusal', () => {
+ mocks.open.mockReturnValue({ status: 'unsupported', message: 'unknown host' })
+ expect(() => openHtmlFilePreview(request)).toThrow('unknown host')
+})
diff --git a/src/renderer/src/lib/open-html-file-preview.ts b/src/renderer/src/lib/open-html-file-preview.ts
new file mode 100644
index 00000000000..3715e919313
--- /dev/null
+++ b/src/renderer/src/lib/open-html-file-preview.ts
@@ -0,0 +1,29 @@
+import { openFileInBrowserTab } from './file-preview'
+import { resolveWorkspaceDocAddressTarget } from './workspace-doc-address-input'
+import { selectWorkspaceSurface } from './workspace-surface-reveal'
+import { useAppStore } from '@/store'
+
+export function openHtmlFilePreview(params: {
+ filePath: string
+ worktreeId: string
+ targetGroupId: string
+}): void {
+ const state = useAppStore.getState()
+ const target = resolveWorkspaceDocAddressTarget(state, params.worktreeId, params.filePath)
+ if (target.status === 'unsupported') {
+ throw new Error(target.message)
+ }
+ const worktreeId =
+ target.status === 'workspace-doc' ? target.docLocation.worktreeId : params.worktreeId
+ if (state.activeWorktreeId !== worktreeId) {
+ selectWorkspaceSurface(state, worktreeId)
+ }
+ const plan = openFileInBrowserTab({
+ filePath: params.filePath,
+ worktreeId,
+ ...(worktreeId === params.worktreeId ? { targetGroupId: params.targetGroupId } : {})
+ })
+ if (plan.status === 'unsupported') {
+ throw new Error(plan.message)
+ }
+}
diff --git a/src/renderer/src/lib/workspace-doc-address-input.test.ts b/src/renderer/src/lib/workspace-doc-address-input.test.ts
index 9b8fd5fc341..73cb97425bd 100644
--- a/src/renderer/src/lib/workspace-doc-address-input.test.ts
+++ b/src/renderer/src/lib/workspace-doc-address-input.test.ts
@@ -9,6 +9,22 @@ const plan = vi.hoisted(() => ({
| { status: 'unsupported'; message: string; reason: 'no-channel' },
calls: [] as { worktreeId: string; filePath: string }[]
}))
+const hosts = vi.hoisted(() => new Map())
+const places = vi.hoisted(() => new Map())
+vi.mock('./resolve-owner', () => ({
+ resolveOwner: (_state: unknown, ref: { workspaceId: string }) => {
+ const environmentId = hosts.has(ref.workspaceId) ? hosts.get(ref.workspaceId) : 'server-1'
+ return environmentId
+ ? {
+ kind: 'resolved',
+ owner: {
+ endpoint: { kind: 'environment', environmentId },
+ at: places.get(ref.workspaceId) ?? 'local'
+ }
+ }
+ : { kind: 'ambiguous' }
+ }
+}))
vi.mock('@/lib/file-preview', () => ({
getWorkspaceFilePreviewPlan: (_state: unknown, worktreeId: string, filePath: string) => {
plan.calls.push({ worktreeId, filePath })
@@ -38,9 +54,68 @@ function makeState(): AppState {
beforeEach(() => {
plan.result = { status: 'doc-preview' }
plan.calls.length = 0
+ hosts.clear()
+ places.clear()
})
describe('resolveWorkspaceDocAddressTarget', () => {
+ it('resolves a file URL inside a sibling folder workspace on the same host', () => {
+ expect(
+ resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'file:///srv/site/report.html')
+ ).toMatchObject({
+ status: 'workspace-doc',
+ docLocation: {
+ worktreeId: 'folder:folder-1',
+ filePath: '/srv/site/report.html'
+ }
+ })
+ })
+
+ it('does not cross SSH targets behind the same paired server', () => {
+ places.set(CURRENT, 'ssh:first')
+ places.set(OTHER, 'ssh:second')
+ resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'file:///home/alice/wt2/report.html')
+ expect(plan.calls).toEqual([{ worktreeId: CURRENT, filePath: '/home/alice/wt2/report.html' }])
+ })
+ it('routes a pasted remote file URL through its workspace preview', () => {
+ expect(
+ resolveWorkspaceDocAddressTarget(
+ makeState(),
+ CURRENT,
+ ' file:///home/alice/wt1/docs/report%20one.html '
+ )
+ ).toMatchObject({
+ status: 'workspace-doc',
+ docLocation: {
+ worktreeId: CURRENT,
+ filePath: '/home/alice/wt1/docs/report one.html'
+ }
+ })
+ })
+
+ it('does not choose a matching root on another host', () => {
+ hosts.set(OTHER, 'server-2')
+ plan.result = { status: 'unsupported', message: 'outside workspace', reason: 'no-channel' }
+ expect(
+ resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'file:///home/alice/wt2/report.html')
+ ).toEqual({ status: 'unsupported', message: 'outside workspace' })
+ expect(plan.calls).toEqual([{ worktreeId: CURRENT, filePath: '/home/alice/wt2/report.html' }])
+ })
+
+ it('skips ambiguous sibling hosts and uses the current host boundary', () => {
+ hosts.set(OTHER, null)
+ resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'file:///home/alice/wt2/report.html')
+ expect(plan.calls).toEqual([{ worktreeId: CURRENT, filePath: '/home/alice/wt2/report.html' }])
+ })
+
+ it('refuses a file URL when its current workspace host is ambiguous', () => {
+ hosts.set(CURRENT, null)
+ expect(
+ resolveWorkspaceDocAddressTarget(makeState(), CURRENT, 'file:///home/alice/wt1/report.html')
+ ).toMatchObject({ status: 'unsupported' })
+ expect(plan.calls).toEqual([])
+ })
+
it('resolves an absolute path inside the current worktree to its document', () => {
const target = resolveWorkspaceDocAddressTarget(
makeState(),
@@ -98,9 +173,10 @@ describe('resolveWorkspaceDocAddressTarget', () => {
})
})
- it('leaves a path outside every known workspace to the URL pipeline', () => {
+ it('keeps a path outside every known workspace on the current host', () => {
expect(resolveWorkspaceDocAddressTarget(makeState(), CURRENT, '/etc/motd.html')).toEqual({
- status: 'not-a-workspace-doc'
+ status: 'workspace-doc',
+ docLocation: { kind: 'workspace-doc', worktreeId: CURRENT, filePath: '/etc/motd.html' }
})
})
diff --git a/src/renderer/src/lib/workspace-doc-address-input.ts b/src/renderer/src/lib/workspace-doc-address-input.ts
index 97be81a15b7..2fa7c5750ff 100644
--- a/src/renderer/src/lib/workspace-doc-address-input.ts
+++ b/src/renderer/src/lib/workspace-doc-address-input.ts
@@ -4,6 +4,15 @@ import type { BrowserPageDocLocation } from '../../../shared/browser-workspace-t
import { getRelativePathInsideRoot, joinPath } from '@/lib/path'
import { getWorkspaceFilePreviewPlan } from '@/lib/file-preview'
import type { AppState } from '@/store/types'
+import { htmlFilePathFromUrl } from '../../../shared/html-file-url'
+import { authorityKey } from '../../../shared/host-authority'
+import { resolveOwner } from './resolve-owner'
+import { translate } from '@/i18n/i18n'
+
+function workspaceHostKey(state: AppState, workspaceId: string): string | null {
+ const match = resolveOwner(state, { workspaceId })
+ return match.kind === 'resolved' ? authorityKey(match.owner) : null
+}
export type WorkspaceDocAddressTarget =
| { status: 'workspace-doc'; docLocation: BrowserPageDocLocation }
@@ -16,17 +25,26 @@ const PREVIEWABLE_PATH_PATTERN = /\.html?$/i
function ownedWorktreeRoots(
state: AppState,
- currentWorktreeId: string
+ currentWorktreeId: string,
+ hostKey: string
): { worktreeId: string; root: string }[] {
const roots: { worktreeId: string; root: string }[] = []
for (const worktree of state.allWorktrees()) {
- if (worktree.id !== currentWorktreeId && worktree.path) {
+ if (
+ worktree.id !== currentWorktreeId &&
+ worktree.path &&
+ workspaceHostKey(state, worktree.id) === hostKey
+ ) {
roots.push({ worktreeId: worktree.id, root: worktree.path })
}
}
for (const folder of state.folderWorkspaces) {
const worktreeId = folderWorkspaceKey(folder.id)
- if (worktreeId !== currentWorktreeId && folder.folderPath) {
+ if (
+ worktreeId !== currentWorktreeId &&
+ folder.folderPath &&
+ workspaceHostKey(state, worktreeId) === hostKey
+ ) {
roots.push({ worktreeId, root: folder.folderPath })
}
}
@@ -68,11 +86,25 @@ export function resolveWorkspaceDocAddressTarget(
currentWorktreeId: string,
rawInput: string
): WorkspaceDocAddressTarget {
- const input = rawInput.trim()
+ const raw = rawInput.trim()
+ const input = htmlFilePathFromUrl(raw) ?? raw
if (!PREVIEWABLE_PATH_PATTERN.test(input)) {
return { status: 'not-a-workspace-doc' }
}
-
+ if (!isAbsoluteFilesystemPathInput(input) && !input.startsWith('./')) {
+ return { status: 'not-a-workspace-doc' }
+ }
+ const hostKey = workspaceHostKey(state, currentWorktreeId)
+ if (hostKey === null) {
+ return {
+ status: 'unsupported',
+ message: translate(
+ 'auto.components.editor.HtmlDocPreview.openExternallyUnknownHostError',
+ "Can't open '{{value0}}': the host that owns it is no longer known.",
+ { value0: input }
+ )
+ }
+ }
if (isAbsoluteFilesystemPathInput(input)) {
// Same refusal as the relative branch: the containment check is lexical, so an absolute path
// routed through dot segments could claim a workspace it then escapes.
@@ -83,12 +115,13 @@ export function resolveWorkspaceDocAddressTarget(
if (currentPath && getRelativePathInsideRoot(input, currentPath)) {
return planToTarget(state, currentWorktreeId, input)
}
- for (const { worktreeId, root } of ownedWorktreeRoots(state, currentWorktreeId)) {
+ for (const { worktreeId, root } of ownedWorktreeRoots(state, currentWorktreeId, hostKey)) {
if (getRelativePathInsideRoot(input, root)) {
return planToTarget(state, worktreeId, input)
}
}
- return { status: 'not-a-workspace-doc' }
+ // Paths outside known roots still belong to this host; its preview plan checks the boundary.
+ return planToTarget(state, currentWorktreeId, input)
}
if (input.startsWith('./')) {
diff --git a/src/shared/html-file-url.test.ts b/src/shared/html-file-url.test.ts
new file mode 100644
index 00000000000..21cee4d1a26
--- /dev/null
+++ b/src/shared/html-file-url.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { htmlFilePathFromUrl } from './html-file-url'
+
+describe('htmlFilePathFromUrl', () => {
+ it.each([
+ ['file:///Users/me/report.html', '/Users/me/report.html'],
+ ['file://localhost/home/me/report.htm', '/home/me/report.htm'],
+ ['FILE:///C:/My%20Reports/%E4%B8%AD.HTM', 'C:/My Reports/中.HTM'],
+ ['file:///home/me/percent%2520.html', '/home/me/percent%20.html']
+ ])('decodes %s', (input, expected) => {
+ expect(htmlFilePathFromUrl(input)).toBe(expected)
+ })
+
+ it.each([
+ 'file://build-server/home/me/report.html',
+ 'file:////build-server/share/report.html',
+ 'file:///tmp%2freport.html',
+ 'file:///tmp/%5creport.html',
+ 'file:///tmp/re\nport.html',
+ 'file:///tmp/%E0%A4%A.html',
+ 'file:///tmp/%00report.html',
+ 'file:///tmp/report%0a.html',
+ 'file:///tmp/report.html?secret=1',
+ 'file:///tmp/report.html#section',
+ 'file:///tmp/report.html?',
+ 'file:///tmp/report.html#',
+ 'file:///tmp/source.ts',
+ 'file:report.html',
+ 'https://example.com/report.html',
+ './report.html'
+ ])('refuses unsupported or malformed input %s', (input) => {
+ expect(htmlFilePathFromUrl(input)).toBeNull()
+ })
+})
diff --git a/src/shared/html-file-url.ts b/src/shared/html-file-url.ts
new file mode 100644
index 00000000000..21a5a597cae
--- /dev/null
+++ b/src/shared/html-file-url.ts
@@ -0,0 +1,18 @@
+import { fileUriToFilesystemPath } from './file-uri-path'
+
+/** File authorities name this machine, never an Orca SSH target. */
+export function htmlFilePathFromUrl(input: string): string | null {
+ if (!/^file:\/\//i.test(input) || /[?#\p{Cc}]|%2f|%5c/iu.test(input)) {
+ return null
+ }
+ try {
+ const url = new URL(input)
+ if (url.hostname || url.pathname.startsWith('//')) {
+ return null
+ }
+ const filePath = fileUriToFilesystemPath(url)
+ return filePath && /\.html?$/i.test(filePath) && !/\p{Cc}/u.test(filePath) ? filePath : null
+ } catch {
+ return null
+ }
+}
diff --git a/tests/e2e/html-file-url-opening.spec.ts b/tests/e2e/html-file-url-opening.spec.ts
new file mode 100644
index 00000000000..321864f8af7
--- /dev/null
+++ b/tests/e2e/html-file-url-opening.spec.ts
@@ -0,0 +1,194 @@
+import { mkdirSync, writeFileSync } from 'node:fs'
+import path from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { expect, test } from './helpers/orca-app'
+import {
+ createRuntimeDesktopPairingOffer,
+ launchPairedElectronClient
+} from './helpers/paired-electron-client'
+import { readDocPreviewRenderedText } from './helpers/paired-html-preview-inventory'
+import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
+
+test('opens a local HTML file URL with encoded characters from the new-tab menu', async ({
+ electronApp,
+ orcaPage,
+ testRepoPath
+}, testInfo) => {
+ const filePath = path.join(testRepoPath, 'report 中%20.html')
+ writeFileSync(filePath, 'Local file URLLocal HTML rendered
')
+ const url = pathToFileURL(filePath).href
+ await waitForSessionReady(orcaPage)
+ const worktreeId = await waitForActiveWorktree(orcaPage)
+ await orcaPage.getByRole('button', { name: 'New tab', exact: true }).first().click()
+ const input = orcaPage.getByRole('combobox', {
+ name: 'Search open tabs, history, files, URLs, agents…',
+ exact: true
+ })
+ await input.fill(url)
+ await expect(orcaPage.getByRole('option', { name: /Open file/ })).toBeVisible()
+ await input.press('Enter')
+ await expect
+ .poll(() =>
+ electronApp.evaluate(async ({ webContents }, fileUrl) => {
+ const guest = webContents
+ .getAllWebContents()
+ .find((contents) => contents.getURL() === fileUrl)
+ return guest?.executeJavaScript('document.querySelector("h1")?.textContent ?? null')
+ }, url)
+ )
+ .toBe('Local HTML rendered')
+ expect(
+ await orcaPage.evaluate(
+ ({ worktreeId, url }) =>
+ window.__store
+ ?.getState()
+ .browserTabsByWorktree[worktreeId]?.some((tab) => tab.url === url && !tab.docLocation),
+ { worktreeId, url }
+ )
+ ).toBe(true)
+ await orcaPage.screenshot({ path: testInfo.outputPath('local-html-file-url.png') })
+})
+
+test('opens a pasted HTML file URL from a paired host and reloads its assets', async ({
+ orcaPage,
+ testRepoPath
+}, testInfo) => {
+ test.setTimeout(240_000)
+ const documentDirectory = path.join(testRepoPath, 'file-url-docs')
+ mkdirSync(documentDirectory, { recursive: true })
+ const filePath = path.join(documentDirectory, 'report one.html')
+ const assetPath = path.join(documentDirectory, 'report-data.js')
+ writeFileSync(
+ filePath,
+ 'Remote file URLRemote HTML rendered
' +
+ 'pending
'
+ )
+ writeFileSync(assetPath, 'document.querySelector("#asset").textContent = "host asset version 1"')
+ await waitForSessionReady(orcaPage)
+ await waitForActiveWorktree(orcaPage)
+ const offer = await createRuntimeDesktopPairingOffer(orcaPage)
+ const client = await launchPairedElectronClient(offer, testInfo, 'HTML file URL host')
+ try {
+ await expect
+ .poll(
+ () =>
+ client.page.evaluate(
+ (root) =>
+ window.__store
+ ?.getState()
+ .allWorktrees()
+ .some((worktree) => worktree.path === root),
+ testRepoPath
+ ),
+ { timeout: 120_000 }
+ )
+ .toBe(true)
+ const worktreeId = await client.page.evaluate(
+ ({ root, environmentId }) => {
+ window.localStorage.setItem('orca.browser.markup-draw-hint-seen', 'true')
+ const state = window.__store?.getState()
+ const worktree = state?.allWorktrees().find((candidate) => candidate.path === root)
+ if (!state || !worktree) {
+ throw new Error('Paired workspace missing')
+ }
+ state.setActiveWorktree(worktree.id, `runtime:${environmentId}`)
+ state.createBrowserTab(worktree.id, 'data:text/html,', {
+ browserRuntimeEnvironmentId: null,
+ focusAddressBar: false,
+ activate: true
+ })
+ return worktree.id
+ },
+ { root: testRepoPath, environmentId: client.environmentId }
+ )
+ await client.page.getByRole('button', { name: 'New tab', exact: true }).first().click()
+ const input = client.page.getByRole('combobox', {
+ name: 'Search open tabs, history, files, URLs, agents…',
+ exact: true
+ })
+ await input.fill(pathToFileURL(filePath).href)
+ await expect(client.page.getByRole('option', { name: /Open file/ })).toBeVisible()
+ await input.press('Enter')
+ await expect
+ .poll(() => readDocPreviewRenderedText(client.page, 'h1'))
+ .toBe('Remote HTML rendered')
+ await expect
+ .poll(() => readDocPreviewRenderedText(client.page, '#asset'))
+ .toBe('host asset version 1')
+ const location = await client.page.evaluate(
+ ({ worktreeId, filePath }) =>
+ window.__store
+ ?.getState()
+ .browserTabsByWorktree[worktreeId]?.find((tab) => tab.docLocation?.filePath === filePath)
+ ?.docLocation,
+ { worktreeId, filePath }
+ )
+ expect(location).toEqual({ kind: 'workspace-doc', worktreeId, filePath })
+ writeFileSync(
+ assetPath,
+ 'document.querySelector("#asset").textContent = "host asset version 2"'
+ )
+ await client.page.getByRole('button', { name: 'Reload preview', exact: true }).click()
+ await expect
+ .poll(() => readDocPreviewRenderedText(client.page, '#asset'))
+ .toBe('host asset version 2')
+ await client.page.evaluate(
+ ({ worktreeId, filePath }) => {
+ const state = window.__store?.getState()
+ const workspace = state?.browserTabsByWorktree[worktreeId]?.find(
+ (tab) => tab.docLocation?.filePath === filePath
+ )
+ if (!state || !workspace) {
+ throw new Error('Document workspace missing')
+ }
+ state.createBrowserPage(workspace.id, 'data:text/html,', {
+ browserRuntimeEnvironmentId: null,
+ activate: true
+ })
+ },
+ { worktreeId, filePath }
+ )
+ await expect(client.page.locator('[data-orca-browser-address-bar]:visible')).toBeFocused()
+ await client.page.getByRole('button', { name: 'New tab', exact: true }).first().click()
+ await input.fill(pathToFileURL(filePath).href)
+ await expect(client.page.getByRole('option', { name: /Open file/ })).toBeVisible()
+ await input.press('Enter')
+ await expect
+ .poll(() => readDocPreviewRenderedText(client.page, '#asset'))
+ .toBe('host asset version 2')
+ await client.page.evaluate((worktreeId) => {
+ window.__store?.getState().createBrowserTab(worktreeId, 'data:text/html,', {
+ browserRuntimeEnvironmentId: null,
+ focusAddressBar: false,
+ activate: true
+ })
+ }, worktreeId)
+ const address = client.page.locator('[data-orca-browser-address-bar]:visible')
+ await address.fill(pathToFileURL(filePath).href)
+ await address.press('Enter')
+ await expect
+ .poll(() => readDocPreviewRenderedText(client.page, '#asset'))
+ .toBe('host asset version 2')
+ expect(
+ await client.page.evaluate(
+ ({ worktreeId, filePath }) =>
+ window.__store
+ ?.getState()
+ .browserTabsByWorktree[worktreeId]?.flatMap(
+ (tab) => window.__store?.getState().browserPagesByWorkspace[tab.id] ?? []
+ )
+ .filter((page) => page.docLocation?.filePath === filePath).length,
+ { worktreeId, filePath }
+ )
+ ).toBe(1)
+ await client.page.screenshot({ path: testInfo.outputPath('remote-html-file-url.png') })
+ } catch (error) {
+ await testInfo.attach('paired-client-failure', {
+ body: await client.page.screenshot(),
+ contentType: 'image/png'
+ })
+ throw error
+ } finally {
+ await client.dispose()
+ }
+})