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.
This commit is contained in:
Jinjing
2026-10-18 20:57:50 -07:00
committed by GitHub
parent 6328a104bd
commit 4d65c7617d
16 changed files with 649 additions and 123 deletions
@@ -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: <FileText className="size-3.5 shrink-0" aria-hidden="true" />,
label: translate('auto.components.tab.bar.TabBarCreateEntry.25dc1cd653', 'Open file'),
prioritizeFilename: true,
@@ -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 () => {
@@ -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<OpenFile, 'id' | 'isDirty'>,
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<void> {
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<void> {
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,
@@ -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', () => {
@@ -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 [
@@ -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(
@@ -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'
})
}
)
+47 -56
View File
@@ -109,7 +109,49 @@ function docPreviewCall(filePath: string, extra: Record<string, unknown> = {}):
]
}
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' })
+10 -6
View File
@@ -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
@@ -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')
})
@@ -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)
}
}
@@ -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<string, string | null>())
const places = vi.hoisted(() => new Map<string, string>())
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' }
})
})
@@ -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('./')) {
+34
View File
@@ -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()
})
})
+18
View File
@@ -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
}
}
+194
View File
@@ -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, '<title>Local file URL</title><h1>Local HTML rendered</h1>')
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,
'<title>Remote file URL</title><h1>Remote HTML rendered</h1>' +
'<p id="asset">pending</p><script src="./report-data.js"></script>'
)
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()
}
})