From 4d65c7617d5334c3d64684b5c36e91afa1758170 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sun, 18 Oct 2026 20:57:50 -0700
Subject: [PATCH] Open HTML file URLs as document previews (#30512)
* Open pasted HTML file:// URLs as document previews
Pasting a file:// URL to an HTML file into the new-tab box used to fall through to the generic URL path, which does not show the document's relative assets. It now routes to the workspace document preview, so the file renders with its local scripts and styles.
- Classify HTML file URLs before the file list loads, so the "Open file" option appears immediately.
- Resolve file URLs and absolute paths only against workspaces on the same host, so an SSH target is never matched to a sibling on another host.
- Reuse an open document on any page of its workspace instead of opening a duplicate preview.
- Keep the preview in the group the user targeted, including for remote hosts.
* Open HTML file:// previews without resolving file-operation context
Opening an HTML file:// URL used to need the worktree and file-operation context before the preview could open. In remote or not-yet-loaded states that lookup can fail, so the preview never appeared. Pass the context as a lazy getter, so it is read only when a file operation actually needs it. The preview path now runs without it, and the dispatch in openTabBarEntry no longer special-cases HTML before the network and file paths.
---
.../tab-bar/TabBarCreateEntryRow.tsx | 12 +-
.../tab-bar/tab-create-entry-action.test.ts | 68 +++---
.../tab-bar/tab-create-entry-action.ts | 51 +++--
.../tab-create-entry-classifier.test.ts | 20 ++
.../tab-bar/tab-create-entry-classifier.ts | 8 +
.../tab-create-entry-history-placement.ts | 2 +-
.../tab-bar/tab-create-entry-preview.test.ts | 37 ++++
src/renderer/src/lib/file-preview.test.ts | 103 +++++-----
src/renderer/src/lib/file-preview.ts | 16 +-
.../src/lib/open-html-file-preview.test.ts | 53 +++++
.../src/lib/open-html-file-preview.ts | 29 +++
.../lib/workspace-doc-address-input.test.ts | 80 +++++++-
.../src/lib/workspace-doc-address-input.ts | 47 ++++-
src/shared/html-file-url.test.ts | 34 +++
src/shared/html-file-url.ts | 18 ++
tests/e2e/html-file-url-opening.spec.ts | 194 ++++++++++++++++++
16 files changed, 649 insertions(+), 123 deletions(-)
create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-preview.test.ts
create mode 100644 src/renderer/src/lib/open-html-file-preview.test.ts
create mode 100644 src/renderer/src/lib/open-html-file-preview.ts
create mode 100644 src/shared/html-file-url.test.ts
create mode 100644 src/shared/html-file-url.ts
create mode 100644 tests/e2e/html-file-url-opening.spec.ts
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()
+ }
+})