mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Preview HTML files by default (#31575)
* Preview HTML files by default across file-opening flows * Centralize workspace file opening and HTML preview routing - Preserve source, diff, and host-safety behavior across all entry points - Reuse previews within the requested editor group and support branch HTML files * Respect working tree state when opening HTML previews * Route HTML links through owning workspaces and editors * Preserve HTML preview state and refresh changed local files * Resolve HTML preview hosts from workspace ownership * Update file-opening tests for HTML preview routing
This commit is contained in:
@@ -37,6 +37,8 @@ vi.mock('@/hooks/useModalReturnFocus', () => ({
|
||||
useModalReturnFocus: () => ({ captureReturnFocus: vi.fn(), skipReturnFocus: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('./quick-open-file-navigation', () => ({ openQuickOpenFile: vi.fn() }))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
@@ -220,13 +220,24 @@ export function BrowserPageToolbar({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
viewSource={{
|
||||
onSelect: () => void window.api.browser.openDevTools({ browserPageId }),
|
||||
label: translate(
|
||||
'auto.components.browser.pane.BrowserPane.ec75d0c412',
|
||||
'Open browser devtools'
|
||||
)
|
||||
}}
|
||||
viewSource={
|
||||
openSourceFile
|
||||
? {
|
||||
onSelect: openSourceFile,
|
||||
label: translate(
|
||||
'auto.components.editor.HtmlDocPreview.openSourceControl',
|
||||
'Open source file'
|
||||
),
|
||||
alreadyInOverflowMenu: true
|
||||
}
|
||||
: {
|
||||
onSelect: () => void window.api.browser.openDevTools({ browserPageId }),
|
||||
label: translate(
|
||||
'auto.components.browser.pane.BrowserPane.ec75d0c412',
|
||||
'Open browser devtools'
|
||||
)
|
||||
}
|
||||
}
|
||||
openExternal={{
|
||||
onSelect: () => {
|
||||
if (!externalUrl) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
replacePersistentWebview,
|
||||
webviewRegistry
|
||||
} from './webview-registry'
|
||||
import { recordLocalHtmlPreviewNavigation } from '../navigate/local-html-preview-freshness'
|
||||
|
||||
export function setBrowserPageWebviewInputLock(
|
||||
webview: Electron.WebviewTag,
|
||||
@@ -70,6 +71,9 @@ export function ensureBrowserPageWebview({
|
||||
setBrowserPageWebviewInputLock(webview, inputLocked)
|
||||
webview.style.background = 'var(--background)'
|
||||
const guest = webview
|
||||
guest.addEventListener('did-start-navigation', (event) => {
|
||||
recordLocalHtmlPreviewNavigation(guest, event)
|
||||
})
|
||||
// A committed synthetic blank document belongs to New Tab, including while its first URL waits.
|
||||
guest.addEventListener('load-commit', (event) => {
|
||||
if (event.isMainFrame) {
|
||||
|
||||
@@ -7,6 +7,9 @@ vi.mock('@/lib/connection-owner-resolution', async (importOriginal) => ({
|
||||
getConnectionIdFromState: () => null,
|
||||
getConnectionIdForFileFromState: () => null
|
||||
}))
|
||||
vi.mock('@/lib/user-opened-local-path', () => ({
|
||||
statUserOpenedPath: vi.fn().mockResolvedValue({ isDirectory: false, escapesWorktree: false })
|
||||
}))
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import { editorTabFileAccess } from '@/lib/local-file-access'
|
||||
@@ -18,13 +21,13 @@ import {
|
||||
|
||||
const PATH = '/Users/me/orca/chat-scratch/0123456789abcdef0123456789abcdef/usage.html'
|
||||
|
||||
it("opens a local chat visual's page as an editable source tab, read and saved in place", () => {
|
||||
it("opens a local chat visual's page as an editable source tab, read and saved in place", async () => {
|
||||
const openFile = vi.fn()
|
||||
useAppStore.setState({ openFile })
|
||||
expect(getShareableBrowserArtifactFile(`file://${encodeURI(PATH)}`)?.filePath).toBe(PATH)
|
||||
expect(canOpenBrowserFileInWorkspaceEditor(useAppStore.getState(), 'wt-1')).toBe(true)
|
||||
|
||||
openBrowserFileSource('wt-1', PATH)
|
||||
await openBrowserFileSource('wt-1', PATH)
|
||||
const opened = openFile.mock.calls[0][0]
|
||||
expect(opened).toMatchObject({ filePath: PATH, relativePath: PATH, mode: 'edit' })
|
||||
// What its editor reads and saves with: the user-named file itself, outside any project.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getConnectionIdFromState } from '@/lib/connection-owner-resolution'
|
||||
import { getRuntimeTargetForWorktreeOwner } from '@/lib/file-owner-runtime-target'
|
||||
import { getRelativePathInsideRoot } from '@/lib/path'
|
||||
import { statUserOpenedPath } from '@/lib/user-opened-local-path'
|
||||
import { toast } from 'sonner'
|
||||
import type { AppState } from '@/store'
|
||||
import { useAppStore } from '@/store'
|
||||
import { openDocPreviewSource } from '../workspace-doc/doc-preview-document-actions'
|
||||
@@ -18,13 +20,33 @@ export function canOpenBrowserFileInWorkspaceEditor(state: AppState, worktreeId:
|
||||
}
|
||||
|
||||
/** Opens the file behind a local browser page as its editable source, as a document preview does. */
|
||||
export function openBrowserFileSource(worktreeId: string, filePath: string): void {
|
||||
const worktreeRoot = useAppStore.getState().getKnownWorktreeById(worktreeId)?.path ?? null
|
||||
openDocPreviewSource({
|
||||
filePath,
|
||||
relativePath: getRelativePathInsideRoot(filePath, worktreeRoot) ?? filePath,
|
||||
worktreeId,
|
||||
runtimeEnvironmentId: null,
|
||||
externalSshTargetId: null
|
||||
})
|
||||
export async function openBrowserFileSource(worktreeId: string, filePath: string): Promise<void> {
|
||||
const state = useAppStore.getState()
|
||||
if (!canOpenBrowserFileInWorkspaceEditor(state, worktreeId)) {
|
||||
return
|
||||
}
|
||||
const worktreeRoot = state.getKnownWorktreeById(worktreeId)?.path ?? null
|
||||
try {
|
||||
const stat = await statUserOpenedPath(
|
||||
{ target: { kind: 'local' }, worktreeId, worktreePath: worktreeRoot },
|
||||
filePath
|
||||
)
|
||||
if (
|
||||
stat.isDirectory ||
|
||||
!canOpenBrowserFileInWorkspaceEditor(useAppStore.getState(), worktreeId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
openDocPreviewSource({
|
||||
filePath,
|
||||
relativePath: stat.escapesWorktree
|
||||
? filePath
|
||||
: (getRelativePathInsideRoot(filePath, worktreeRoot) ?? filePath),
|
||||
worktreeId,
|
||||
runtimeEnvironmentId: null,
|
||||
externalSshTargetId: null
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
recordLocalHtmlPreviewNavigation,
|
||||
refreshLocalHtmlPreview
|
||||
} from './local-html-preview-freshness'
|
||||
|
||||
const stat = vi.fn()
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
beforeEach(() => {
|
||||
stat.mockReset()
|
||||
stat.mockResolvedValue({ size: 100, mtime: 1, isDirectory: false })
|
||||
vi.stubGlobal('window', { api: { fs: { stat } } })
|
||||
})
|
||||
|
||||
function guest() {
|
||||
const reloadIgnoringCache = vi.fn()
|
||||
const webview = {
|
||||
getWebContentsId: () => 1,
|
||||
reloadIgnoringCache
|
||||
}
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these tests use only the guest liveness and reload methods.
|
||||
webview: webview as unknown as Electron.WebviewTag,
|
||||
reloadIgnoringCache
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(webview: Electron.WebviewTag, url = 'file:///repo/report.html') {
|
||||
recordLocalHtmlPreviewNavigation(webview, { isMainFrame: true, isInPlace: false, url })
|
||||
}
|
||||
|
||||
it('preserves an unchanged page across repeated file opens and in-page navigation', async () => {
|
||||
const { webview, reloadIgnoringCache } = guest()
|
||||
navigate(webview)
|
||||
recordLocalHtmlPreviewNavigation(webview, {
|
||||
isMainFrame: true,
|
||||
isInPlace: true,
|
||||
url: 'file:///repo/report.html#section'
|
||||
})
|
||||
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
|
||||
expect(reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ size: 100, mtime: 2 },
|
||||
{ size: 120, mtime: 1 }
|
||||
])('reloads changed disk content once, including regeneration: %j', async (revision) => {
|
||||
const { webview, reloadIgnoringCache } = guest()
|
||||
navigate(webview)
|
||||
stat.mockResolvedValue({ ...revision, isDirectory: false })
|
||||
|
||||
await Promise.all([
|
||||
refreshLocalHtmlPreview(webview, '/repo/report.html'),
|
||||
refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
])
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
|
||||
expect(reloadIgnoringCache).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('records the new disk revision after a manual reload', async () => {
|
||||
const { webview, reloadIgnoringCache } = guest()
|
||||
navigate(webview)
|
||||
stat.mockResolvedValue({ size: 100, mtime: 2, isDirectory: false })
|
||||
navigate(webview)
|
||||
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
|
||||
expect(reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reload another page after navigation during the disk check', async () => {
|
||||
const { webview, reloadIgnoringCache } = guest()
|
||||
navigate(webview)
|
||||
let finishStat = (_value: { size: number; mtime: number; isDirectory: boolean }): void => {
|
||||
throw new Error('Disk check did not start')
|
||||
}
|
||||
stat.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishStat = resolve
|
||||
})
|
||||
)
|
||||
const refresh = refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
navigate(webview, 'https://example.com')
|
||||
finishStat({ size: 100, mtime: 2, isDirectory: false })
|
||||
await refresh
|
||||
|
||||
expect(reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the loaded revision after a failed stat so a later successful open can refresh', async () => {
|
||||
const { webview, reloadIgnoringCache } = guest()
|
||||
navigate(webview)
|
||||
stat.mockRejectedValueOnce(new Error('Filesystem unavailable'))
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
expect(reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
|
||||
stat.mockResolvedValue({ size: 100, mtime: 2, isDirectory: false })
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
expect(reloadIgnoringCache).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('refreshes when the initial disk revision was unavailable but a later reopen can read it', async () => {
|
||||
const { webview, reloadIgnoringCache } = guest()
|
||||
stat.mockRejectedValueOnce(new Error('Filesystem unavailable'))
|
||||
navigate(webview)
|
||||
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
|
||||
expect(reloadIgnoringCache).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('refreshes a directly linked file through explicit access and retains its revision across reloads', async () => {
|
||||
const { webview, reloadIgnoringCache } = guest()
|
||||
let mtime = 1
|
||||
stat.mockImplementation(async ({ access }: { access: { kind: string } }) => {
|
||||
if (access.kind === 'document-resource') {
|
||||
throw new Error('Symlink target outside the document folder')
|
||||
}
|
||||
return { size: 100, mtime, isDirectory: false }
|
||||
})
|
||||
reloadIgnoringCache.mockImplementation(() => navigate(webview))
|
||||
navigate(webview)
|
||||
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
expect(reloadIgnoringCache).toHaveBeenCalledTimes(1)
|
||||
|
||||
mtime = 2
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
expect(reloadIgnoringCache).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains a changed revision for retry when the guest is temporarily missing', async () => {
|
||||
const { webview, reloadIgnoringCache } = guest()
|
||||
navigate(webview)
|
||||
stat.mockResolvedValue({ size: 100, mtime: 2, isDirectory: false })
|
||||
vi.spyOn(webview, 'getWebContentsId').mockImplementationOnce(() => {
|
||||
throw new Error('Guest missing')
|
||||
})
|
||||
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
expect(reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
expect(reloadIgnoringCache).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries a changed file after its guest was not ready to reload', async () => {
|
||||
const { webview, reloadIgnoringCache } = guest()
|
||||
navigate(webview)
|
||||
stat.mockResolvedValue({ size: 100, mtime: 2, isDirectory: false })
|
||||
reloadIgnoringCache.mockImplementationOnce(() => {
|
||||
throw new Error('Guest not ready')
|
||||
})
|
||||
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
|
||||
expect(reloadIgnoringCache).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('compares Windows file paths with encoded characters and fragments', async () => {
|
||||
const { webview, reloadIgnoringCache } = guest()
|
||||
navigate(webview, 'file:///C:/repo/report%20one.html#section')
|
||||
stat.mockResolvedValue({ size: 100, mtime: 2, isDirectory: false })
|
||||
|
||||
await refreshLocalHtmlPreview(webview, 'C:\\repo\\report one.html')
|
||||
|
||||
expect(reloadIgnoringCache).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not stat ordinary web pages or reload a guest without a loaded-file revision', async () => {
|
||||
const { webview, reloadIgnoringCache } = guest()
|
||||
navigate(webview, 'https://example.com/report.html')
|
||||
|
||||
await refreshLocalHtmlPreview(webview, '/repo/report.html')
|
||||
|
||||
expect(stat).not.toHaveBeenCalled()
|
||||
expect(reloadIgnoringCache).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { documentResourceAccess, userNamedFileAccess } from '@/lib/local-file-access'
|
||||
import { normalizeRuntimePathForComparison } from '../../../../../shared/cross-platform-path'
|
||||
import { isHtmlFilePath } from '../../../../../shared/html-file-url'
|
||||
import type { LocalFileAccess } from '../../../../../shared/local-file-access'
|
||||
import { browserFileUrlToAbsolutePath } from '../describe-page/browser-artifact-upload'
|
||||
import { reloadBrowserPageWebview } from './browser-reload-action'
|
||||
|
||||
type FileRevision = { size: number; mtime: number }
|
||||
type LoadedFile = { filePath: string; revision: Promise<FileRevision | null> }
|
||||
|
||||
const loadedFiles = new WeakMap<Electron.WebviewTag, LoadedFile>()
|
||||
|
||||
async function readFileRevision(
|
||||
filePath: string,
|
||||
access: LocalFileAccess = documentResourceAccess(filePath)
|
||||
): Promise<FileRevision | null> {
|
||||
try {
|
||||
const stat = await window.api.fs.stat({
|
||||
filePath,
|
||||
access
|
||||
})
|
||||
return stat.isDirectory ? null : { size: stat.size, mtime: stat.mtime }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function recordLocalHtmlPreviewNavigation(
|
||||
webview: Electron.WebviewTag,
|
||||
event: Pick<Electron.DidStartNavigationEvent, 'isMainFrame' | 'isInPlace' | 'url'>
|
||||
): void {
|
||||
if (!event.isMainFrame || event.isInPlace) {
|
||||
return
|
||||
}
|
||||
const filePath = browserFileUrlToAbsolutePath(event.url)
|
||||
if (!filePath || !isHtmlFilePath(filePath)) {
|
||||
loadedFiles.delete(webview)
|
||||
return
|
||||
}
|
||||
const previous = loadedFiles.get(webview)
|
||||
const previousRevision =
|
||||
previous &&
|
||||
normalizeRuntimePathForComparison(previous.filePath) ===
|
||||
normalizeRuntimePathForComparison(filePath)
|
||||
? previous.revision
|
||||
: null
|
||||
loadedFiles.set(webview, {
|
||||
filePath,
|
||||
revision: readFileRevision(filePath).then((revision) => revision ?? previousRevision)
|
||||
})
|
||||
}
|
||||
|
||||
/** Explicit file opens refresh changed disk contents while unchanged pages keep their UI state. */
|
||||
export async function refreshLocalHtmlPreview(
|
||||
webview: Electron.WebviewTag,
|
||||
filePath: string
|
||||
): Promise<void> {
|
||||
const loaded = loadedFiles.get(webview)
|
||||
if (
|
||||
!loaded ||
|
||||
normalizeRuntimePathForComparison(loaded.filePath) !==
|
||||
normalizeRuntimePathForComparison(filePath)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const [previous, current] = await Promise.all([
|
||||
loaded.revision,
|
||||
readFileRevision(filePath, userNamedFileAccess())
|
||||
])
|
||||
if (
|
||||
loadedFiles.get(webview) !== loaded ||
|
||||
!current ||
|
||||
(previous && previous.size === current.size && previous.mtime === current.mtime)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const refreshed = { filePath, revision: Promise.resolve(current) }
|
||||
loadedFiles.set(webview, refreshed)
|
||||
if (
|
||||
reloadBrowserPageWebview(webview, { ignoreCache: true }) !== 'reloaded' &&
|
||||
loadedFiles.get(webview) === refreshed
|
||||
) {
|
||||
loadedFiles.set(webview, loaded)
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as I18nModule from '@/i18n/i18n'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
/** What the per-file resolver answers: null local, a string remote, undefined unresolved. */
|
||||
@@ -17,7 +18,8 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
vi.mock('@/i18n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof I18nModule>()),
|
||||
translate: (_key: string, fallback: string, params?: Record<string, string>) =>
|
||||
fallback.replace('{{value0}}', params?.value0 ?? '')
|
||||
}))
|
||||
@@ -103,7 +105,8 @@ describe('openDocPreviewSource', () => {
|
||||
worktreeId: 'wt-1',
|
||||
language: 'html',
|
||||
mode: 'edit'
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
@@ -113,7 +116,8 @@ describe('openDocPreviewSource', () => {
|
||||
openDocPreviewSource({ ...REMOTE_DOCUMENT, externalSshTargetId: 'ssh-7' })
|
||||
|
||||
expect(mocks.openFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ externalSshTargetId: 'ssh-7' })
|
||||
expect.objectContaining({ externalSshTargetId: 'ssh-7' }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
+17
-10
@@ -10,6 +10,7 @@ import {
|
||||
canClientOsOpenWorkspaceFile
|
||||
} from '@/lib/workspace-file-host-routing'
|
||||
import { useAppStore } from '@/store'
|
||||
import { openWorkspaceFile } from '@/lib/workspace-file-open'
|
||||
import { downloadAndOpenRemoteTerminalFile } from '@/components/terminal-pane/terminal-remote-file-download-open'
|
||||
|
||||
export type DocPreviewDocument = {
|
||||
@@ -30,16 +31,22 @@ export type DocPreviewDocument = {
|
||||
* preview keeps its own id, so the reader can leave the rendered page where it was.
|
||||
*/
|
||||
export function openDocPreviewSource(document: DocPreviewDocument): void {
|
||||
useAppStore.getState().openFile({
|
||||
filePath: document.filePath,
|
||||
relativePath: document.relativePath,
|
||||
worktreeId: document.worktreeId,
|
||||
// Why not the preview tab's language: it is pinned to 'html' for the preview itself, and the
|
||||
// source tab needs the editor's own detection to pick a highlighter.
|
||||
language: detectLanguage(document.filePath),
|
||||
runtimeEnvironmentId: document.runtimeEnvironmentId,
|
||||
...(document.externalSshTargetId ? { externalSshTargetId: document.externalSshTargetId } : {}),
|
||||
mode: 'edit'
|
||||
openWorkspaceFile({
|
||||
kind: 'file',
|
||||
intent: 'source',
|
||||
file: {
|
||||
filePath: document.filePath,
|
||||
relativePath: document.relativePath,
|
||||
worktreeId: document.worktreeId,
|
||||
// Why not the preview tab's language: it is pinned to 'html' for the preview itself, and the
|
||||
// source tab needs the editor's own detection to pick a highlighter.
|
||||
language: detectLanguage(document.filePath),
|
||||
runtimeEnvironmentId: document.runtimeEnvironmentId,
|
||||
...(document.externalSshTargetId
|
||||
? { externalSshTargetId: document.externalSshTargetId }
|
||||
: {}),
|
||||
mode: 'edit'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ describe('dropping a project file on the tab strip', () => {
|
||||
expect.objectContaining({
|
||||
filePath: '/repo/docs-link/secret.md',
|
||||
relativePath: '/repo/docs-link/secret.md'
|
||||
})
|
||||
}),
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -70,7 +71,8 @@ describe('dropping a project file on the tab strip', () => {
|
||||
await dropOnTabStrip('/repo/docs/plain.md')
|
||||
|
||||
expect(mocks.openFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: '/repo/docs/plain.md', relativePath: 'docs/plain.md' })
|
||||
expect.objectContaining({ filePath: '/repo/docs/plain.md', relativePath: 'docs/plain.md' }),
|
||||
{}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,8 +13,8 @@ import { translate } from '@/i18n/i18n'
|
||||
import { captureWorktreeSshMutationExpectation } from '@/lib/ssh-mutation-expectation'
|
||||
import { statUserOpenedPath } from '@/lib/user-opened-local-path'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { openDocumentInFloatingWorkspace } from '@/lib/open-document-in-floating-workspace'
|
||||
import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target'
|
||||
import { openWorkspaceFile } from '@/lib/workspace-file-open'
|
||||
|
||||
export function shouldUploadRemoteEditorFileDrop(
|
||||
target: RuntimeClientTarget,
|
||||
@@ -101,6 +101,21 @@ export function editorGroupStillExists({
|
||||
return groups.some((group) => group.id === groupId)
|
||||
}
|
||||
|
||||
function canOpenEditorFileDrop(
|
||||
captured: EditorFileDropContext,
|
||||
destination: EditorFileDropDestination
|
||||
): boolean {
|
||||
if (!editorGroupStillExists(destination)) {
|
||||
return false
|
||||
}
|
||||
const current = captureEditorFileDropContext(destination.worktreeId)
|
||||
if (!current || !sameEditorFileDropOwner(captured, current)) {
|
||||
showOwnerChangedError()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the destination workspace's host at drop time and returns the open
|
||||
* step; a host change before preparation finishes refuses instead of re-routing.
|
||||
@@ -114,12 +129,7 @@ export function captureEditorFileDropOpen(
|
||||
return async () => undefined
|
||||
}
|
||||
return async (paths) => {
|
||||
if (paths.length === 0 || !editorGroupStillExists(destination)) {
|
||||
return
|
||||
}
|
||||
const current = captureEditorFileDropContext(destination.worktreeId)
|
||||
if (!current || !sameEditorFileDropOwner(captured, current)) {
|
||||
showOwnerChangedError()
|
||||
if (paths.length === 0 || !canOpenEditorFileDrop(captured, destination)) {
|
||||
return
|
||||
}
|
||||
await openEditorFileDropPaths(captured, destination, paths)
|
||||
@@ -127,11 +137,11 @@ export function captureEditorFileDropOpen(
|
||||
}
|
||||
|
||||
async function openEditorFileDropPaths(
|
||||
{ fileContext, worktreePath, connectionId }: EditorFileDropContext,
|
||||
context: EditorFileDropContext,
|
||||
destination: EditorFileDropDestination,
|
||||
paths: readonly string[]
|
||||
): Promise<void> {
|
||||
const store = useAppStore.getState()
|
||||
const { fileContext, worktreePath, connectionId } = context
|
||||
const { worktreeId, groupId } = destination
|
||||
const groupOptions = groupId ? { targetGroupId: groupId } : null
|
||||
const runtimeEnvironmentId = runtimeTargetEnvironmentId(fileContext.target)
|
||||
@@ -155,20 +165,17 @@ async function openEditorFileDropPaths(
|
||||
destinationDir,
|
||||
{ ensureDestinationDir: true, nameExtensionlessImages: true }
|
||||
)
|
||||
if (!editorGroupStillExists(destination)) {
|
||||
return
|
||||
}
|
||||
for (const result of results) {
|
||||
if (!editorGroupStillExists(destination)) {
|
||||
if (!canOpenEditorFileDrop(context, destination)) {
|
||||
return
|
||||
}
|
||||
if (result.status !== 'imported' || result.kind === 'directory') {
|
||||
continue
|
||||
}
|
||||
const maybeRelative = toWorktreeRelativePath(result.destPath, worktreePath)
|
||||
store.setActiveTabType('editor', worktreeId)
|
||||
store.openFile(
|
||||
{
|
||||
openWorkspaceFile({
|
||||
kind: 'file',
|
||||
file: {
|
||||
filePath: result.destPath,
|
||||
relativePath: maybeRelative ?? result.destPath,
|
||||
worktreeId,
|
||||
@@ -176,8 +183,8 @@ async function openEditorFileDropPaths(
|
||||
language: detectLanguage(result.destPath),
|
||||
mode: 'edit'
|
||||
},
|
||||
{ suppressActiveRuntimeFallback: runtimeEnvironmentId === null, ...groupOptions }
|
||||
)
|
||||
options: { suppressActiveRuntimeFallback: runtimeEnvironmentId === null, ...groupOptions }
|
||||
})
|
||||
}
|
||||
if (results.some((result) => result.status !== 'imported')) {
|
||||
toast.error(
|
||||
@@ -199,7 +206,7 @@ async function openEditorFileDropPaths(
|
||||
for (const filePath of paths) {
|
||||
try {
|
||||
const stat = await statUserOpenedPath(fileContext, filePath)
|
||||
if (!editorGroupStillExists(destination)) {
|
||||
if (!canOpenEditorFileDrop(context, destination)) {
|
||||
return
|
||||
}
|
||||
if (stat.isDirectory) {
|
||||
@@ -214,27 +221,22 @@ async function openEditorFileDropPaths(
|
||||
relativePath = maybeRelative
|
||||
}
|
||||
}
|
||||
store.setActiveTabType('editor', worktreeId)
|
||||
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
openDocumentInFloatingWorkspace(
|
||||
store.openFile,
|
||||
{ filePath, relativePath },
|
||||
groupOptions ?? {}
|
||||
)
|
||||
continue
|
||||
}
|
||||
const file = {
|
||||
filePath,
|
||||
relativePath,
|
||||
worktreeId,
|
||||
language: detectLanguage(filePath),
|
||||
mode: 'edit' as const
|
||||
}
|
||||
if (groupOptions) {
|
||||
store.openFile(file, groupOptions)
|
||||
} else {
|
||||
store.openFile(file)
|
||||
}
|
||||
const floating = worktreeId === FLOATING_TERMINAL_WORKTREE_ID
|
||||
openWorkspaceFile({
|
||||
kind: 'file',
|
||||
file: {
|
||||
filePath,
|
||||
relativePath,
|
||||
worktreeId,
|
||||
language: detectLanguage(filePath),
|
||||
mode: 'edit',
|
||||
...(floating ? { runtimeEnvironmentId: null } : {})
|
||||
},
|
||||
options: {
|
||||
...groupOptions,
|
||||
...(floating ? { preview: false, suppressActiveRuntimeFallback: true } : {})
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// Ignore files that cannot be stat'd.
|
||||
}
|
||||
|
||||
+90
-3
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/lib/remote-workspace-drop-test-fixtures'
|
||||
import { joinPath } from '@/lib/path'
|
||||
import { useEditorGroupFileDropOwner } from './use-editor-group-file-drop-owner'
|
||||
import { createCompatibleRuntimeStatusResponse } from '@/runtime/runtime-compatibility-test-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
importPaths: vi.fn(),
|
||||
@@ -29,8 +30,8 @@ function EditorStrip({ worktreeId, groupId }: { worktreeId: string; groupId: str
|
||||
return <div ref={attach} data-testid="strip" />
|
||||
}
|
||||
|
||||
function dropFile(target: Element): void {
|
||||
const transfer = { types: ['Files'], files: [new File(['x'], 'shot.png')], dropEffect: 'move' }
|
||||
function dropFile(target: Element, name = 'shot.png'): void {
|
||||
const transfer = { types: ['Files'], files: [new File(['x'], name)], dropEffect: 'move' }
|
||||
const event = new Event('drop', { bubbles: true, cancelable: true, composed: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: transfer })
|
||||
Object.defineProperty(event, 'isTrusted', { value: true })
|
||||
@@ -41,7 +42,20 @@ function dropFile(target: Element): void {
|
||||
|
||||
beforeEach(() => {
|
||||
seedRemoteDropWorkspaces()
|
||||
useAppStore.setState({ openFile: mocks.openFile, setActiveTabType: mocks.setActiveTabType })
|
||||
const response = createCompatibleRuntimeStatusResponse('owner-runtime')
|
||||
if (!response.ok) {
|
||||
throw new Error('Expected a compatible runtime fixture')
|
||||
}
|
||||
useAppStore.setState({
|
||||
openFile: mocks.openFile,
|
||||
setActiveTabType: mocks.setActiveTabType,
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['owner-runtime', { status: response.result, checkedAt: Date.now() }]
|
||||
])
|
||||
})
|
||||
mocks.prepare.mockImplementation(async ({ paths }: { paths: string[] }) => ({
|
||||
paths,
|
||||
failures: []
|
||||
@@ -108,6 +122,79 @@ describe('editor group OS file drops on remote workspaces', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['wt-ssh', 'wt-runtime'])(
|
||||
'previews uploaded HTML in its destination group in %s',
|
||||
async (worktreeId) => {
|
||||
const groupId = worktreeId === 'wt-ssh' ? 'group-ssh' : 'group-runtime'
|
||||
const root = worktreeId === 'wt-ssh' ? SSH_PATH : RUNTIME_PATH
|
||||
const view = render(<EditorStrip worktreeId={worktreeId} groupId={groupId} />)
|
||||
dropFile(view.getByTestId('strip'), 'report.html')
|
||||
await waitFor(() =>
|
||||
expect(useAppStore.getState().browserTabsByWorktree[worktreeId]).toHaveLength(1)
|
||||
)
|
||||
const state = useAppStore.getState()
|
||||
const workspace = state.browserTabsByWorktree[worktreeId][0]
|
||||
expect(state.browserPagesByWorkspace[workspace.id][0].docLocation).toEqual({
|
||||
kind: 'workspace-doc',
|
||||
worktreeId,
|
||||
filePath: joinPath(root, '.orca/drops/report.html')
|
||||
})
|
||||
expect(state.unifiedTabsByWorktree[worktreeId]).toEqual([
|
||||
expect.objectContaining({ contentType: 'browser', entityId: workspace.id, groupId })
|
||||
])
|
||||
expect(mocks.openFile).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('opens uploaded HTML source when the runtime cannot serve scoped previews', async () => {
|
||||
useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() })
|
||||
const view = render(<EditorStrip worktreeId="wt-runtime" groupId="group-runtime" />)
|
||||
|
||||
dropFile(view.getByTestId('strip'), 'report.html')
|
||||
|
||||
await waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.openFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: joinPath(RUNTIME_PATH, '.orca/drops/report.html'),
|
||||
worktreeId: 'wt-runtime',
|
||||
runtimeEnvironmentId: 'owner-runtime'
|
||||
}),
|
||||
{ suppressActiveRuntimeFallback: false, targetGroupId: 'group-runtime' }
|
||||
)
|
||||
expect(useAppStore.getState().browserTabsByWorktree['wt-runtime'] ?? []).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses an HTML preview when its workspace changes hosts during upload', async () => {
|
||||
let finish: (result: {
|
||||
results: { status: 'imported'; kind: 'file'; destPath: string }[]
|
||||
}) => void = () => undefined
|
||||
mocks.importPaths.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve)))
|
||||
const view = render(<EditorStrip worktreeId="wt-runtime" groupId="group-runtime" />)
|
||||
dropFile(view.getByTestId('strip'), 'report.html')
|
||||
await waitFor(() => expect(mocks.importPaths).toHaveBeenCalledTimes(1))
|
||||
useAppStore.setState((state) => ({
|
||||
repos: state.repos.map((repo) =>
|
||||
repo.id === 'repo-rt' ? { ...repo, executionHostId: 'runtime:new-owner' as const } : repo
|
||||
)
|
||||
}))
|
||||
await act(async () =>
|
||||
finish({
|
||||
results: [
|
||||
{
|
||||
status: 'imported',
|
||||
kind: 'file',
|
||||
destPath: joinPath(RUNTIME_PATH, '.orca/drops/report.html')
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
expect(useAppStore.getState().browserTabsByWorktree['wt-runtime'] ?? []).toHaveLength(0)
|
||||
expect(mocks.openFile).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Couldn't verify which host owns/)
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['wt-ssh', 'wt-runtime'])(
|
||||
'opens nothing when the group closes during upload in %s and keeps preparation feedback',
|
||||
async (worktreeId) => {
|
||||
|
||||
@@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => {
|
||||
const groupsByWorktree: Record<string, { id: string }[]> = {}
|
||||
return {
|
||||
openFile: vi.fn(),
|
||||
setActiveTabType: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
prepare: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
@@ -56,7 +55,6 @@ vi.mock('@/store', () => ({
|
||||
activeWorktreeId: 'wt-active',
|
||||
groupsByWorktree: mocks.groupsByWorktree,
|
||||
getKnownWorktreeById: (id: string) => ({ id, path: `/repos/${id}` }),
|
||||
setActiveTabType: mocks.setActiveTabType,
|
||||
openFile: mocks.openFile
|
||||
})
|
||||
}
|
||||
@@ -149,7 +147,6 @@ describe('editor group OS file drops', () => {
|
||||
}),
|
||||
{ targetGroupId: 'group-b' }
|
||||
)
|
||||
expect(mocks.setActiveTabType).toHaveBeenCalledWith('editor', 'wt-b')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -262,7 +259,6 @@ describe('editor group OS file drops', () => {
|
||||
}
|
||||
await act(async () => finish({ isDirectory: false, escapesWorktree: false }))
|
||||
expect(mocks.openFile).not.toHaveBeenCalled()
|
||||
expect(mocks.setActiveTabType).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => {
|
||||
authorize: vi.fn(),
|
||||
open: vi.fn(() => 'owner-file-id'),
|
||||
source: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
reveal: vi.fn(),
|
||||
assertCurrent: vi.fn(),
|
||||
rpc: vi.fn(),
|
||||
@@ -26,6 +27,10 @@ vi.mock('@/store', () => ({
|
||||
})
|
||||
}
|
||||
}))
|
||||
vi.mock('@/lib/file-preview', () => ({
|
||||
openFileInBrowserTab: mocks.preview,
|
||||
getWorkspaceFilePreviewPlan: () => ({ status: 'browser-tab' })
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-file-metadata-client', () => ({
|
||||
statRuntimePath: mocks.stat,
|
||||
isMissingRuntimePathError: (error: Error) => error.message.includes('ENOENT')
|
||||
@@ -52,9 +57,36 @@ beforeEach(() => {
|
||||
mocks.route.current = { target: { kind: 'local' }, connectionId: undefined }
|
||||
mocks.stat.mockResolvedValue({ isDirectory: false })
|
||||
mocks.assertCurrent.mockReset()
|
||||
mocks.preview.mockReturnValue({ status: 'unsupported' })
|
||||
vi.stubGlobal('window', { api: { fs: { authorizeExternalPath: mocks.authorize } } })
|
||||
})
|
||||
|
||||
it('previews an HTML file selected without a line destination', async () => {
|
||||
mocks.preview.mockReturnValue({ status: 'browser-tab' })
|
||||
await openQuickOpenFile('report.html', 'wt', '/repo', { pathQuery: 'report.html' })
|
||||
expect(mocks.preview).toHaveBeenCalledWith({ filePath: '/repo/report.html', worktreeId: 'wt' })
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
expect(mocks.reveal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens HTML at the requested source line even when a preview is available', async () => {
|
||||
mocks.preview.mockReturnValue({ status: 'browser-tab' })
|
||||
await openQuickOpenFile('report.html', 'wt', '/repo', {
|
||||
pathQuery: 'report.html',
|
||||
line: 12,
|
||||
column: 3
|
||||
})
|
||||
expect(mocks.preview).not.toHaveBeenCalled()
|
||||
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ language: 'html' }), undefined)
|
||||
expect(mocks.reveal).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'/repo/report.html',
|
||||
12,
|
||||
3,
|
||||
'owner-file-id'
|
||||
)
|
||||
})
|
||||
|
||||
it('validates pasted local paths with user-named access and uses the owner-qualified reveal', async () => {
|
||||
await openQuickOpenFile('/external/guide.md', 'wt', '/repo', {
|
||||
pathQuery: '/external/guide.md',
|
||||
@@ -69,7 +101,8 @@ it('validates pasted local paths with user-named access and uses the owner-quali
|
||||
filePath: '/external/guide.md',
|
||||
relativePath: '/external/guide.md',
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
expect(mocks.source).toHaveBeenCalledWith('owner-file-id', 'source')
|
||||
expect(mocks.reveal).toHaveBeenCalledWith(
|
||||
@@ -89,7 +122,10 @@ it('keeps a literal colon filename when it exists instead of applying its appare
|
||||
{ pathQuery: '/repo/report', line: 12 },
|
||||
'/repo/report:12'
|
||||
)
|
||||
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/repo/report:12' }))
|
||||
expect(mocks.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: '/repo/report:12' }),
|
||||
undefined
|
||||
)
|
||||
expect(mocks.reveal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -102,7 +138,10 @@ it('falls back from a missing literal colon filename to line navigation', async
|
||||
{ pathQuery: '/repo/file.ts', line: 12 },
|
||||
'/repo/file.ts:12'
|
||||
)
|
||||
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/repo/file.ts' }))
|
||||
expect(mocks.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: '/repo/file.ts' }),
|
||||
undefined
|
||||
)
|
||||
expect(mocks.reveal).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'/repo/file.ts',
|
||||
@@ -121,7 +160,10 @@ it('probes SSH paths only on their owning host', async () => {
|
||||
'/external/file.ts',
|
||||
{ kind: 'user-file' }
|
||||
)
|
||||
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ externalSshTargetId: 'ssh-a' }))
|
||||
expect(mocks.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ externalSshTargetId: 'ssh-a' }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves paired paths on the host and never falls back to client filesystem access', async () => {
|
||||
@@ -136,7 +178,8 @@ it('resolves paired paths on the host and never falls back to client filesystem
|
||||
expect(mocks.stat).not.toHaveBeenCalled()
|
||||
expect(mocks.authorize).not.toHaveBeenCalled()
|
||||
expect(mocks.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimeEnvironmentId: 'env-a', relativePath: 'file.ts' })
|
||||
expect.objectContaining({ runtimeEnvironmentId: 'env-a', relativePath: 'file.ts' }),
|
||||
undefined
|
||||
)
|
||||
mocks.rpc.mockResolvedValue({
|
||||
exists: true,
|
||||
@@ -200,7 +243,10 @@ it('resolves literal colon names on a paired host before treating the suffix as
|
||||
'/repo/report:12'
|
||||
)
|
||||
expect(mocks.rpc).toHaveBeenCalledOnce()
|
||||
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/repo/report:12' }))
|
||||
expect(mocks.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: '/repo/report:12' }),
|
||||
undefined
|
||||
)
|
||||
expect(mocks.reveal).not.toHaveBeenCalled()
|
||||
expect(mocks.stat).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -220,7 +266,10 @@ it('keeps a pasted POSIX file on the local workspace WSL distro before any files
|
||||
expect.anything(),
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\repo\\file.ts'
|
||||
)
|
||||
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ relativePath: 'file.ts' }))
|
||||
expect(mocks.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ relativePath: 'file.ts' }),
|
||||
undefined
|
||||
)
|
||||
mocks.route.current.connectionId = 'ssh-a'
|
||||
await openQuickOpenFile('/home/repo/file.ts', 'wt', '\\\\wsl.localhost\\Ubuntu\\home\\repo', {
|
||||
pathQuery: '/home/repo/file.ts'
|
||||
@@ -244,7 +293,8 @@ it.each(['literal:12', 'nested/literal:12', 'literal:12:3'])(
|
||||
query
|
||||
)
|
||||
expect(mocks.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: `/repo/${selected}` })
|
||||
expect.objectContaining({ filePath: `/repo/${selected}` }),
|
||||
undefined
|
||||
)
|
||||
expect(mocks.reveal).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prepareQuickOpenFiles, rankQuickOpenFiles } from '../../../shared/quick-open-path-search'
|
||||
import { statUserOpenedPath } from '@/lib/user-opened-local-path'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { openWorkspaceFile } from '@/lib/workspace-file-open'
|
||||
import { joinPath, getRelativePathInsideRoot } from '@/lib/path'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isMissingRuntimePathError } from '@/runtime/runtime-file-metadata-client'
|
||||
@@ -120,28 +121,27 @@ export async function openQuickOpenFile(
|
||||
}
|
||||
}
|
||||
assertCurrent()
|
||||
const store = useAppStore.getState()
|
||||
const fileId = store.openFile({
|
||||
filePath,
|
||||
relativePath,
|
||||
worktreeId,
|
||||
runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null,
|
||||
...(route.connectionId && getRelativePathInsideRoot(filePath, root) === null
|
||||
? { externalSshTargetId: route.connectionId }
|
||||
: {}),
|
||||
language: detectLanguage(filePath),
|
||||
mode: 'edit'
|
||||
const revealLine = literalSelected ? undefined : navigation.line
|
||||
const opened = openWorkspaceFile({
|
||||
kind: 'file',
|
||||
intent: revealLine === undefined ? 'default' : 'source',
|
||||
file: {
|
||||
filePath,
|
||||
relativePath,
|
||||
worktreeId,
|
||||
runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null,
|
||||
...(route.connectionId && getRelativePathInsideRoot(filePath, root) === null
|
||||
? { externalSshTargetId: route.connectionId }
|
||||
: {}),
|
||||
language: detectLanguage(filePath),
|
||||
mode: 'edit'
|
||||
}
|
||||
})
|
||||
if (!literalSelected && navigation.line !== undefined) {
|
||||
if (revealLine !== undefined && opened.surface === 'editor') {
|
||||
const fileId = opened.fileId
|
||||
if (detectLanguage(filePath) === 'markdown') {
|
||||
useAppStore.getState().setMarkdownViewMode(fileId, 'source')
|
||||
}
|
||||
scheduleEditorLineReveal(
|
||||
useAppStore.getState,
|
||||
filePath,
|
||||
navigation.line,
|
||||
navigation.column,
|
||||
fileId
|
||||
)
|
||||
scheduleEditorLineReveal(useAppStore.getState, filePath, revealLine, navigation.column, fileId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ const mocks = vi.hoisted(() => {
|
||||
lastActivityAt: 0
|
||||
}
|
||||
const calls = {
|
||||
createBrowserTab: vi.fn(),
|
||||
openDiff: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
openConflictFile: vi.fn(),
|
||||
@@ -149,6 +150,10 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
|
||||
activeWorktreeId: mocks.activeWorktree.id,
|
||||
activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' },
|
||||
groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] },
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
createBrowserTab: mocks.calls.createBrowserTab,
|
||||
getKnownWorktreeById: () => mocks.activeWorktree,
|
||||
repos: [mocks.activeRepo],
|
||||
worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] },
|
||||
sshConnectionStates: new Map(),
|
||||
@@ -211,6 +216,7 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
|
||||
clearDiffComments: noopAsync(true),
|
||||
clearDiffCommentsForFile: noopAsync(true),
|
||||
setScrollToDiffCommentId: vi.fn(),
|
||||
closeAgentSendPopoverTargetMode: vi.fn(),
|
||||
setRightSidebarOpen: vi.fn(),
|
||||
setRightSidebarTab: vi.fn(),
|
||||
allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'),
|
||||
@@ -267,11 +273,10 @@ function doubleClickUncommitted(path: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
function clickBranchRow(init: MouseEventInit = {}): void {
|
||||
const label = [...container.querySelectorAll('span')].find(
|
||||
(candidate) => candidate.textContent === 'branch.ts'
|
||||
function clickBranchRow(path = 'src/branch.ts', init: MouseEventInit = {}): void {
|
||||
const row = container.querySelector<HTMLDivElement>(
|
||||
`[data-source-control-area="branch"][data-source-control-path="${path}"]`
|
||||
)
|
||||
const row = label?.closest('div')
|
||||
expect(row).not.toBeNull()
|
||||
act(() => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true, ...init }))
|
||||
@@ -279,6 +284,260 @@ function clickBranchRow(init: MouseEventInit = {}): void {
|
||||
}
|
||||
|
||||
describe('SourceControl preview row opens', () => {
|
||||
it.each(['untracked', 'unstaged', 'staged'] as const)(
|
||||
'keeps the current-file indicator on an HTML browser preview in %s',
|
||||
(area) => {
|
||||
resetState({
|
||||
gitStatusByWorktree: {
|
||||
[mocks.activeWorktree.id]: [
|
||||
gitEntry({
|
||||
path: 'report one.html',
|
||||
area,
|
||||
status: area === 'untracked' ? area : 'modified'
|
||||
})
|
||||
]
|
||||
},
|
||||
activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'browser' },
|
||||
activeBrowserTabIdByWorktree: { [mocks.activeWorktree.id]: 'browser-1' },
|
||||
browserTabsByWorktree: {
|
||||
[mocks.activeWorktree.id]: [{ id: 'browser-1', activePageId: 'page-html' }]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'browser-1': [
|
||||
{ id: 'page-web', url: 'https://example.com' },
|
||||
{ id: 'page-html', url: 'file:///repo/wt/report%20one.html#section' }
|
||||
]
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-source-control-path="report one.html"]')
|
||||
?.getAttribute('data-current')
|
||||
).toBe('true')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([mocks.activeWorktree.id, 'other-workspace'])(
|
||||
'matches a workspace document only to its owning workspace: %s',
|
||||
(documentWorktreeId) => {
|
||||
resetState({
|
||||
repos: [{ ...mocks.activeRepo, connectionId: 'ssh-1' }],
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'test.html' })] },
|
||||
activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'browser' },
|
||||
activeBrowserTabIdByWorktree: { [mocks.activeWorktree.id]: 'browser-1' },
|
||||
browserTabsByWorktree: {
|
||||
[mocks.activeWorktree.id]: [{ id: 'browser-1', activePageId: 'page-html' }]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'browser-1': [
|
||||
{
|
||||
id: 'page-html',
|
||||
url: 'data:text/html,',
|
||||
docLocation: {
|
||||
kind: 'workspace-doc',
|
||||
worktreeId: documentWorktreeId,
|
||||
filePath: '/repo/wt/test.html'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-source-control-path="test.html"]')
|
||||
?.getAttribute('data-current')
|
||||
).toBe(documentWorktreeId === mocks.activeWorktree.id ? 'true' : null)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['untracked', 'unstaged', 'staged'] as const)(
|
||||
'renders HTML when opening a row in %s',
|
||||
(area) => {
|
||||
resetState({
|
||||
gitStatusByWorktree: {
|
||||
[mocks.activeWorktree.id]: [
|
||||
gitEntry({ path: 'test.html', area, status: area === 'untracked' ? area : 'modified' })
|
||||
]
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
clickUncommitted('test.html')
|
||||
|
||||
expect(mocks.calls.createBrowserTab).toHaveBeenCalledWith(
|
||||
mocks.activeWorktree.id,
|
||||
'file:///repo/wt/test.html',
|
||||
{ title: 'test.html', targetGroupId: undefined, activate: true }
|
||||
)
|
||||
expect(mocks.calls.openDiff).not.toHaveBeenCalled()
|
||||
expect(mocks.calls.openFile).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('targets the requested split when previewing HTML', () => {
|
||||
resetState({
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'test.html' })] }
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
clickUncommitted('test.html', { ctrlKey: true })
|
||||
|
||||
expect(mocks.calls.createBrowserTab).toHaveBeenCalledWith(
|
||||
mocks.activeWorktree.id,
|
||||
'file:///repo/wt/test.html',
|
||||
{ title: 'test.html', targetGroupId: 'group-2', activate: true }
|
||||
)
|
||||
expect(mocks.calls.openDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('previews HTML through its SSH owner', () => {
|
||||
resetState({
|
||||
repos: [{ ...mocks.activeRepo, connectionId: 'ssh-1' }],
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'test.html' })] }
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
clickUncommitted('test.html')
|
||||
|
||||
expect(mocks.calls.createBrowserTab).toHaveBeenCalledWith(
|
||||
mocks.activeWorktree.id,
|
||||
'data:text/html,',
|
||||
expect.objectContaining({
|
||||
docLocation: {
|
||||
kind: 'workspace-doc',
|
||||
worktreeId: mocks.activeWorktree.id,
|
||||
filePath: '/repo/wt/test.html'
|
||||
},
|
||||
browserRuntimeEnvironmentId: null,
|
||||
activate: true
|
||||
})
|
||||
)
|
||||
expect(mocks.calls.openDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps deleted HTML and HTML conflicts in their diff surfaces', () => {
|
||||
resetState({
|
||||
gitStatusByWorktree: {
|
||||
[mocks.activeWorktree.id]: [
|
||||
gitEntry({ path: 'deleted.html', status: 'deleted' }),
|
||||
gitEntry({
|
||||
path: 'conflict.html',
|
||||
conflictKind: 'both_modified',
|
||||
conflictStatus: 'unresolved'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
clickUncommitted('deleted.html')
|
||||
clickUncommitted('conflict.html')
|
||||
|
||||
expect(mocks.calls.createBrowserTab).not.toHaveBeenCalled()
|
||||
expect(mocks.calls.openDiff).toHaveBeenCalledWith(
|
||||
mocks.activeWorktree.id,
|
||||
'/repo/wt/deleted.html',
|
||||
'deleted.html',
|
||||
'html',
|
||||
false,
|
||||
{ targetGroupId: undefined, preview: true }
|
||||
)
|
||||
expect(mocks.calls.openConflictFile).toHaveBeenCalledWith(
|
||||
mocks.activeWorktree.id,
|
||||
'/repo/wt',
|
||||
expect.objectContaining({ path: 'conflict.html' }),
|
||||
'html',
|
||||
{ targetGroupId: undefined, preview: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the diff when this client cannot preview HTML', () => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
|
||||
onTestFinished(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
resetState({
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'test.html' })] }
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
clickUncommitted('test.html')
|
||||
|
||||
expect(mocks.calls.createBrowserTab).not.toHaveBeenCalled()
|
||||
expect(mocks.calls.openDiff).toHaveBeenCalledWith(
|
||||
mocks.activeWorktree.id,
|
||||
'/repo/wt/test.html',
|
||||
'test.html',
|
||||
'html',
|
||||
false,
|
||||
{ targetGroupId: undefined, preview: true }
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['uncommitted', 'branch'] as const)('opens a %s HTML review note in the diff', (area) => {
|
||||
resetState({
|
||||
worktreesByRepo: {
|
||||
[mocks.activeRepo.id]: [
|
||||
{
|
||||
...mocks.activeWorktree,
|
||||
diffComments: [
|
||||
{
|
||||
id: 'html-note',
|
||||
worktreeId: mocks.activeWorktree.id,
|
||||
filePath: 'test.html',
|
||||
lineNumber: 1,
|
||||
side: 'modified',
|
||||
body: 'Review this heading',
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
gitStatusByWorktree: {
|
||||
[mocks.activeWorktree.id]: area === 'uncommitted' ? [gitEntry({ path: 'test.html' })] : []
|
||||
},
|
||||
gitBranchChangesByWorktree: {
|
||||
[mocks.activeWorktree.id]: area === 'branch' ? [branchEntry({ path: 'test.html' })] : []
|
||||
},
|
||||
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: branchSummary() }
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
const expandNotes = container.querySelector<HTMLButtonElement>('button[title="Expand notes"]')
|
||||
expect(expandNotes).not.toBeNull()
|
||||
act(() => expandNotes?.click())
|
||||
const note = container.querySelector<HTMLButtonElement>('button[aria-label^="Open note on"]')
|
||||
expect(note?.textContent).toContain('Review this heading')
|
||||
act(() => note?.click())
|
||||
|
||||
expect(mocks.calls.createBrowserTab).not.toHaveBeenCalled()
|
||||
if (area === 'uncommitted') {
|
||||
expect(mocks.calls.openDiff).toHaveBeenCalledWith(
|
||||
mocks.activeWorktree.id,
|
||||
'/repo/wt/test.html',
|
||||
'test.html',
|
||||
'html',
|
||||
false,
|
||||
{ preview: true }
|
||||
)
|
||||
} else {
|
||||
expect(mocks.calls.openBranchDiff).toHaveBeenCalledWith(
|
||||
mocks.activeWorktree.id,
|
||||
'/repo/wt',
|
||||
expect.objectContaining({ path: 'test.html' }),
|
||||
branchSummary(),
|
||||
'html',
|
||||
{ preview: true }
|
||||
)
|
||||
}
|
||||
expect(mocks.state.setScrollToDiffCommentId).toHaveBeenLastCalledWith('html-note')
|
||||
})
|
||||
|
||||
it('passes preview=true when plain uncommitted row clicks open diff tabs', () => {
|
||||
resetState({
|
||||
gitStatusByWorktree: {
|
||||
@@ -531,6 +790,68 @@ describe('SourceControl preview row opens', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([false, true])('previews committed HTML from a branch row (split: %s)', (split) => {
|
||||
resetState({
|
||||
gitBranchChangesByWorktree: {
|
||||
[mocks.activeWorktree.id]: [branchEntry({ path: 'test.html' })]
|
||||
},
|
||||
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: branchSummary() }
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
clickBranchRow('test.html', { ctrlKey: split })
|
||||
|
||||
expect(mocks.calls.createBrowserTab).toHaveBeenCalledWith(
|
||||
mocks.activeWorktree.id,
|
||||
'file:///repo/wt/test.html',
|
||||
{ title: 'test.html', targetGroupId: split ? 'group-2' : undefined, activate: true }
|
||||
)
|
||||
expect(mocks.calls.openBranchDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['branch-deleted', 'worktree-deleted', 'worktree-conflict'] as const)(
|
||||
'keeps a %s HTML branch row in the diff',
|
||||
(kind) => {
|
||||
resetState({
|
||||
gitBranchChangesByWorktree: {
|
||||
[mocks.activeWorktree.id]: [
|
||||
branchEntry({
|
||||
path: 'test.html',
|
||||
status: kind === 'branch-deleted' ? 'deleted' : 'added'
|
||||
})
|
||||
]
|
||||
},
|
||||
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: branchSummary() },
|
||||
gitStatusByWorktree: {
|
||||
[mocks.activeWorktree.id]:
|
||||
kind === 'branch-deleted'
|
||||
? []
|
||||
: [
|
||||
gitEntry({
|
||||
path: 'test.html',
|
||||
...(kind === 'worktree-deleted'
|
||||
? { status: 'deleted' }
|
||||
: { conflictKind: 'both_modified', conflictStatus: 'unresolved' })
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
clickBranchRow('test.html')
|
||||
|
||||
expect(mocks.calls.createBrowserTab).not.toHaveBeenCalled()
|
||||
expect(mocks.calls.openBranchDiff).toHaveBeenCalledWith(
|
||||
mocks.activeWorktree.id,
|
||||
'/repo/wt',
|
||||
expect.objectContaining({ path: 'test.html' }),
|
||||
branchSummary(),
|
||||
'html',
|
||||
{ targetGroupId: undefined, preview: true }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('passes preview=true when a plain branch row click opens a branch diff tab', () => {
|
||||
resetState({
|
||||
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [branchEntry()] },
|
||||
|
||||
@@ -280,7 +280,7 @@ function HandlersProbe({ scrollRef }: { scrollRef: React.RefObject<HTMLDivElemen
|
||||
capturedHandlers = useFileExplorerHandlers({
|
||||
activeWorktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: null,
|
||||
openFile: vi.fn(),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
makePreviewFilePermanent: vi.fn(),
|
||||
toggleDir: vi.fn(),
|
||||
loadDir: vi.fn(),
|
||||
|
||||
@@ -48,7 +48,7 @@ const symlinkDirectoryNode: TreeNode = {
|
||||
function createHandlerParams(toggleDir: (worktreeId: string, dirPath: string) => void) {
|
||||
return {
|
||||
activeWorktreeId: 'wt-1',
|
||||
openFile: vi.fn(),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
makePreviewFilePermanent: vi.fn(),
|
||||
toggleDir,
|
||||
loadDir: vi.fn().mockResolvedValue(true),
|
||||
@@ -62,7 +62,11 @@ function createHandlerParams(toggleDir: (worktreeId: string, dirPath: string) =>
|
||||
function renderHandlers(toggleDir: (worktreeId: string, dirPath: string) => void) {
|
||||
const params = createHandlerParams(toggleDir)
|
||||
const hook = renderHook(() => useFileExplorerHandlers(params))
|
||||
return { ...hook, openFile: params.openFile, setSelectedPath: params.setSelectedPath }
|
||||
return {
|
||||
...hook,
|
||||
openWorkspaceFile: params.openWorkspaceFile,
|
||||
setSelectedPath: params.setSelectedPath
|
||||
}
|
||||
}
|
||||
|
||||
// Wires a row like the explorer does so clicks resolve timing in handleRowClick.
|
||||
@@ -183,7 +187,7 @@ describe('instant directory toggle', () => {
|
||||
|
||||
it('keeps opening files on the second click, which is not a directory toggle', async () => {
|
||||
const toggleDir = vi.fn()
|
||||
const { result, openFile } = renderHandlers(toggleDir)
|
||||
const { result, openWorkspaceFile } = renderHandlers(toggleDir)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleClick(fileNode, 'skip')
|
||||
@@ -191,6 +195,6 @@ describe('instant directory toggle', () => {
|
||||
})
|
||||
|
||||
expect(toggleDir).not.toHaveBeenCalled()
|
||||
expect(openFile).toHaveBeenCalledTimes(1)
|
||||
expect(openWorkspaceFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ function render(
|
||||
worktreePath={join(tmpdir(), 'wt-1')}
|
||||
revealInExplorer={vi.fn()}
|
||||
activeConnectionId={null}
|
||||
openCommittedDiff={vi.fn()}
|
||||
openBranchFile={vi.fn()}
|
||||
openBranchAllDiffs={vi.fn()}
|
||||
diffCommentCountByPath={new Map()}
|
||||
/>
|
||||
|
||||
+47
@@ -1,9 +1,56 @@
|
||||
import type { DiffSource } from '@/store/slices/editor'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getRelativePathInsideRoot } from '@/lib/path'
|
||||
import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner'
|
||||
import { getBrowserPageRuntimeEnvironmentId } from '@/components/browser-pane/describe-page/browser-page-url-display'
|
||||
import { browserFileUrlToAbsolutePath } from '@/components/browser-pane/describe-page/browser-artifact-upload'
|
||||
import { canOpenBrowserFileInWorkspaceEditor } from '@/components/browser-pane/navigate/browser-file-editor-open'
|
||||
|
||||
const EMPTY_OPEN_ROW_KEYS: ReadonlySet<string> = new Set()
|
||||
|
||||
const SIGNATURE_SEPARATOR = '::'
|
||||
|
||||
export function getActiveOpenFileSignature(
|
||||
state: AppState,
|
||||
worktreeId: string | null,
|
||||
worktreePath: string | null
|
||||
): string | null {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
if (state.activeTabTypeByWorktree?.[worktreeId] === 'editor') {
|
||||
const activeFileId = state.activeFileIdByWorktree?.[worktreeId]
|
||||
const activeFile = state.openFiles?.find(
|
||||
(file) => file.id === activeFileId && file.worktreeId === worktreeId
|
||||
)
|
||||
return activeFile
|
||||
? buildActiveOpenFileSignature(activeFile.diffSource, activeFile.relativePath)
|
||||
: null
|
||||
}
|
||||
if (state.activeTabTypeByWorktree?.[worktreeId] !== 'browser') {
|
||||
return null
|
||||
}
|
||||
const workspaceId = state.activeBrowserTabIdByWorktree?.[worktreeId]
|
||||
const workspace = state.browserTabsByWorktree?.[worktreeId]?.find((tab) => tab.id === workspaceId)
|
||||
const pages = workspaceId ? state.browserPagesByWorkspace?.[workspaceId] : undefined
|
||||
const page = pages?.find((candidate) => candidate.id === workspace?.activePageId) ?? pages?.[0]
|
||||
if (!page) {
|
||||
return null
|
||||
}
|
||||
let filePath: string | null = null
|
||||
if (page.docLocation?.worktreeId === worktreeId) {
|
||||
filePath = page.docLocation.filePath
|
||||
} else if (
|
||||
!page.docLocation &&
|
||||
!getBrowserPageRuntimeEnvironmentId(page, runtimeEnvironmentIdUnlessTwin(state, worktreeId)) &&
|
||||
canOpenBrowserFileInWorkspaceEditor(state, worktreeId)
|
||||
) {
|
||||
filePath = browserFileUrlToAbsolutePath(page.url)
|
||||
}
|
||||
const relativePath = filePath ? getRelativePathInsideRoot(filePath, worktreePath) : null
|
||||
return relativePath ? buildActiveOpenFileSignature(undefined, relativePath) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stable string that identifies the active editor file for highlight
|
||||
* matching: `${diffSource}::${relativePath}`. Kept as a primitive so the zustand
|
||||
|
||||
@@ -48,6 +48,8 @@ export function BranchEntryRow({
|
||||
onRevealInExplorer={onRevealInExplorer}
|
||||
>
|
||||
<div
|
||||
data-source-control-path={entry.path}
|
||||
data-source-control-area="branch"
|
||||
className="group flex cursor-pointer items-center gap-1 pr-3 py-1 transition-colors hover:bg-accent/40"
|
||||
style={{
|
||||
paddingLeft: `${depth * SOURCE_CONTROL_TREE_INDENT_PX + SOURCE_CONTROL_TREE_FILE_PADDING_PX}px`
|
||||
|
||||
@@ -29,7 +29,7 @@ export function SourceControlBranchSection({
|
||||
worktreePath,
|
||||
revealInExplorer,
|
||||
activeConnectionId,
|
||||
openCommittedDiff,
|
||||
openBranchFile,
|
||||
openBranchAllDiffs,
|
||||
diffCommentCountByPath
|
||||
}: {
|
||||
@@ -47,7 +47,7 @@ export function SourceControlBranchSection({
|
||||
worktreePath: string
|
||||
revealInExplorer: (worktreeId: string, absolutePath: string) => void
|
||||
activeConnectionId: string | null
|
||||
openCommittedDiff: (entry: GitBranchChangeEntry, event?: SourceControlRowOpenEvent) => void
|
||||
openBranchFile: (entry: GitBranchChangeEntry, event?: SourceControlRowOpenEvent) => void
|
||||
openBranchAllDiffs: (
|
||||
worktreeId: string,
|
||||
worktreePath: string,
|
||||
@@ -130,7 +130,7 @@ export function SourceControlBranchSection({
|
||||
depth={node.depth}
|
||||
onRevealInExplorer={revealInExplorer}
|
||||
connectionId={activeConnectionId}
|
||||
onOpen={(event) => openCommittedDiff(node.entry, event)}
|
||||
onOpen={(event) => openBranchFile(node.entry, event)}
|
||||
commentCount={diffCommentCountByPath.get(node.entry.path) ?? 0}
|
||||
showPathHint={false}
|
||||
/>
|
||||
@@ -150,7 +150,7 @@ export function SourceControlBranchSection({
|
||||
worktreePath={worktreePath}
|
||||
onRevealInExplorer={revealInExplorer}
|
||||
connectionId={activeConnectionId}
|
||||
onOpen={(event) => openCommittedDiff(entry, event)}
|
||||
onOpen={(event) => openBranchFile(entry, event)}
|
||||
commentCount={diffCommentCountByPath.get(entry.path) ?? 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { getEditorPanelRenderModel } from '@/components/editor/editor-panel-render-model'
|
||||
import { createEditorStore } from '@/store/slices/editor-slice-test-harness'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import { openUncommittedEntry } from './open-uncommitted-entry'
|
||||
import { openUncommittedEntry } from '@/lib/workspace-file-changes-open'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
vi.mock('@/runtime/close-mirrored-editor-tab', () => ({
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ function renderUncommittedSections({
|
||||
handleContextMenu={vi.fn()}
|
||||
revealInExplorer={vi.fn()}
|
||||
activeConnectionId={null}
|
||||
handleOpenDiff={vi.fn()}
|
||||
openUncommittedFile={vi.fn()}
|
||||
handleStage={vi.fn()}
|
||||
handleUnstage={vi.fn()}
|
||||
requestDiscardEntry={vi.fn()}
|
||||
|
||||
+4
-4
@@ -37,7 +37,7 @@ export function SourceControlSectionFileList({
|
||||
handleContextMenu,
|
||||
revealInExplorer,
|
||||
activeConnectionId,
|
||||
handleOpenDiff,
|
||||
openUncommittedFile,
|
||||
handleStage,
|
||||
handleUnstage,
|
||||
requestDiscardEntry,
|
||||
@@ -64,7 +64,7 @@ export function SourceControlSectionFileList({
|
||||
handleContextMenu: (key: string) => void
|
||||
revealInExplorer: (worktreeId: string, absolutePath: string) => void
|
||||
activeConnectionId: string | null
|
||||
handleOpenDiff: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void
|
||||
openUncommittedFile: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void
|
||||
handleStage: (path: string) => Promise<void>
|
||||
handleUnstage: (path: string, oldPath?: string) => Promise<void>
|
||||
requestDiscardEntry: (entry: GitStatusEntry) => void
|
||||
@@ -122,7 +122,7 @@ export function SourceControlSectionFileList({
|
||||
onContextMenu={handleContextMenu}
|
||||
onRevealInExplorer={revealInExplorer}
|
||||
connectionId={activeConnectionId}
|
||||
onOpen={handleOpenDiff}
|
||||
onOpen={openUncommittedFile}
|
||||
onStage={handleStage}
|
||||
onUnstage={handleUnstage}
|
||||
onDiscard={requestDiscardEntry}
|
||||
@@ -173,7 +173,7 @@ export function SourceControlSectionFileList({
|
||||
onContextMenu={handleContextMenu}
|
||||
onRevealInExplorer={revealInExplorer}
|
||||
connectionId={activeConnectionId}
|
||||
onOpen={handleOpenDiff}
|
||||
onOpen={openUncommittedFile}
|
||||
onStage={handleStage}
|
||||
onUnstage={handleUnstage}
|
||||
onDiscard={requestDiscardEntry}
|
||||
|
||||
+2
-2
@@ -86,7 +86,7 @@ export function SourceControlUncommittedSections(props: {
|
||||
handleContextMenu: (key: string) => void
|
||||
revealInExplorer: (worktreeId: string, absolutePath: string) => void
|
||||
activeConnectionId: string | null
|
||||
handleOpenDiff: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void
|
||||
openUncommittedFile: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void
|
||||
handleStage: (path: string) => Promise<void>
|
||||
handleUnstage: (path: string, oldPath?: string) => Promise<void>
|
||||
requestDiscardEntry: (entry: GitStatusEntry) => void
|
||||
@@ -215,7 +215,7 @@ export function SourceControlUncommittedSections(props: {
|
||||
handleContextMenu={props.handleContextMenu}
|
||||
revealInExplorer={props.revealInExplorer}
|
||||
activeConnectionId={props.activeConnectionId}
|
||||
handleOpenDiff={props.handleOpenDiff}
|
||||
openUncommittedFile={props.openUncommittedFile}
|
||||
handleStage={props.handleStage}
|
||||
handleUnstage={props.handleUnstage}
|
||||
requestDiscardEntry={props.requestDiscardEntry}
|
||||
|
||||
+4
-4
@@ -115,7 +115,7 @@ export function useSourceControlFileListing({
|
||||
})
|
||||
|
||||
// Why: modifier-click keeps the current pane intact by opening the file in a fresh split to the right.
|
||||
const { resolveSplitTargetGroupId, activeOpenRowKeys, handleOpenDiff, openCommittedDiff } =
|
||||
const { resolveSplitTargetGroupId, activeOpenRowKeys, openUncommittedFile, openBranchFile } =
|
||||
useSourceControlRowOpening({
|
||||
isMac,
|
||||
activeWorktreeId,
|
||||
@@ -131,7 +131,7 @@ export function useSourceControlFileListing({
|
||||
const { selectedKeys, handleSelect, handleContextMenu, clearSelection } =
|
||||
useSourceControlSelection({
|
||||
flatEntries: visibleSelectionEntries,
|
||||
onOpenDiff: handleOpenDiff,
|
||||
onOpenFile: openUncommittedFile,
|
||||
shouldOpenAsSplit,
|
||||
containerRef: sourceControlRef
|
||||
})
|
||||
@@ -212,7 +212,7 @@ export function useSourceControlFileListing({
|
||||
handleBulkStage,
|
||||
handleBulkUnstage,
|
||||
handleContextMenu,
|
||||
handleOpenDiff,
|
||||
openUncommittedFile,
|
||||
handleSelect,
|
||||
handleStageAllPaths,
|
||||
handleStageAllPrimary,
|
||||
@@ -222,7 +222,7 @@ export function useSourceControlFileListing({
|
||||
isExecutingBulk,
|
||||
isGitHistoryVisible,
|
||||
normalizedFilter,
|
||||
openCommittedDiff,
|
||||
openBranchFile,
|
||||
pushRecovery,
|
||||
refreshGitHistory,
|
||||
refreshGitHistoryRef,
|
||||
|
||||
+27
-49
@@ -1,13 +1,12 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { openUncommittedEntry } from './open-uncommitted-entry'
|
||||
import { openWorkspaceFile } from '@/lib/workspace-file-open'
|
||||
import { useAppStore } from '@/store'
|
||||
import type {
|
||||
GitBranchChangeEntry,
|
||||
GitBranchCompareSummary
|
||||
} from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import { buildActiveOpenFileSignature, buildActiveOpenRowKeys } from './active-open-file-keys'
|
||||
import { getActiveOpenFileSignature, buildActiveOpenRowKeys } from './active-open-file-keys'
|
||||
import type { FlatEntry } from './use-selection'
|
||||
import {
|
||||
isSourceControlSplitOpenModifier,
|
||||
@@ -31,11 +30,6 @@ export function useSourceControlRowOpening({
|
||||
const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree)
|
||||
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
|
||||
const createEmptySplitGroup = useAppStore((s) => s.createEmptySplitGroup)
|
||||
const trackConflictPath = useAppStore((s) => s.trackConflictPath)
|
||||
const openConflictFile = useAppStore((s) => s.openConflictFile)
|
||||
const openDiff = useAppStore((s) => s.openDiff)
|
||||
const openFile = useAppStore((s) => s.openFile)
|
||||
const openBranchDiff = useAppStore((s) => s.openBranchDiff)
|
||||
|
||||
// Why: modifier-click keeps the current pane intact by opening the file in a fresh split to the right.
|
||||
const resolveSplitTargetGroupId = useCallback(
|
||||
@@ -53,22 +47,9 @@ export function useSourceControlRowOpening({
|
||||
[activeGroupIdByWorktree, activeWorktreeId, createEmptySplitGroup, groupsByWorktree, isMac]
|
||||
)
|
||||
|
||||
// Why: a stable string signature keeps this selector referentially stable so the panel re-renders only when the active editor file changes; null when the tab isn't an editor.
|
||||
const activeOpenFileSignature = useAppStore((s) => {
|
||||
if (!activeWorktreeId || s.activeTabTypeByWorktree?.[activeWorktreeId] !== 'editor') {
|
||||
return null
|
||||
}
|
||||
const activeFileId = s.activeFileIdByWorktree?.[activeWorktreeId]
|
||||
if (!activeFileId) {
|
||||
return null
|
||||
}
|
||||
const activeFile = s.openFiles?.find(
|
||||
(file) => file.id === activeFileId && file.worktreeId === activeWorktreeId
|
||||
)
|
||||
return activeFile
|
||||
? buildActiveOpenFileSignature(activeFile.diffSource, activeFile.relativePath)
|
||||
: null
|
||||
})
|
||||
const activeOpenFileSignature = useAppStore((s) =>
|
||||
getActiveOpenFileSignature(s, activeWorktreeId, worktreePath)
|
||||
)
|
||||
const activeOpenAvailableRowKeys = useMemo(() => {
|
||||
const keys = new Set<string>()
|
||||
for (const entry of visibleSelectionEntries) {
|
||||
@@ -81,33 +62,27 @@ export function useSourceControlRowOpening({
|
||||
[activeOpenAvailableRowKeys, activeOpenFileSignature]
|
||||
)
|
||||
|
||||
const handleOpenDiff = useCallback(
|
||||
const openUncommittedFile = useCallback(
|
||||
(entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => {
|
||||
if (!activeWorktreeId || !worktreePath) {
|
||||
return
|
||||
}
|
||||
const targetGroupId = resolveSplitTargetGroupId(event)
|
||||
const openAsPreview = shouldOpenSourceControlRowAsPreview(event, targetGroupId)
|
||||
openUncommittedEntry(
|
||||
{ trackConflictPath, openConflictFile, openFile, openDiff },
|
||||
activeWorktreeId,
|
||||
openWorkspaceFile({
|
||||
kind: 'uncommitted',
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
entry,
|
||||
{ targetGroupId, preview: openAsPreview }
|
||||
)
|
||||
options: {
|
||||
targetGroupId,
|
||||
preview: shouldOpenSourceControlRowAsPreview(event, targetGroupId)
|
||||
}
|
||||
})
|
||||
},
|
||||
[
|
||||
activeWorktreeId,
|
||||
worktreePath,
|
||||
resolveSplitTargetGroupId,
|
||||
trackConflictPath,
|
||||
openConflictFile,
|
||||
openDiff,
|
||||
openFile
|
||||
]
|
||||
[activeWorktreeId, worktreePath, resolveSplitTargetGroupId]
|
||||
)
|
||||
|
||||
const openCommittedDiff = useCallback(
|
||||
const openBranchFile = useCallback(
|
||||
(entry: GitBranchChangeEntry, event?: SourceControlRowOpenEvent) => {
|
||||
if (
|
||||
!activeWorktreeId ||
|
||||
@@ -118,17 +93,20 @@ export function useSourceControlRowOpening({
|
||||
return
|
||||
}
|
||||
const targetGroupId = resolveSplitTargetGroupId(event)
|
||||
openBranchDiff(
|
||||
activeWorktreeId,
|
||||
openWorkspaceFile({
|
||||
kind: 'branch',
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
entry,
|
||||
branchSummary,
|
||||
detectLanguage(entry.path),
|
||||
{ targetGroupId, preview: shouldOpenSourceControlRowAsPreview(event, targetGroupId) }
|
||||
)
|
||||
compare: branchSummary,
|
||||
options: {
|
||||
targetGroupId,
|
||||
preview: shouldOpenSourceControlRowAsPreview(event, targetGroupId)
|
||||
}
|
||||
})
|
||||
},
|
||||
[activeWorktreeId, branchSummary, openBranchDiff, resolveSplitTargetGroupId, worktreePath]
|
||||
[activeWorktreeId, branchSummary, resolveSplitTargetGroupId, worktreePath]
|
||||
)
|
||||
|
||||
return { resolveSplitTargetGroupId, activeOpenRowKeys, handleOpenDiff, openCommittedDiff }
|
||||
return { resolveSplitTargetGroupId, activeOpenRowKeys, openUncommittedFile, openBranchFile }
|
||||
}
|
||||
|
||||
@@ -62,12 +62,12 @@ export function getSelectionRangeKeys(
|
||||
|
||||
export function useSourceControlSelection({
|
||||
flatEntries,
|
||||
onOpenDiff,
|
||||
onOpenFile,
|
||||
shouldOpenAsSplit,
|
||||
containerRef
|
||||
}: {
|
||||
flatEntries: FlatEntry[]
|
||||
onOpenDiff: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void
|
||||
onOpenFile: (entry: GitStatusEntry, event?: SourceControlRowOpenEvent) => void
|
||||
shouldOpenAsSplit?: (event: SourceControlRowOpenEvent) => boolean
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
}) {
|
||||
@@ -76,7 +76,7 @@ export function useSourceControlSelection({
|
||||
const flatEntriesRef = useRef(flatEntries)
|
||||
const anchorKeyRef = useRef<string | null>(anchorKey)
|
||||
const selectedKeysRef = useRef(selectedKeys)
|
||||
const onOpenDiffRef = useRef(onOpenDiff)
|
||||
const onOpenFileRef = useRef(onOpenFile)
|
||||
const shouldOpenAsSplitRef = useRef(shouldOpenAsSplit)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,8 +92,8 @@ export function useSourceControlSelection({
|
||||
}, [selectedKeys])
|
||||
|
||||
useEffect(() => {
|
||||
onOpenDiffRef.current = onOpenDiff
|
||||
}, [onOpenDiff])
|
||||
onOpenFileRef.current = onOpenFile
|
||||
}, [onOpenFile])
|
||||
|
||||
useEffect(() => {
|
||||
shouldOpenAsSplitRef.current = shouldOpenAsSplit
|
||||
@@ -118,7 +118,7 @@ export function useSourceControlSelection({
|
||||
if (shouldOpenAsSplitRef.current?.(e)) {
|
||||
setSelectedKeys((prev) => (prev.size > 0 ? new Set() : prev))
|
||||
setAnchorKey(null)
|
||||
onOpenDiffRef.current(entry, e)
|
||||
onOpenFileRef.current(entry, e)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ export function useSourceControlSelection({
|
||||
// stale invisible anchor.
|
||||
setSelectedKeys(new Set())
|
||||
setAnchorKey(key)
|
||||
onOpenDiffRef.current(entry)
|
||||
onOpenFileRef.current(entry)
|
||||
} else if (isCmdOrCtrl) {
|
||||
if (selectedKeysRef.current.has(key)) {
|
||||
setSelectedKeys((prev) => {
|
||||
@@ -163,7 +163,7 @@ export function useSourceControlSelection({
|
||||
return prev
|
||||
})
|
||||
setAnchorKey(key)
|
||||
onOpenDiffRef.current(entry)
|
||||
onOpenFileRef.current(entry)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { DiffComment } from '../../../../../../shared/diff-comment-types'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import { openWorkspaceFile } from '@/lib/workspace-file-open'
|
||||
import {
|
||||
cancelSourceControlEditorRevealFrames,
|
||||
requestSourceControlEditorRevealFrame
|
||||
@@ -25,8 +26,6 @@ export function useSourceControlNoteOpening({
|
||||
entries,
|
||||
branchEntries,
|
||||
branchSummary,
|
||||
handleOpenDiff,
|
||||
openCommittedDiff,
|
||||
sourceControlRef
|
||||
}: {
|
||||
activeWorktreeId: string | null
|
||||
@@ -34,8 +33,6 @@ export function useSourceControlNoteOpening({
|
||||
entries: GitStatusEntry[]
|
||||
branchEntries: GitBranchChangeEntry[]
|
||||
branchSummary: GitBranchCompareSummary | null
|
||||
handleOpenDiff: (entry: GitStatusEntry) => void
|
||||
openCommittedDiff: (entry: GitBranchChangeEntry) => void
|
||||
sourceControlRef: RefObject<HTMLDivElement | null>
|
||||
}): SourceControlNoteOpening {
|
||||
const openFile = useAppStore((s) => s.openFile)
|
||||
@@ -98,7 +95,14 @@ export function useSourceControlNoteOpening({
|
||||
matches.find((e) => e.area === 'untracked') ??
|
||||
matches[0]
|
||||
if (uncommitted) {
|
||||
handleOpenDiff(uncommitted)
|
||||
openWorkspaceFile({
|
||||
kind: 'uncommitted',
|
||||
intent: 'diff',
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
entry: uncommitted,
|
||||
options: { preview: true }
|
||||
})
|
||||
if (commentId) {
|
||||
setScrollToDiffCommentId(commentId)
|
||||
}
|
||||
@@ -106,7 +110,15 @@ export function useSourceControlNoteOpening({
|
||||
}
|
||||
const branchEntry = branchEntries.find((e) => e.path === filePath)
|
||||
if (branchEntry && branchSummary?.status === 'ready') {
|
||||
openCommittedDiff(branchEntry)
|
||||
openWorkspaceFile({
|
||||
kind: 'branch',
|
||||
intent: 'diff',
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
entry: branchEntry,
|
||||
compare: branchSummary,
|
||||
options: { preview: true }
|
||||
})
|
||||
if (commentId) {
|
||||
setScrollToDiffCommentId(commentId)
|
||||
}
|
||||
@@ -132,8 +144,6 @@ export function useSourceControlNoteOpening({
|
||||
branchEntries,
|
||||
branchSummary,
|
||||
entries,
|
||||
handleOpenDiff,
|
||||
openCommittedDiff,
|
||||
openFile,
|
||||
setEditorViewMode,
|
||||
setScrollToDiffCommentId,
|
||||
|
||||
@@ -32,7 +32,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) {
|
||||
handleAbortOperationForConflict,
|
||||
handleCommitAction,
|
||||
handleContextMenu,
|
||||
handleOpenDiff,
|
||||
openUncommittedFile,
|
||||
handleResolveConflictsWithAI,
|
||||
handleSelect,
|
||||
handleStage,
|
||||
@@ -50,7 +50,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) {
|
||||
openAllDiffs,
|
||||
openBranchAllDiffs,
|
||||
openCommitFile,
|
||||
openCommittedDiff,
|
||||
openBranchFile,
|
||||
openConflictReview,
|
||||
openHistoryCommitDiff,
|
||||
refreshActiveGitStatus,
|
||||
@@ -170,7 +170,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) {
|
||||
handleContextMenu={handleContextMenu}
|
||||
revealInExplorer={revealInExplorer}
|
||||
activeConnectionId={activeConnectionId}
|
||||
handleOpenDiff={handleOpenDiff}
|
||||
openUncommittedFile={openUncommittedFile}
|
||||
handleStage={handleStage}
|
||||
handleUnstage={handleUnstage}
|
||||
requestDiscardEntry={requestDiscardEntry}
|
||||
@@ -207,7 +207,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) {
|
||||
worktreePath={worktreePath}
|
||||
revealInExplorer={revealInExplorer}
|
||||
activeConnectionId={activeConnectionId}
|
||||
openCommittedDiff={openCommittedDiff}
|
||||
openBranchFile={openBranchFile}
|
||||
openBranchAllDiffs={openBranchAllDiffs}
|
||||
diffCommentCountByPath={diffCommentCountByPath}
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,6 @@ export function useSourceControlPanelModel() {
|
||||
entries,
|
||||
fetchUpstreamStatus,
|
||||
grouped,
|
||||
handleOpenDiff,
|
||||
handleStageAllPrimary,
|
||||
hostedReview,
|
||||
hostedReviewCreateCopy,
|
||||
@@ -59,7 +58,6 @@ export function useSourceControlPanelModel() {
|
||||
isHostedReviewStateLoading,
|
||||
isRemoteOperationActive,
|
||||
openModal,
|
||||
openCommittedDiff,
|
||||
refreshActiveGitStatusAfterMutation,
|
||||
remoteStatus,
|
||||
remoteStatusForActions,
|
||||
@@ -155,8 +153,6 @@ export function useSourceControlPanelModel() {
|
||||
entries,
|
||||
branchEntries,
|
||||
branchSummary,
|
||||
handleOpenDiff,
|
||||
openCommittedDiff,
|
||||
sourceControlRef
|
||||
})
|
||||
const entryMutations = useSourceControlEntryMutations({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from '@/lib/worktree-runtime-owner'
|
||||
import { useWorkspaceFileBrowserActionPredicate } from '@/lib/file-preview'
|
||||
import { openWorkspaceFile } from '@/lib/workspace-file-open'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
@@ -132,7 +133,6 @@ export function useFileExplorerTreePaneState({
|
||||
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(s, activeWorktreeId)
|
||||
)
|
||||
const toggleDir = useAppStore((s) => s.toggleDir)
|
||||
const openFile = useAppStore((s) => s.openFile)
|
||||
const makePreviewFilePermanent = useAppStore((s) => s.makePreviewFilePermanent)
|
||||
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
|
||||
const closeFile = useAppStore((s) => s.closeFile)
|
||||
@@ -251,7 +251,7 @@ export function useFileExplorerTreePaneState({
|
||||
const handlers = useFileExplorerHandlers({
|
||||
activeWorktreeId,
|
||||
runtimeEnvironmentId: ownerRuntimeEnvironmentId,
|
||||
openFile,
|
||||
openWorkspaceFile,
|
||||
makePreviewFilePermanent,
|
||||
toggleDir: hasNameFilter ? handleToggleNameFilterDir : toggleDir,
|
||||
loadDir,
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('activateFileExplorerNode', () => {
|
||||
await activateFileExplorerNode({
|
||||
node: directoryNode,
|
||||
activeWorktreeId: 'wt-1',
|
||||
openFile: vi.fn(),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
toggleDir,
|
||||
canToggleDirectories: false,
|
||||
loadDir: vi.fn(),
|
||||
@@ -52,12 +52,12 @@ describe('activateFileExplorerNode', () => {
|
||||
const loadDir = vi.fn().mockResolvedValue(true)
|
||||
const markPathAsDirectory = vi.fn()
|
||||
const toggleDir = vi.fn()
|
||||
const openFile = vi.fn()
|
||||
const openWorkspaceFile = vi.fn()
|
||||
|
||||
await activateFileExplorerNode({
|
||||
node: symlinkNode,
|
||||
activeWorktreeId: 'wt-1',
|
||||
openFile,
|
||||
openWorkspaceFile,
|
||||
toggleDir,
|
||||
loadDir,
|
||||
statPath: vi.fn().mockResolvedValue({ isDirectory: true }),
|
||||
@@ -72,17 +72,17 @@ describe('activateFileExplorerNode', () => {
|
||||
})
|
||||
expect(markPathAsDirectory).toHaveBeenCalledWith('/repo/linked-docs')
|
||||
expect(toggleDir).toHaveBeenCalledWith('wt-1', '/repo/linked-docs')
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
expect(openWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not follow a folder link that leads out of the project', async () => {
|
||||
const loadDir = vi.fn()
|
||||
const openFile = vi.fn()
|
||||
const openWorkspaceFile = vi.fn()
|
||||
|
||||
await activateFileExplorerNode({
|
||||
node: { ...symlinkNode, operationOwner: { kind: 'local' } },
|
||||
activeWorktreeId: 'wt-1',
|
||||
openFile,
|
||||
openWorkspaceFile,
|
||||
toggleDir: vi.fn(),
|
||||
loadDir,
|
||||
statPath: vi.fn().mockResolvedValue({ isDirectory: true, escapesWorktree: true }),
|
||||
@@ -91,14 +91,14 @@ describe('activateFileExplorerNode', () => {
|
||||
})
|
||||
|
||||
expect(loadDir).not.toHaveBeenCalled()
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
expect(openWorkspaceFile).not.toHaveBeenCalled()
|
||||
expect(toastError).toHaveBeenCalledWith(
|
||||
"This folder links outside the project, so it can't be opened here."
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a file link that leads out of the project by its absolute path', async () => {
|
||||
const openFile = vi.fn()
|
||||
const openWorkspaceFile = vi.fn()
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' } as never]
|
||||
@@ -109,7 +109,7 @@ describe('activateFileExplorerNode', () => {
|
||||
node: { ...symlinkNode, operationOwner: { kind: 'local' } },
|
||||
activeWorktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: null,
|
||||
openFile,
|
||||
openWorkspaceFile,
|
||||
toggleDir: vi.fn(),
|
||||
loadDir: vi.fn(),
|
||||
statPath: vi.fn().mockResolvedValue({ isDirectory: false, escapesWorktree: true }),
|
||||
@@ -118,18 +118,21 @@ describe('activateFileExplorerNode', () => {
|
||||
})
|
||||
|
||||
// The absolute relativePath marks the tab as user-named, which survives a restart.
|
||||
expect(openFile).toHaveBeenCalledWith(
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/repo/linked-docs',
|
||||
relativePath: '/repo/linked-docs',
|
||||
worktreeId: 'wt-1'
|
||||
}),
|
||||
expect.anything()
|
||||
kind: 'file',
|
||||
file: expect.objectContaining({
|
||||
filePath: '/repo/linked-docs',
|
||||
relativePath: '/repo/linked-docs',
|
||||
worktreeId: 'wt-1'
|
||||
}),
|
||||
options: expect.anything()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a symlink as a file when target stat fails', async () => {
|
||||
const openFile = vi.fn()
|
||||
const openWorkspaceFile = vi.fn()
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
@@ -147,7 +150,7 @@ describe('activateFileExplorerNode', () => {
|
||||
node: symlinkNode,
|
||||
activeWorktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: 'runtime-env-1',
|
||||
openFile,
|
||||
openWorkspaceFile,
|
||||
toggleDir: vi.fn(),
|
||||
loadDir: vi.fn(),
|
||||
statPath: vi.fn().mockRejectedValue(new Error('stat failed')),
|
||||
@@ -155,16 +158,19 @@ describe('activateFileExplorerNode', () => {
|
||||
setSelectedPath: vi.fn()
|
||||
})
|
||||
|
||||
expect(openFile).toHaveBeenCalledWith(
|
||||
{
|
||||
filePath: '/repo/linked-docs',
|
||||
relativePath: 'linked-docs',
|
||||
worktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: 'runtime-env-1',
|
||||
language: expect.any(String),
|
||||
mode: 'edit'
|
||||
},
|
||||
{ preview: true, focusEditor: true, suppressActiveRuntimeFallback: false }
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'file',
|
||||
file: {
|
||||
filePath: '/repo/linked-docs',
|
||||
relativePath: 'linked-docs',
|
||||
worktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: 'runtime-env-1',
|
||||
language: expect.any(String),
|
||||
mode: 'edit'
|
||||
},
|
||||
options: { preview: true, focusEditor: true, suppressActiveRuntimeFallback: false }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -177,7 +183,7 @@ describe('activateFileExplorerNode', () => {
|
||||
depth: 0,
|
||||
operationOwner: { kind: 'local' }
|
||||
}
|
||||
const openFile = vi.fn()
|
||||
const openWorkspaceFile = vi.fn()
|
||||
useAppStore.setState({
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' } as never]
|
||||
@@ -188,7 +194,7 @@ describe('activateFileExplorerNode', () => {
|
||||
node: fileNode,
|
||||
activeWorktreeId: 'wt-1',
|
||||
runtimeEnvironmentId: null,
|
||||
openFile,
|
||||
openWorkspaceFile,
|
||||
toggleDir: vi.fn(),
|
||||
loadDir: vi.fn(),
|
||||
statPath: vi.fn(),
|
||||
@@ -196,12 +202,15 @@ describe('activateFileExplorerNode', () => {
|
||||
setSelectedPath: vi.fn()
|
||||
})
|
||||
|
||||
expect(openFile).toHaveBeenCalledWith(
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/repo/README.md',
|
||||
runtimeEnvironmentId: undefined
|
||||
}),
|
||||
{ preview: true, focusEditor: true, suppressActiveRuntimeFallback: true }
|
||||
kind: 'file',
|
||||
file: expect.objectContaining({
|
||||
filePath: '/repo/README.md',
|
||||
runtimeEnvironmentId: undefined
|
||||
}),
|
||||
options: { preview: true, focusEditor: true, suppressActiveRuntimeFallback: true }
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,25 +12,12 @@ import {
|
||||
requireMatchingFileExplorerOperationRoute
|
||||
} from './file-explorer-operation-owner'
|
||||
import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target'
|
||||
import type { WorkspaceFileOpener } from '@/lib/workspace-file-open'
|
||||
|
||||
type UseFileExplorerHandlersParams = {
|
||||
activeWorktreeId: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
openFile: (
|
||||
params: {
|
||||
filePath: string
|
||||
relativePath: string
|
||||
worktreeId: string
|
||||
language: string
|
||||
mode: 'edit'
|
||||
runtimeEnvironmentId?: string | null
|
||||
},
|
||||
options?: {
|
||||
preview?: boolean
|
||||
suppressActiveRuntimeFallback?: boolean
|
||||
focusEditor?: boolean
|
||||
}
|
||||
) => void
|
||||
openWorkspaceFile: WorkspaceFileOpener
|
||||
makePreviewFilePermanent: (filePath: string) => void
|
||||
toggleDir: (worktreeId: string, dirPath: string) => void
|
||||
canToggleDirectories?: boolean
|
||||
@@ -51,14 +38,11 @@ type UseFileExplorerHandlersReturn = {
|
||||
handleWheelCapture: (e: React.WheelEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
type OpenFileParams = Parameters<UseFileExplorerHandlersParams['openFile']>[0]
|
||||
type OpenFileOptions = Parameters<UseFileExplorerHandlersParams['openFile']>[1]
|
||||
|
||||
export async function activateFileExplorerNode(args: {
|
||||
node: TreeNode
|
||||
activeWorktreeId: string | null
|
||||
runtimeEnvironmentId?: string | null
|
||||
openFile: (params: OpenFileParams, options?: OpenFileOptions) => void
|
||||
openWorkspaceFile: WorkspaceFileOpener
|
||||
toggleDir: (worktreeId: string, dirPath: string) => void
|
||||
canToggleDirectories?: boolean
|
||||
loadDir: UseFileExplorerHandlersParams['loadDir']
|
||||
@@ -69,7 +53,7 @@ export async function activateFileExplorerNode(args: {
|
||||
const {
|
||||
node,
|
||||
activeWorktreeId,
|
||||
openFile,
|
||||
openWorkspaceFile,
|
||||
toggleDir,
|
||||
canToggleDirectories = true,
|
||||
loadDir,
|
||||
@@ -139,8 +123,9 @@ export async function activateFileExplorerNode(args: {
|
||||
toast.error(getFileExplorerOwnerUnresolvedMessage())
|
||||
return
|
||||
}
|
||||
openFile(
|
||||
{
|
||||
openWorkspaceFile({
|
||||
kind: 'file',
|
||||
file: {
|
||||
filePath: node.path,
|
||||
// Why: a file link out of the project opens by its absolute path, as a file the user named,
|
||||
// so it reads the same before and after a restart instead of being refused as a project file.
|
||||
@@ -150,7 +135,7 @@ export async function activateFileExplorerNode(args: {
|
||||
language: detectLanguage(node.name),
|
||||
mode: 'edit'
|
||||
},
|
||||
{
|
||||
options: {
|
||||
preview: true,
|
||||
// Why: activating an Explorer file is a focus handoff even if the rich
|
||||
// editor finishes mounting after the row receives browser focus.
|
||||
@@ -159,13 +144,13 @@ export async function activateFileExplorerNode(args: {
|
||||
// encode "no runtime owner" via the fallback-suppression option.
|
||||
suppressActiveRuntimeFallback: fileRuntimeEnvironmentId === null
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function useFileExplorerHandlers({
|
||||
activeWorktreeId,
|
||||
runtimeEnvironmentId,
|
||||
openFile,
|
||||
openWorkspaceFile,
|
||||
makePreviewFilePermanent,
|
||||
toggleDir,
|
||||
canToggleDirectories = true,
|
||||
@@ -187,7 +172,7 @@ export function useFileExplorerHandlers({
|
||||
node,
|
||||
activeWorktreeId,
|
||||
runtimeEnvironmentId,
|
||||
openFile,
|
||||
openWorkspaceFile,
|
||||
toggleDir,
|
||||
canToggleDirectories,
|
||||
loadDir,
|
||||
@@ -202,7 +187,7 @@ export function useFileExplorerHandlers({
|
||||
canToggleDirectories,
|
||||
loadDir,
|
||||
markPathAsDirectory,
|
||||
openFile,
|
||||
openWorkspaceFile,
|
||||
statPath,
|
||||
toggleDir,
|
||||
setSelectedPath
|
||||
|
||||
@@ -21,8 +21,8 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
|
||||
vi.mock('@/store/selectors', () => ({ getWorktreeOnHostFromState: mocks.getWorktree }))
|
||||
vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: mocks.activate }))
|
||||
vi.mock('@/runtime/runtime-git-status-client', () => ({ getRuntimeGitStatus: mocks.gitStatus }))
|
||||
vi.mock('../right-sidebar/source-control/listing/open-uncommitted-entry', () => ({
|
||||
openUncommittedEntry: mocks.openEntry
|
||||
vi.mock('@/lib/workspace-file-open', () => ({
|
||||
openWorkspaceFile: mocks.openEntry
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.error } }))
|
||||
|
||||
@@ -61,13 +61,14 @@ describe('Space change inspection', () => {
|
||||
providesInitialSurface: true,
|
||||
executionHostId: hostId
|
||||
})
|
||||
expect(mocks.openEntry).toHaveBeenCalledWith(
|
||||
mocks.state,
|
||||
'same-id',
|
||||
'/server/workspace',
|
||||
expect(mocks.openEntry).toHaveBeenCalledWith({
|
||||
kind: 'uncommitted',
|
||||
intent: 'diff',
|
||||
worktreeId: 'same-id',
|
||||
worktreePath: '/server/workspace',
|
||||
entry,
|
||||
{ preview: true, runtimeEnvironmentId }
|
||||
)
|
||||
options: { preview: true, runtimeEnvironmentId }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -88,7 +89,7 @@ describe('Space change inspection', () => {
|
||||
const conflict = { ...entry, conflictKind: 'both_deleted', conflictStatus: 'unresolved' }
|
||||
mocks.gitStatus.mockResolvedValue({ entries: [conflict] })
|
||||
await revealWorkspaceSpaceChange('same-id', 'local', entry)
|
||||
expect(mocks.openEntry.mock.calls[0][3]).toBe(conflict)
|
||||
expect(mocks.openEntry.mock.calls[0][0].entry).toBe(conflict)
|
||||
})
|
||||
|
||||
it('reports a stale change without navigating or opening a file', async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { repoMatchesHostIdentity } from '@/store/slices/repo-host-identity'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { getRuntimeGitStatus } from '@/runtime/runtime-git-status-client'
|
||||
import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target'
|
||||
import { openUncommittedEntry } from '../right-sidebar/source-control/listing/open-uncommitted-entry'
|
||||
import { openWorkspaceFile } from '@/lib/workspace-file-open'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function openWorkspaceSpaceChange(
|
||||
@@ -77,9 +77,16 @@ export async function revealWorkspaceSpaceChange(
|
||||
if (activated === false) {
|
||||
return
|
||||
}
|
||||
openUncommittedEntry(useAppStore.getState(), worktreeId, worktree.path, entry, {
|
||||
preview: true,
|
||||
runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null
|
||||
openWorkspaceFile({
|
||||
kind: 'uncommitted',
|
||||
intent: 'diff',
|
||||
worktreeId,
|
||||
worktreePath: worktree.path,
|
||||
entry,
|
||||
options: {
|
||||
preview: true,
|
||||
runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(translate('workspaceSpace.changes.openFailed', 'Could not open changes'), {
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { toWorktreeRelativePath } from '@/lib/terminal-links'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import {
|
||||
validateNewTabEntryAbsolutePath,
|
||||
type TabEntryLocalPlatform
|
||||
} from './tab-create-entry-path-validation'
|
||||
import type { statUserOpenedPath, UserOpenedPathStat } from '@/lib/user-opened-local-path'
|
||||
import type { openWorkspaceFile } from '@/lib/workspace-file-open'
|
||||
|
||||
type AbsoluteFileOperations = {
|
||||
assertAbsolutePathAllowed: () => void
|
||||
openFile: (
|
||||
file: Omit<OpenFile, 'id' | 'isDirty'>,
|
||||
options?: { preview?: boolean; targetGroupId?: string }
|
||||
) => void
|
||||
openWorkspaceFile: typeof openWorkspaceFile
|
||||
statUserOpenedPath: typeof statUserOpenedPath
|
||||
}
|
||||
|
||||
@@ -39,8 +36,9 @@ export async function openAbsoluteTabEntryFile(args: {
|
||||
}
|
||||
args.operations.assertAbsolutePathAllowed()
|
||||
|
||||
args.operations.openFile(
|
||||
{
|
||||
args.operations.openWorkspaceFile({
|
||||
kind: 'file',
|
||||
file: {
|
||||
filePath,
|
||||
// Why: a project link out of the project keeps its absolute path, so it reads as user-named.
|
||||
relativePath: stat.escapesWorktree
|
||||
@@ -50,6 +48,6 @@ export async function openAbsoluteTabEntryFile(args: {
|
||||
language: detectLanguage(filePath),
|
||||
mode: 'edit'
|
||||
},
|
||||
{ preview: false, targetGroupId: args.groupId }
|
||||
)
|
||||
options: { preview: false, targetGroupId: args.groupId }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ describe('openTabEntryWithOperations', () => {
|
||||
return {
|
||||
createRuntimePath: vi.fn().mockResolvedValue(undefined),
|
||||
openWorkspaceBrowserTab: vi.fn().mockResolvedValue(undefined),
|
||||
openHtmlFilePreview: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
statRuntimePath: vi.fn().mockResolvedValue({ size: 1, isDirectory: false, mtime: 1 }),
|
||||
statUserOpenedPath: vi.fn().mockResolvedValue({ isDirectory: false, escapesWorktree: false }),
|
||||
assertAbsolutePathAllowed: vi.fn(),
|
||||
@@ -45,12 +44,50 @@ describe('openTabEntryWithOperations', () => {
|
||||
fileList: readyFiles(['src/index.ts']),
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1',
|
||||
getFileContext: () => ({ worktreePath: '/repo', runtimeContext }),
|
||||
getFileContext: () => ({ worktreePath: '/repo', runtimeContext, assertCurrent: () => {} }),
|
||||
allowAbsolutePaths: true,
|
||||
localPlatform: 'posix' as const,
|
||||
searchEngine: 'google' as const
|
||||
}
|
||||
|
||||
it.each(['report.html', '/repo/report.html'])(
|
||||
'requests the default file view for an existing HTML file: %s',
|
||||
async (query) => {
|
||||
const operations = makeOperations()
|
||||
await openTabEntryWithOperations({
|
||||
...baseArgs,
|
||||
fileList: readyFiles(['report.html']),
|
||||
query,
|
||||
operations
|
||||
})
|
||||
expect(operations.openWorkspaceFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'file',
|
||||
file: expect.objectContaining({ filePath: '/repo/report.html' }),
|
||||
options: { preview: false, targetGroupId: 'group-1' }
|
||||
})
|
||||
)
|
||||
expect(operations.createRuntimePath).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('requests source for newly created HTML so it can be edited', async () => {
|
||||
const operations = makeOperations()
|
||||
await openTabEntryWithOperations({ ...baseArgs, query: 'new.html', operations })
|
||||
expect(operations.openWorkspaceFile).toHaveBeenCalledWith({
|
||||
kind: 'file',
|
||||
intent: 'source',
|
||||
file: {
|
||||
filePath: '/repo/new.html',
|
||||
relativePath: 'new.html',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'html',
|
||||
mode: 'edit'
|
||||
},
|
||||
options: { preview: false, targetGroupId: 'group-1' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'previews HTML file URLs without creating files (local paths: %s)',
|
||||
async (allowAbsolutePaths) => {
|
||||
@@ -64,7 +101,8 @@ describe('openTabEntryWithOperations', () => {
|
||||
query: 'file:///repo/docs/report%20%E4%B8%AD.html',
|
||||
operations
|
||||
})
|
||||
expect(operations.openHtmlFilePreview).toHaveBeenCalledWith({
|
||||
expect(operations.openWorkspaceFile).toHaveBeenCalledWith({
|
||||
kind: 'preview',
|
||||
filePath: '/repo/docs/report 中.html',
|
||||
worktreeId: 'wt-1',
|
||||
targetGroupId: 'group-1'
|
||||
@@ -83,7 +121,7 @@ describe('openTabEntryWithOperations', () => {
|
||||
await expect(
|
||||
openTabEntryWithOperations({ ...baseArgs, query: 'src/index.ts', operations })
|
||||
).rejects.toThrow('Cannot open a directory')
|
||||
expect(operations.openFile).not.toHaveBeenCalled()
|
||||
expect(operations.openWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates new files and opens them in the target group', async () => {
|
||||
@@ -101,13 +139,16 @@ describe('openTabEntryWithOperations', () => {
|
||||
'/repo/docs/new.md',
|
||||
'file'
|
||||
)
|
||||
expect(operations.openFile).toHaveBeenCalledWith(
|
||||
expect(operations.openWorkspaceFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/repo/docs/new.md',
|
||||
relativePath: 'docs/new.md',
|
||||
worktreeId: 'wt-1'
|
||||
}),
|
||||
{ preview: false, targetGroupId: 'group-1' }
|
||||
kind: 'file',
|
||||
file: expect.objectContaining({
|
||||
filePath: '/repo/docs/new.md',
|
||||
relativePath: 'docs/new.md',
|
||||
worktreeId: 'wt-1'
|
||||
}),
|
||||
options: { preview: false, targetGroupId: 'group-1' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -127,9 +168,12 @@ describe('openTabEntryWithOperations', () => {
|
||||
})
|
||||
|
||||
expect(operations.createRuntimePath).not.toHaveBeenCalled()
|
||||
expect(operations.openFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ relativePath: 'README.md' }),
|
||||
{ preview: false, targetGroupId: 'group-1' }
|
||||
expect(operations.openWorkspaceFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'file',
|
||||
file: expect.objectContaining({ relativePath: 'README.md' }),
|
||||
options: { preview: false, targetGroupId: 'group-1' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -160,12 +204,15 @@ describe('openTabEntryWithOperations', () => {
|
||||
'/repo/.tmp/direct-entry-validation/created.md',
|
||||
'file'
|
||||
)
|
||||
expect(operations.openFile).toHaveBeenCalledWith(
|
||||
expect(operations.openWorkspaceFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/repo/.tmp/direct-entry-validation/created.md',
|
||||
relativePath: '.tmp/direct-entry-validation/created.md'
|
||||
}),
|
||||
{ preview: false, targetGroupId: 'group-1' }
|
||||
kind: 'file',
|
||||
file: expect.objectContaining({
|
||||
filePath: '/repo/.tmp/direct-entry-validation/created.md',
|
||||
relativePath: '.tmp/direct-entry-validation/created.md'
|
||||
}),
|
||||
options: { preview: false, targetGroupId: 'group-1' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -189,7 +236,7 @@ describe('openTabEntryWithOperations', () => {
|
||||
'/repo/docs/new.md',
|
||||
'file'
|
||||
)
|
||||
expect(operations.openFile).toHaveBeenCalled()
|
||||
expect(operations.openWorkspaceFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid new file paths before creating parent directories', async () => {
|
||||
@@ -200,7 +247,7 @@ describe('openTabEntryWithOperations', () => {
|
||||
).rejects.toThrow('File paths cannot contain . or .. segments.')
|
||||
|
||||
expect(operations.createRuntimePath).not.toHaveBeenCalled()
|
||||
expect(operations.openFile).not.toHaveBeenCalled()
|
||||
expect(operations.openWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stats and opens when create loses an EEXIST race to a file', async () => {
|
||||
@@ -214,7 +261,7 @@ describe('openTabEntryWithOperations', () => {
|
||||
await openTabEntryWithOperations({ ...baseArgs, query: 'docs/race.md', operations })
|
||||
|
||||
expect(operations.statRuntimePath).toHaveBeenCalledWith(runtimeContext, '/repo/docs/race.md')
|
||||
expect(operations.openFile).toHaveBeenCalled()
|
||||
expect(operations.openWorkspaceFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes URL classifications through the workspace browser opener', async () => {
|
||||
@@ -302,13 +349,16 @@ describe('openTabEntryWithOperations', () => {
|
||||
})
|
||||
|
||||
expect(operations.statUserOpenedPath).toHaveBeenCalledWith(runtimeContext, '/tmp/notes.md')
|
||||
expect(operations.openFile).toHaveBeenCalledWith(
|
||||
expect(operations.openWorkspaceFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/tmp/notes.md',
|
||||
relativePath: '/tmp/notes.md',
|
||||
worktreeId: 'wt-1'
|
||||
}),
|
||||
{ preview: false, targetGroupId: 'group-1' }
|
||||
kind: 'file',
|
||||
file: expect.objectContaining({
|
||||
filePath: '/tmp/notes.md',
|
||||
relativePath: '/tmp/notes.md',
|
||||
worktreeId: 'wt-1'
|
||||
}),
|
||||
options: { preview: false, targetGroupId: 'group-1' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -322,12 +372,15 @@ describe('openTabEntryWithOperations', () => {
|
||||
operations
|
||||
})
|
||||
|
||||
expect(operations.openFile).toHaveBeenCalledWith(
|
||||
expect(operations.openWorkspaceFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/repo/src/index.ts',
|
||||
relativePath: 'src/index.ts'
|
||||
}),
|
||||
{ preview: false, targetGroupId: 'group-1' }
|
||||
kind: 'file',
|
||||
file: expect.objectContaining({
|
||||
filePath: '/repo/src/index.ts',
|
||||
relativePath: 'src/index.ts'
|
||||
}),
|
||||
options: { preview: false, targetGroupId: 'group-1' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -343,12 +396,15 @@ describe('openTabEntryWithOperations', () => {
|
||||
operations
|
||||
})
|
||||
|
||||
expect(operations.openFile).toHaveBeenCalledWith(
|
||||
expect(operations.openWorkspaceFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/repo/docs-link/secret.md',
|
||||
relativePath: '/repo/docs-link/secret.md'
|
||||
}),
|
||||
{ preview: false, targetGroupId: 'group-1' }
|
||||
kind: 'file',
|
||||
file: expect.objectContaining({
|
||||
filePath: '/repo/docs-link/secret.md',
|
||||
relativePath: '/repo/docs-link/secret.md'
|
||||
}),
|
||||
options: { preview: false, targetGroupId: 'group-1' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -367,7 +423,7 @@ describe('openTabEntryWithOperations', () => {
|
||||
|
||||
expect(operations.statUserOpenedPath).not.toHaveBeenCalled()
|
||||
expect(operations.createRuntimePath).not.toHaveBeenCalled()
|
||||
expect(operations.openFile).not.toHaveBeenCalled()
|
||||
expect(operations.openWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects Windows path syntax before touching a native POSIX path', async () => {
|
||||
@@ -383,7 +439,7 @@ describe('openTabEntryWithOperations', () => {
|
||||
).rejects.toThrow('Enter an absolute path for this computer.')
|
||||
|
||||
expect(operations.statUserOpenedPath).not.toHaveBeenCalled()
|
||||
expect(operations.openFile).not.toHaveBeenCalled()
|
||||
expect(operations.openWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes and opens Windows drive paths on Windows', async () => {
|
||||
@@ -392,7 +448,11 @@ describe('openTabEntryWithOperations', () => {
|
||||
await openTabEntryWithOperations({
|
||||
...baseArgs,
|
||||
localPlatform: 'windows',
|
||||
getFileContext: () => ({ worktreePath: 'C:/repo', runtimeContext }),
|
||||
getFileContext: () => ({
|
||||
worktreePath: 'C:/repo',
|
||||
runtimeContext,
|
||||
assertCurrent: () => {}
|
||||
}),
|
||||
classification: { kind: 'absolute-file', filePath: 'C:\\tmp\\notes.md' },
|
||||
query: 'C:\\tmp\\notes.md',
|
||||
operations
|
||||
@@ -432,6 +492,6 @@ describe('openTabEntryWithOperations', () => {
|
||||
releaseStat?.()
|
||||
await rejection
|
||||
|
||||
expect(operations.openFile).not.toHaveBeenCalled()
|
||||
expect(operations.openWorkspaceFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createRuntimePath } from '@/runtime/runtime-file-mutation-client'
|
||||
import { statRuntimePath } from '@/runtime/runtime-file-metadata-client'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import {
|
||||
buildSearchUrl,
|
||||
DEFAULT_SEARCH_ENGINE,
|
||||
@@ -47,7 +46,11 @@ export {
|
||||
isTabEntryAbsolutePathAllowed
|
||||
} from './tab-create-entry-local-path'
|
||||
import { statUserOpenedPath } from '@/lib/user-opened-local-path'
|
||||
import { openHtmlFilePreview } from '@/lib/open-html-file-preview'
|
||||
import { openWorkspaceFile } from '@/lib/workspace-file-open'
|
||||
import {
|
||||
assertEditorFileOperationCurrent,
|
||||
captureEditorFileOperationProvenance
|
||||
} from '@/lib/editor-file-operation-owner'
|
||||
|
||||
export type TabCreateEntryArgs = {
|
||||
classification?: TabEntryActionClassification
|
||||
@@ -60,11 +63,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 }
|
||||
) => void
|
||||
openWorkspaceFile: typeof openWorkspaceFile
|
||||
statRuntimePath: typeof statRuntimePath
|
||||
statUserOpenedPath: typeof statUserOpenedPath
|
||||
assertAbsolutePathAllowed: () => void
|
||||
@@ -75,7 +74,11 @@ type OpenTabEntryWithOperationsArgs = {
|
||||
fileList: RuntimeFileListState
|
||||
worktreeId: string
|
||||
groupId: string
|
||||
getFileContext: () => { worktreePath: string; runtimeContext: RuntimeFileOperationArgs }
|
||||
getFileContext: () => {
|
||||
worktreePath: string
|
||||
runtimeContext: RuntimeFileOperationArgs
|
||||
assertCurrent: () => void
|
||||
}
|
||||
allowAbsolutePaths: boolean
|
||||
localPlatform: TabEntryLocalPlatform
|
||||
searchEngine: SearchEngine
|
||||
@@ -116,49 +119,15 @@ async function createParentDirectoriesForNewFile(args: {
|
||||
}
|
||||
}
|
||||
|
||||
async function openExistingFile(args: {
|
||||
context: RuntimeFileOperationArgs
|
||||
groupId: string
|
||||
operations: TabEntryOperations
|
||||
relativePath: string
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
}): Promise<void> {
|
||||
const filePath = joinPath(args.worktreePath, args.relativePath)
|
||||
let stat: Awaited<ReturnType<typeof statRuntimePath>>
|
||||
try {
|
||||
stat = await args.operations.statRuntimePath(args.context, filePath)
|
||||
} catch {
|
||||
throw new Error(`File no longer exists: ${args.relativePath}`)
|
||||
}
|
||||
if (stat.isDirectory) {
|
||||
throw new Error(`Cannot open a directory: ${args.relativePath}`)
|
||||
}
|
||||
args.operations.openFile(
|
||||
{
|
||||
filePath,
|
||||
relativePath: args.relativePath,
|
||||
worktreeId: args.worktreeId,
|
||||
language: detectLanguage(args.relativePath),
|
||||
mode: 'edit'
|
||||
},
|
||||
{ preview: false, targetGroupId: args.groupId }
|
||||
)
|
||||
}
|
||||
|
||||
type NetworkTabEntryClassification = Extract<
|
||||
TabEntryActionClassification,
|
||||
{ kind: 'explicit-url' | 'host-url' | 'search' }
|
||||
>
|
||||
|
||||
function isNetworkTabEntry(
|
||||
classification: TabEntryActionClassification
|
||||
): classification is NetworkTabEntryClassification {
|
||||
return (
|
||||
classification.kind === 'explicit-url' ||
|
||||
classification.kind === 'host-url' ||
|
||||
classification.kind === 'search'
|
||||
)
|
||||
entry: TabEntryActionClassification
|
||||
): entry is NetworkTabEntryClassification {
|
||||
return entry.kind === 'explicit-url' || entry.kind === 'host-url' || entry.kind === 'search'
|
||||
}
|
||||
|
||||
function getNetworkTabRequest(
|
||||
@@ -198,7 +167,8 @@ export async function openTabEntryWithOperations({
|
||||
}
|
||||
|
||||
if (classification.kind === 'html-file-url') {
|
||||
operations.openHtmlFilePreview({
|
||||
operations.openWorkspaceFile({
|
||||
kind: 'preview',
|
||||
filePath: classification.filePath,
|
||||
worktreeId,
|
||||
targetGroupId: groupId
|
||||
@@ -213,7 +183,7 @@ export async function openTabEntryWithOperations({
|
||||
return
|
||||
}
|
||||
|
||||
const { worktreePath, runtimeContext } = getFileContext()
|
||||
const { worktreePath, runtimeContext, assertCurrent } = getFileContext()
|
||||
|
||||
if (classification.kind === 'absolute-file') {
|
||||
if (!allowAbsolutePaths) {
|
||||
@@ -231,39 +201,43 @@ export async function openTabEntryWithOperations({
|
||||
return
|
||||
}
|
||||
|
||||
if (classification.kind === 'existing-file') {
|
||||
await openExistingFile({
|
||||
context: runtimeContext,
|
||||
groupId,
|
||||
operations,
|
||||
relativePath: classification.relativePath,
|
||||
worktreeId,
|
||||
worktreePath
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = joinPath(worktreePath, classification.relativePath)
|
||||
try {
|
||||
await createParentDirectoriesForNewFile({
|
||||
context: runtimeContext,
|
||||
operations,
|
||||
relativePath: classification.relativePath,
|
||||
worktreePath
|
||||
})
|
||||
await operations.createRuntimePath(runtimeContext, filePath, 'file')
|
||||
} catch (error) {
|
||||
if (!isExistsError(error)) {
|
||||
throw error
|
||||
if (classification.kind === 'new-file') {
|
||||
try {
|
||||
await createParentDirectoriesForNewFile({
|
||||
context: runtimeContext,
|
||||
operations,
|
||||
relativePath: classification.relativePath,
|
||||
worktreePath
|
||||
})
|
||||
await operations.createRuntimePath(runtimeContext, filePath, 'file')
|
||||
} catch (error) {
|
||||
if (!isExistsError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
await openExistingFile({
|
||||
context: runtimeContext,
|
||||
groupId,
|
||||
operations,
|
||||
relativePath: classification.relativePath,
|
||||
worktreeId,
|
||||
worktreePath
|
||||
let stat: Awaited<ReturnType<typeof statRuntimePath>>
|
||||
try {
|
||||
stat = await operations.statRuntimePath(runtimeContext, filePath)
|
||||
} catch {
|
||||
throw new Error(`File no longer exists: ${classification.relativePath}`)
|
||||
}
|
||||
assertCurrent()
|
||||
if (stat.isDirectory) {
|
||||
throw new Error(`Cannot open a directory: ${classification.relativePath}`)
|
||||
}
|
||||
operations.openWorkspaceFile({
|
||||
kind: 'file',
|
||||
intent: classification.kind === 'new-file' ? 'source' : 'default',
|
||||
file: {
|
||||
filePath,
|
||||
relativePath: classification.relativePath,
|
||||
worktreeId,
|
||||
language: detectLanguage(classification.relativePath),
|
||||
mode: 'edit'
|
||||
},
|
||||
options: { preview: false, targetGroupId: groupId }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -282,9 +256,18 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise<void> {
|
||||
if (!worktree) {
|
||||
throw new Error('No active worktree.')
|
||||
}
|
||||
const provenance = captureEditorFileOperationProvenance(
|
||||
state,
|
||||
args.worktreeId,
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
return {
|
||||
worktreePath: worktree.path,
|
||||
runtimeContext: getTabEntryFileOperationContext(state, args.worktreeId, worktree.path)
|
||||
runtimeContext: getTabEntryFileOperationContext(state, args.worktreeId, worktree.path),
|
||||
assertCurrent: () => {
|
||||
assertEditorFileOperationCurrent(useAppStore.getState(), args.worktreeId, provenance)
|
||||
}
|
||||
}
|
||||
},
|
||||
allowAbsolutePaths,
|
||||
@@ -295,8 +278,7 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise<void> {
|
||||
operations: {
|
||||
createRuntimePath,
|
||||
openWorkspaceBrowserTab,
|
||||
openHtmlFilePreview,
|
||||
openFile: state.openFile,
|
||||
openWorkspaceFile,
|
||||
statRuntimePath,
|
||||
statUserOpenedPath,
|
||||
assertAbsolutePathAllowed: () => {
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { ProjectGroup } from '../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { useAppStore } from '@/store'
|
||||
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import * as metadata from '@/runtime/runtime-file-metadata-client'
|
||||
import { openTabBarEntry } from './tab-create-entry-action'
|
||||
import {
|
||||
createTabEntryAllowAbsolutePathsSelector,
|
||||
getTabEntryAllowAbsolutePaths,
|
||||
@@ -60,9 +63,72 @@ function makeProjectGroup(overrides: Partial<ProjectGroup> = {}): ProjectGroup {
|
||||
|
||||
describe('getTabEntryAllowAbsolutePaths', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
it('refuses an HTML open if its workspace changes hosts during the file check', async () => {
|
||||
const worktree = makeWorktree({
|
||||
id: localWorktreeId,
|
||||
repoId: 'repo-local',
|
||||
path: '/Users/me/repo',
|
||||
hostId: 'local'
|
||||
})
|
||||
useAppStore.setState({
|
||||
repos: [makeRepo({ id: 'repo-local', executionHostId: 'local' })],
|
||||
worktreesByRepo: { 'repo-local': [worktree] },
|
||||
runtimeEnvironmentCatalogHydrated: true
|
||||
})
|
||||
vi.spyOn(metadata, 'statRuntimePath').mockImplementationOnce(async () => {
|
||||
useAppStore.setState({
|
||||
repos: [makeRepo({ id: 'repo-local', executionHostId: 'runtime:hub-a' })],
|
||||
worktreesByRepo: {
|
||||
'repo-local': [
|
||||
{ ...worktree, hostId: 'runtime:hub-a', runtimeOwnerEnvironmentId: 'hub-a' }
|
||||
]
|
||||
}
|
||||
})
|
||||
return { size: 1, isDirectory: false, mtime: 1 }
|
||||
})
|
||||
|
||||
await expect(
|
||||
openTabBarEntry({
|
||||
query: 'report.html',
|
||||
worktreeId: localWorktreeId,
|
||||
groupId: 'group-local',
|
||||
fileList: { files: ['report.html'], loading: false, loadError: null }
|
||||
})
|
||||
).rejects.toThrow("Couldn't verify which host owns this file")
|
||||
expect(useAppStore.getState().browserTabsByWorktree[localWorktreeId] ?? []).toHaveLength(0)
|
||||
expect(useAppStore.getState().openFiles).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('previews an existing HTML file in a local folder workspace', async () => {
|
||||
const folder = makeFolderWorkspace()
|
||||
const worktreeId = folderWorkspaceKey(folder.id)
|
||||
useAppStore.setState({
|
||||
folderWorkspaces: [folder],
|
||||
projectGroups: [makeProjectGroup()],
|
||||
repos: []
|
||||
})
|
||||
vi.spyOn(metadata, 'statRuntimePath').mockResolvedValue({
|
||||
size: 1,
|
||||
isDirectory: false,
|
||||
mtime: 1
|
||||
})
|
||||
|
||||
await openTabBarEntry({
|
||||
query: 'report.html',
|
||||
worktreeId,
|
||||
groupId: 'group-local',
|
||||
fileList: { files: ['report.html'], loading: false, loadError: null }
|
||||
})
|
||||
expect(useAppStore.getState().browserTabsByWorktree[worktreeId]).toEqual([
|
||||
expect.objectContaining({ url: 'file:///Users/me/folder/report.html' })
|
||||
])
|
||||
expect(useAppStore.getState().openFiles).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('allows absolute paths for a known local worktree', () => {
|
||||
useAppStore.setState({
|
||||
repos: [makeRepo({ id: 'repo-local' })],
|
||||
|
||||
@@ -2,7 +2,7 @@ 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/workspace-file-open', () => ({ openWorkspaceFile: preview }))
|
||||
vi.mock('@/lib/editor-file-operation-owner', () => ({
|
||||
getEditorFileOperationContext: () => {
|
||||
throw new Error('A preview must not require file mutation authorization')
|
||||
@@ -29,6 +29,7 @@ it.each([true, false])(
|
||||
})
|
||||
expect(preview).toHaveBeenCalledTimes(1)
|
||||
expect(preview).toHaveBeenCalledWith({
|
||||
kind: 'preview',
|
||||
filePath: '/repo/report.html',
|
||||
worktreeId: 'remote',
|
||||
targetGroupId: 'group-2'
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links'
|
||||
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
|
||||
import { openWorkspaceFile } from '@/lib/workspace-file-open'
|
||||
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
|
||||
import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route'
|
||||
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
|
||||
import { canClientOsOpenWorkspaceFile } from '@/lib/workspace-file-host-routing'
|
||||
import { isMissingRuntimePathError } from '@/runtime/runtime-file-metadata-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
|
||||
import {
|
||||
getTerminalFileContext,
|
||||
@@ -29,7 +28,7 @@ import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-wor
|
||||
import { captureEditorOpenLinkIntent } from '@/store/slices/editor/tabs/editor-open-target-group'
|
||||
import { cancelPendingEditorLineRevealFrames } from '@/store/slices/editor/focus/editor-focus-reveal'
|
||||
import { showOpenInGroupRefusedToast } from './terminal-file-open-in-group-toast'
|
||||
import { isExplicitEditorScopeKept, openLinkedFileInEditor } from './terminal-file-link-editor-open'
|
||||
import { isExplicitEditorScopeKept } from '@/lib/editor-file-link-open'
|
||||
|
||||
export {
|
||||
getTerminalFileContext,
|
||||
@@ -62,28 +61,6 @@ type TerminalFileOpenDeps = {
|
||||
openInSourceGroup?: boolean
|
||||
}
|
||||
|
||||
export function isHtmlFilePath(filePath: string): boolean {
|
||||
return /\.html?$/i.test(filePath)
|
||||
}
|
||||
|
||||
function openHtmlFileInBrowser(filePath: string, worktreeId: string, clientLocal: boolean): void {
|
||||
const store = useAppStore.getState()
|
||||
if (worktreeId) {
|
||||
// Why: following an HTML file link changes which worktree is foregrounded,
|
||||
// so it must record a history visit before opening the browser tab — but the
|
||||
// browser tab is the surface, so an emptied workspace must not gain a shell.
|
||||
activateAndRevealWorktree(worktreeId, { providesInitialSurface: true })
|
||||
}
|
||||
const fileUrl = absolutePathToFileUri(filePath)
|
||||
const title = filePath.split(/[/\\]/).pop() ?? filePath
|
||||
store.createBrowserTab(worktreeId, fileUrl, {
|
||||
title,
|
||||
activate: true,
|
||||
// Why: the file is on this computer, so the workspace's paired-server runtime must not adopt the page.
|
||||
...(clientLocal ? { browserRuntimeEnvironmentId: null } : {})
|
||||
})
|
||||
}
|
||||
|
||||
export function shouldOpenTerminalFileWithSystemDefault(fileContext: TerminalFileContext): boolean {
|
||||
// Why: an unresolved owner has no connectionId either, which would otherwise read as local.
|
||||
return fileContext.sourceHostResolved && canClientOsOpenWorkspaceFile(fileContext)
|
||||
@@ -236,30 +213,6 @@ export function openDetectedFilePath(
|
||||
return
|
||||
}
|
||||
|
||||
// Why: local HTML files render in Orca's browser for ordinary Cmd/Ctrl-click,
|
||||
// and remain the fallback if Shift+Cmd/Ctrl cannot launch the OS default.
|
||||
if (isHtmlFilePath(mappedFilePath) && hostGrantId === null) {
|
||||
if (canOpenWithSystemDefault) {
|
||||
// Why: Orca's browser has no group placement in v1.
|
||||
if (refuseExplicitNonEditorRoute()) {
|
||||
return
|
||||
}
|
||||
openHtmlFileInBrowser(mappedFilePath, worktreeId, clientLocal)
|
||||
return
|
||||
}
|
||||
// Why: the same gesture renders remote HTML too, through the doc preview; only an
|
||||
// unsupported plan (e.g. a paired doc outside the worktree) falls back to source.
|
||||
const plan = getWorkspaceFilePreviewPlan(useAppStore.getState(), worktreeId, mappedFilePath)
|
||||
if (plan.status === 'doc-preview') {
|
||||
if (refuseExplicitNonEditorRoute()) {
|
||||
return
|
||||
}
|
||||
activateAndRevealWorktree(worktreeId, { providesInitialSurface: true })
|
||||
openFileInBrowserTab({ filePath: mappedFilePath, worktreeId })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const store = useAppStore.getState()
|
||||
let targetWorktreeId = worktreeId
|
||||
const runtimeOwnerId = runtimeTargetEnvironmentId(fileContext.target) ?? sourceRuntimeOwnerId
|
||||
@@ -301,17 +254,11 @@ export function openDetectedFilePath(
|
||||
return
|
||||
}
|
||||
|
||||
if (targetWorktreeId) {
|
||||
// Why: the route may name a folder-workspace key, and the same worktree id can exist
|
||||
// on several hosts — dispatch by workspace shape and keep the resolved host.
|
||||
activateAndRevealWorkspace(targetWorktreeId, {
|
||||
providesInitialSurface: true,
|
||||
executionHostId: targetExecutionHostId
|
||||
})
|
||||
}
|
||||
|
||||
const opened = openLinkedFileInEditor(
|
||||
{
|
||||
const opened = openWorkspaceFile({
|
||||
kind: 'link',
|
||||
executionHostId: targetExecutionHostId,
|
||||
clientLocal,
|
||||
file: {
|
||||
filePath: mappedFilePath,
|
||||
relativePath,
|
||||
worktreeId: targetWorktreeId || '',
|
||||
@@ -329,10 +276,10 @@ export function openDetectedFilePath(
|
||||
: {})
|
||||
},
|
||||
linkIntent,
|
||||
{ line, column },
|
||||
() => requestId === latestOpenDetectedFilePathRequestId
|
||||
)
|
||||
if (!opened) {
|
||||
position: { line, column },
|
||||
isCurrentRequest: () => requestId === latestOpenDetectedFilePathRequestId
|
||||
})
|
||||
if (opened.surface === 'refused') {
|
||||
refuseExplicitNonEditorRoute()
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
|
||||
import { openDetectedFilePath } from './terminal-link-handlers'
|
||||
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
|
||||
import {
|
||||
@@ -67,8 +67,9 @@ describe('handleOscLink', () => {
|
||||
expect(openFilePathMock).not.toHaveBeenCalled()
|
||||
// Why: the browser tab is the surface — activation must not re-seed a shell into a
|
||||
// workspace whose last terminal the user closed.
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
|
||||
providesInitialSurface: true
|
||||
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {
|
||||
providesInitialSurface: true,
|
||||
executionHostId: 'local'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,6 +89,20 @@ describe('handleOscLink', () => {
|
||||
expect(openFilePathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens an HTML line link in source and reveals the requested line', async () => {
|
||||
openDetectedFilePath('/tmp/report.html', 12, 3, deps)
|
||||
await flushAsyncWork()
|
||||
await flushDoubleRaf()
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(openFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: '/tmp/report.html', mode: 'edit' }),
|
||||
{ forceContentReload: true }
|
||||
)
|
||||
expect(setPendingEditorRevealMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ line: 12, column: 3, filePath: '/tmp/report.html' })
|
||||
)
|
||||
})
|
||||
|
||||
it('opens local file paths in Orca and reveals default column 1 with the platform modifier', async () => {
|
||||
setPlatform('Macintosh')
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { vi, type Mock } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { Tab, TabGroup } from '../../../../shared/tab-types'
|
||||
import type { BrowserPage } from '../../../../shared/browser-workspace-types'
|
||||
|
||||
export type TerminalLinkStoreSettings = {
|
||||
openLinksInApp?: boolean
|
||||
@@ -35,6 +36,17 @@ export type TerminalLinkStoreState = {
|
||||
}[]
|
||||
>
|
||||
folderWorkspaces: []
|
||||
repos: {
|
||||
id: string
|
||||
displayName: string
|
||||
connectionId?: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
}[]
|
||||
browserTabsByWorktree: Record<string, { id: string }[]>
|
||||
browserPagesByWorkspace: Record<string, BrowserPage[]>
|
||||
getKnownWorktreeById: (
|
||||
id: string
|
||||
) => TerminalLinkStoreState['worktreesByRepo'][string][number] | undefined
|
||||
}
|
||||
|
||||
export type TerminalLinkTestDoubles = {
|
||||
@@ -109,7 +121,14 @@ export function createTerminalLinkTestDoubles(): TerminalLinkTestDoubles {
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
worktreesByRepo: {},
|
||||
folderWorkspaces: [] as []
|
||||
folderWorkspaces: [],
|
||||
repos: [],
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
getKnownWorktreeById: (id) =>
|
||||
Object.values(storeState.worktreesByRepo)
|
||||
.flat()
|
||||
.find((worktree) => worktree.id === id)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -60,7 +60,14 @@ export function installTerminalLinkTestEnvironment(doubles: TerminalLinkTestDoub
|
||||
storeState.openFiles = []
|
||||
storeState.unifiedTabsByWorktree = {}
|
||||
storeState.groupsByWorktree = {}
|
||||
storeState.worktreesByRepo = {}
|
||||
storeState.worktreesByRepo = {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/tmp', hostId: 'local' }]
|
||||
}
|
||||
storeState.repos = [
|
||||
{ id: 'repo-1', displayName: 'Repo', connectionId: null, executionHostId: 'local' }
|
||||
]
|
||||
storeState.browserTabsByWorktree = {}
|
||||
storeState.browserPagesByWorkspace = {}
|
||||
registerHttpLinkStoreAccessor(() => storeState)
|
||||
vi.stubGlobal('window', {
|
||||
dispatchEvent: vi.fn(),
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
dedupeLogicalLines,
|
||||
openFilePathLinkAtBufferPosition
|
||||
} from './terminal-file-link-hit-testing'
|
||||
import {
|
||||
isHtmlFilePath,
|
||||
shouldOpenTerminalFileWithSystemDefault
|
||||
} from './terminal-file-open-routing'
|
||||
import { isHtmlFilePath } from '../../../../shared/html-file-url'
|
||||
import { shouldOpenTerminalFileWithSystemDefault } from './terminal-file-open-routing'
|
||||
import { fileLinkTargetExists, resolveFileLinkTarget } from './terminal-file-link-target'
|
||||
import {
|
||||
buildHardWrappedPathLogicalLineCandidates,
|
||||
|
||||
@@ -32,10 +32,6 @@ vi.mock('@/store', () => ({
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: toastErrorMock } }))
|
||||
|
||||
vi.mock('@/lib/language-detect', () => ({
|
||||
detectLanguage: () => 'plaintext'
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorkspace: vi.fn(),
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
@@ -110,7 +106,7 @@ describe('terminal file links carry their source tab', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses an explicit request that resolves to Orca browser HTML', async () => {
|
||||
it('opens HTML source in the explicitly requested source group', async () => {
|
||||
setPlatform('Macintosh')
|
||||
seedSourceTab()
|
||||
|
||||
@@ -118,8 +114,17 @@ describe('terminal file links carry their source tab', () => {
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(createBrowserTabMock).not.toHaveBeenCalled()
|
||||
expect(openFileForLinkMock).not.toHaveBeenCalled()
|
||||
expect(toastErrorMock).toHaveBeenCalledTimes(1)
|
||||
expect(openFileForLinkMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: '/tmp/report.html', language: 'html', mode: 'edit' }),
|
||||
{ forceContentReload: true },
|
||||
expect.objectContaining({
|
||||
workspaceId: 'wt-1',
|
||||
hostId: null,
|
||||
targetGroupId: 'group-a'
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(toastErrorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a closed explicit group before activating its workspace', async () => {
|
||||
|
||||
+5
-4
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { openDetectedFilePath } from './terminal-link-handlers'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
|
||||
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
|
||||
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
|
||||
import {
|
||||
@@ -47,7 +47,7 @@ vi.mock('./terminal-remote-file-download-open', () => ({
|
||||
|
||||
vi.mock('@/lib/file-preview', () => ({
|
||||
getWorkspaceFilePreviewPlan: vi.fn(() => ({ status: 'doc-preview' })),
|
||||
openFileInBrowserTab: vi.fn()
|
||||
openFileInBrowserTab: vi.fn(() => ({ status: 'doc-preview' }))
|
||||
}))
|
||||
|
||||
installTerminalLinkTestEnvironment(doubles)
|
||||
@@ -254,8 +254,9 @@ describe('handleOscLink', () => {
|
||||
})
|
||||
// Why: the preview tab is the surface — activation must not re-seed a shell into a
|
||||
// workspace whose last terminal the user closed.
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
|
||||
providesInitialSurface: true
|
||||
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {
|
||||
providesInitialSurface: true,
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import type {
|
||||
EditorOpenLinkIntent,
|
||||
EditorOpenResult
|
||||
} from '@/store/slices/editor/types/editor-open-link-intent'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
|
||||
/** An explicit group request may only end in an editor in the scope it was captured from. */
|
||||
export function isExplicitEditorScopeKept(
|
||||
@@ -0,0 +1,69 @@
|
||||
import { basename } from '@/lib/path'
|
||||
import { activateBrowserWorkspaceTab } from '@/lib/browser-workspace-tab-activation'
|
||||
import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { CreateBrowserTabOptions } from '@/store/slices/browser/browser-slice-contract'
|
||||
import { browserPageDocLocationsEqual } from '../../../shared/browser-page-doc-location'
|
||||
import type { BrowserPageDocLocation } from '../../../shared/browser-workspace-types'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants'
|
||||
import { findWorkspaceShowingPage } from './workspace-file-preview-page'
|
||||
import { getBrowserPageRuntimeEnvironmentId } from '@/components/browser-pane/describe-page/browser-page-url-display'
|
||||
import { refreshLocalHtmlPreview } from '@/components/browser-pane/navigate/local-html-preview-freshness'
|
||||
import { webviewRegistry } from '@/components/browser-pane/host-guest/webview-registry'
|
||||
import type { WorkspaceFilePreviewPlan } from './file-preview'
|
||||
|
||||
export function openFilePreviewTab(
|
||||
state: AppState,
|
||||
params: { filePath: string; worktreeId: string; activate: boolean; clientLocal?: boolean },
|
||||
plan: Exclude<WorkspaceFilePreviewPlan, { status: 'unsupported' }>,
|
||||
placement?: Pick<CreateBrowserTabOptions, 'targetGroupId' | 'split'>
|
||||
): void {
|
||||
const docLocation: BrowserPageDocLocation | null =
|
||||
plan.status === 'doc-preview'
|
||||
? { kind: 'workspace-doc', worktreeId: params.worktreeId, filePath: params.filePath }
|
||||
: null
|
||||
const inferredRuntimeEnvironmentId = runtimeEnvironmentIdUnlessTwin(state, params.worktreeId)
|
||||
const previewRuntimeEnvironmentId = params.clientLocal ? null : inferredRuntimeEnvironmentId
|
||||
const existing = findWorkspaceShowingPage(
|
||||
state,
|
||||
params.worktreeId,
|
||||
(page) =>
|
||||
plan.status === 'browser-tab'
|
||||
? !page.docLocation &&
|
||||
page.url === plan.url &&
|
||||
getBrowserPageRuntimeEnvironmentId(page, inferredRuntimeEnvironmentId) ===
|
||||
previewRuntimeEnvironmentId
|
||||
: browserPageDocLocationsEqual(page.docLocation ?? null, docLocation),
|
||||
placement?.targetGroupId
|
||||
)
|
||||
if (existing) {
|
||||
const webview = plan.status === 'browser-tab' ? webviewRegistry.get(existing.pageId) : null
|
||||
if (webview) {
|
||||
void refreshLocalHtmlPreview(webview, params.filePath)
|
||||
}
|
||||
if (
|
||||
!params.activate ||
|
||||
!activateBrowserWorkspaceTab({
|
||||
worktreeId: params.worktreeId,
|
||||
...existing
|
||||
})
|
||||
) {
|
||||
state.setActiveBrowserTab(existing.workspaceId)
|
||||
state.setActiveBrowserPage(existing.workspaceId, existing.pageId)
|
||||
}
|
||||
return
|
||||
}
|
||||
state.createBrowserTab(
|
||||
params.worktreeId,
|
||||
plan.status === 'browser-tab' ? plan.url : ORCA_BROWSER_BLANK_URL,
|
||||
{
|
||||
title:
|
||||
plan.status === 'browser-tab' ? plan.title : basename(params.filePath) || params.filePath,
|
||||
...placement,
|
||||
// Document grants belong to this desktop even when the workspace is remote.
|
||||
...(docLocation ? { docLocation, browserRuntimeEnvironmentId: null } : {}),
|
||||
...(params.clientLocal ? { browserRuntimeEnvironmentId: null } : {}),
|
||||
activate: params.activate
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -76,7 +76,15 @@ vi.mock('@/store', () => ({
|
||||
...(mocks.twin
|
||||
? localAndSshTwinRows('repo-1', 'wt-1')
|
||||
: {
|
||||
repos: [{ id: 'repo-1', connectionId: mocks.connectionId }],
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
connectionId: mocks.connectionId,
|
||||
...(mocks.environmentId
|
||||
? { executionHostId: `runtime:${mocks.environmentId}` }
|
||||
: {})
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
|
||||
})
|
||||
})
|
||||
@@ -124,6 +132,110 @@ function mockOpenDocument(filePath: string): void {
|
||||
}
|
||||
|
||||
describe('openFileInBrowserTab', () => {
|
||||
it.each(['remote-host', undefined])(
|
||||
'does not reuse a server-owned file URL for a client-local open: %s',
|
||||
(browserRuntimeEnvironmentId) => {
|
||||
mocks.environmentId = 'remote-host'
|
||||
mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-remote' }] }
|
||||
mocks.browserPagesByWorkspace = {
|
||||
'browser-remote': [
|
||||
{ id: 'page-remote', url: 'file:///tmp/report.html', browserRuntimeEnvironmentId }
|
||||
]
|
||||
}
|
||||
|
||||
openFileInBrowserTab({
|
||||
filePath: '/tmp/report.html',
|
||||
worktreeId: 'wt-1',
|
||||
clientLocal: true
|
||||
})
|
||||
|
||||
expect(mocks.createBrowserTab).toHaveBeenCalledWith('wt-1', 'file:///tmp/report.html', {
|
||||
title: 'report.html',
|
||||
targetGroupId: undefined,
|
||||
browserRuntimeEnvironmentId: null,
|
||||
activate: true
|
||||
})
|
||||
expect(mocks.setActiveBrowserPage).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('reuses an explicitly local page in a paired workspace', () => {
|
||||
mocks.environmentId = 'remote-host'
|
||||
mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-local' }] }
|
||||
mocks.browserPagesByWorkspace = {
|
||||
'browser-local': [
|
||||
{ id: 'page-local', url: 'file:///tmp/report.html', browserRuntimeEnvironmentId: null }
|
||||
]
|
||||
}
|
||||
|
||||
openFileInBrowserTab({
|
||||
filePath: '/tmp/report.html',
|
||||
worktreeId: 'wt-1',
|
||||
clientLocal: true
|
||||
})
|
||||
|
||||
expect(mocks.setActiveBrowserPage).toHaveBeenCalledWith('browser-local', 'page-local')
|
||||
expect(mocks.createBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reuse a remotely pinned page in a local workspace', () => {
|
||||
mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-remote' }] }
|
||||
mocks.browserPagesByWorkspace = {
|
||||
'browser-remote': [
|
||||
{
|
||||
id: 'page-remote',
|
||||
url: 'file:///tmp/report.html',
|
||||
browserRuntimeEnvironmentId: 'remote-host'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
openFileInBrowserTab({ filePath: '/tmp/report.html', worktreeId: 'wt-1' })
|
||||
|
||||
expect(mocks.createBrowserTab).toHaveBeenCalledOnce()
|
||||
expect(mocks.setActiveBrowserPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([undefined, 'group-2', 'group-3'])(
|
||||
'reuses a local file on an inactive page within the requested group: %s',
|
||||
(targetGroupId) => {
|
||||
mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-9' }] }
|
||||
mocks.browserPagesByWorkspace = {
|
||||
'browser-9': [
|
||||
{ id: 'page-web', url: 'https://example.com' },
|
||||
{ id: 'page-html', url: 'file:///repo/report%20one.html' }
|
||||
]
|
||||
}
|
||||
mocks.unifiedTabsByWorktree = {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'tab-html',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'browser',
|
||||
entityId: 'browser-9',
|
||||
groupId: 'group-2'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
openFileInBrowserTab({ filePath: '/repo/report one.html', worktreeId: 'wt-1', targetGroupId })
|
||||
|
||||
if (targetGroupId === 'group-3') {
|
||||
expect(mocks.createBrowserTab).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
'file:///repo/report%20one.html',
|
||||
{ title: 'report one.html', activate: true, targetGroupId }
|
||||
)
|
||||
expect(mocks.focusGroup).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
|
||||
expect(mocks.focusGroup).toHaveBeenCalledWith('wt-1', 'group-2')
|
||||
expect(mocks.activateTab).toHaveBeenCalledWith('tab-html', { worktreeId: 'wt-1' })
|
||||
expect(mocks.setActiveBrowserPage).toHaveBeenCalledWith('browser-9', 'page-html')
|
||||
expect(mocks.createBrowserTab).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
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 }] }
|
||||
@@ -149,6 +261,10 @@ describe('openFileInBrowserTab', () => {
|
||||
})
|
||||
it('keeps a remote document preview in the requested group', () => {
|
||||
mocks.environmentId = 'env-1'
|
||||
mockOpenDocument('/srv/repo/docs/report.html')
|
||||
mocks.unifiedTabsByWorktree = {
|
||||
'wt-1': [{ contentType: 'browser', entityId: 'browser-9', groupId: 'group-1' }]
|
||||
}
|
||||
openFileInBrowserTab({
|
||||
filePath: '/srv/repo/docs/report.html',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -329,7 +445,11 @@ describe('openFileInBrowserTab', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('creates local side previews in an activated right-hand split', () => {
|
||||
it('creates a local side preview in a right-hand split even when the file is already open', () => {
|
||||
mocks.browserTabsByWorktree = { 'wt-1': [{ id: 'browser-9' }] }
|
||||
mocks.browserPagesByWorkspace = {
|
||||
'browser-9': [{ id: 'page-html', url: 'file:///tmp/example.html' }]
|
||||
}
|
||||
openFilePreviewToSide({
|
||||
language: 'html',
|
||||
filePath: '/tmp/example.html',
|
||||
|
||||
@@ -18,14 +18,14 @@ import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { findSiblingGroupId } from '@/store/slices/tabs'
|
||||
import type { CreateBrowserTabOptions } from '@/store/slices/browser/browser-slice-contract'
|
||||
import { browserPageDocLocationsEqual } from '../../../shared/browser-page-doc-location'
|
||||
import type { BrowserPageDocLocation } from '../../../shared/browser-workspace-types'
|
||||
import { findPage } from '@/store/slices/browser-page-records'
|
||||
import type { BrowserPageConversionLeg } from '@/store/slices/browser-page-conversion'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants'
|
||||
import { chatScratchFileAddress } from '@/runtime/runtime-chat-scratch-file-address'
|
||||
import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors'
|
||||
import { findWorkspaceShowingPage } from './workspace-file-preview-page'
|
||||
import { openFilePreviewTab } from './file-preview-tab'
|
||||
|
||||
export type PreviewableLanguage = 'html'
|
||||
|
||||
@@ -51,11 +51,31 @@ export type WorkspaceFilePreviewPlan =
|
||||
| { status: 'doc-preview' }
|
||||
| { status: 'unsupported'; message: string; reason: 'no-channel' | 'outside-worktree' }
|
||||
|
||||
export function getWorkspaceFilePreviewPlan(
|
||||
function getLocalFilePreviewPlan(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
filePath: string
|
||||
): WorkspaceFilePreviewPlan {
|
||||
const availability = getClientCreationActionPolicy(state, worktreeId)['managed-browser']
|
||||
if (availability.state !== 'enabled') {
|
||||
return { status: 'unsupported', message: availability.reason, reason: 'no-channel' }
|
||||
}
|
||||
return {
|
||||
status: 'browser-tab',
|
||||
url: absolutePathToFileUri(filePath),
|
||||
title: basename(filePath) || filePath
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceFilePreviewPlan(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
filePath: string,
|
||||
clientLocal = false
|
||||
): WorkspaceFilePreviewPlan {
|
||||
if (clientLocal) {
|
||||
return getLocalFilePreviewPlan(state, worktreeId, filePath)
|
||||
}
|
||||
// Why: a twin reads as "no server" below, which would open this machine's copy of the path.
|
||||
if (isTwinWorkspace(state, worktreeId)) {
|
||||
return {
|
||||
@@ -93,15 +113,7 @@ export function getWorkspaceFilePreviewPlan(
|
||||
}
|
||||
return webClientDocPreviewRefusal() ?? { status: 'doc-preview' }
|
||||
}
|
||||
const availability = getClientCreationActionPolicy(state, worktreeId)['managed-browser']
|
||||
if (availability.state !== 'enabled') {
|
||||
return { status: 'unsupported', message: availability.reason, reason: 'no-channel' }
|
||||
}
|
||||
return {
|
||||
status: 'browser-tab',
|
||||
url: absolutePathToFileUri(filePath),
|
||||
title: basename(filePath) || filePath
|
||||
}
|
||||
return getLocalFilePreviewPlan(state, worktreeId, filePath)
|
||||
}
|
||||
|
||||
export function canShowWorkspaceFileBrowserAction(
|
||||
@@ -145,85 +157,28 @@ export function useWorkspaceFileBrowserActionPredicate(
|
||||
)
|
||||
}
|
||||
|
||||
function openDocPreviewTab(
|
||||
state: AppState,
|
||||
params: { filePath: string; worktreeId: string; activate: boolean },
|
||||
placement?: Pick<CreateBrowserTabOptions, 'targetGroupId' | 'split'>
|
||||
): void {
|
||||
const docLocation = {
|
||||
kind: 'workspace-doc' as const,
|
||||
worktreeId: params.worktreeId,
|
||||
filePath: params.filePath
|
||||
}
|
||||
// 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 = findWorkspaceShowingDoc(state, docLocation)
|
||||
if (existing) {
|
||||
if (
|
||||
!params.activate ||
|
||||
!activateBrowserWorkspaceTab({
|
||||
worktreeId: params.worktreeId,
|
||||
...existing
|
||||
})
|
||||
) {
|
||||
state.setActiveBrowserTab(existing.workspaceId)
|
||||
state.setActiveBrowserPage(existing.workspaceId, existing.pageId)
|
||||
}
|
||||
return
|
||||
}
|
||||
state.createBrowserTab(params.worktreeId, ORCA_BROWSER_BLANK_URL, {
|
||||
docLocation,
|
||||
title: basename(params.filePath) || params.filePath,
|
||||
...placement,
|
||||
// Why explicitly client-local: the document is read through a grant this desktop mints, so the
|
||||
// page never belongs to a remote runtime even when the worktree does.
|
||||
browserRuntimeEnvironmentId: null,
|
||||
// Why the caller decides: opening a file is a request to look at it, while a preview opened to
|
||||
// the side belongs beside the source the reader is still working in.
|
||||
activate: params.activate
|
||||
})
|
||||
}
|
||||
|
||||
export function openFileInBrowserTab(params: {
|
||||
filePath: string
|
||||
worktreeId: string
|
||||
targetGroupId?: string
|
||||
clientLocal?: boolean
|
||||
}): WorkspaceFilePreviewPlan {
|
||||
const state = useAppStore.getState()
|
||||
const plan = getWorkspaceFilePreviewPlan(state, params.worktreeId, params.filePath)
|
||||
const plan = getWorkspaceFilePreviewPlan(
|
||||
state,
|
||||
params.worktreeId,
|
||||
params.filePath,
|
||||
params.clientLocal
|
||||
)
|
||||
if (plan.status === 'unsupported') {
|
||||
return plan
|
||||
}
|
||||
if (plan.status === 'doc-preview') {
|
||||
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
|
||||
openFilePreviewTab(state, { ...params, activate: true }, plan, {
|
||||
targetGroupId: params.targetGroupId
|
||||
})
|
||||
return plan
|
||||
}
|
||||
|
||||
/** The tab a document is already open in, found by every PAGE's docLocation and not just the
|
||||
* workspace mirror — a mixed workspace whose doc page is inactive mirrors null. */
|
||||
function findWorkspaceShowingDoc(
|
||||
state: AppState,
|
||||
docLocation: BrowserPageDocLocation
|
||||
): { workspaceId: string; pageId: string } | null {
|
||||
for (const tab of state.browserTabsByWorktree[docLocation.worktreeId] ?? []) {
|
||||
const page = (state.browserPagesByWorkspace[tab.id] ?? []).find((candidate) =>
|
||||
browserPageDocLocationsEqual(candidate.docLocation ?? null, docLocation)
|
||||
)
|
||||
if (page) {
|
||||
return { workspaceId: tab.id, pageId: page.id }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The address bar's way into a workspace document: reuse before converting. A document already on
|
||||
* screen is a request to look at it — two tabs of one document would each hold their own grant on
|
||||
@@ -246,7 +201,11 @@ export function convertBrowserPageToWorkspaceDoc(
|
||||
// history could jump there forever. A history leg's document was this tab's own, so converting
|
||||
// in place is right.
|
||||
const isHistoryLeg = options?.leg !== undefined
|
||||
const existing = isHistoryLeg ? null : findWorkspaceShowingDoc(state, docLocation)
|
||||
const existing = isHistoryLeg
|
||||
? null
|
||||
: findWorkspaceShowingPage(state, docLocation.worktreeId, (page) =>
|
||||
browserPageDocLocationsEqual(page.docLocation ?? null, docLocation)
|
||||
)
|
||||
if (existing) {
|
||||
// Why the worktree switches first: activation is deliberately scoped to the active worktree,
|
||||
// so without the switch a cross-worktree reuse would happen entirely out of sight.
|
||||
@@ -339,7 +298,12 @@ export function openFilePreviewToSide(params: {
|
||||
: { split: { sourceGroupId, splitDirection: 'right' as const, activate } }
|
||||
|
||||
if (plan.status === 'doc-preview') {
|
||||
openDocPreviewTab(state, { filePath: params.filePath, worktreeId, activate: false }, placement)
|
||||
openFilePreviewTab(
|
||||
state,
|
||||
{ filePath: params.filePath, worktreeId, activate: false },
|
||||
plan,
|
||||
placement
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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')
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import type { GitStatusEntry } from '../../../shared/git-status-types'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { joinPath } from '@/lib/path'
|
||||
@@ -0,0 +1,350 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { openWorkspaceFile, type WorkspaceFileOpenRequest } from './workspace-file-open'
|
||||
import type { GitStatusEntry } from '../../../shared/git-status-types'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const gitStatusByWorktree: Record<string, GitStatusEntry[]> = {}
|
||||
return {
|
||||
resolve: vi.fn(),
|
||||
plan: vi.fn(),
|
||||
open: vi.fn(),
|
||||
select: vi.fn(),
|
||||
activate: vi.fn(),
|
||||
environmentId: null as string | null,
|
||||
state: {
|
||||
activeWorktreeId: 'current',
|
||||
gitStatusByWorktree,
|
||||
runtimeStatusByEnvironmentId: new Map<string, { status: { capabilities: string[] } }>(),
|
||||
openBranchDiff: vi.fn(),
|
||||
openDiff: vi.fn(),
|
||||
openFileForLink: vi.fn(),
|
||||
openFile: vi.fn((file: { filePath: string }) => file.filePath)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } }))
|
||||
vi.mock('./workspace-doc-address-input', () => ({
|
||||
resolveWorkspaceDocAddressTarget: mocks.resolve
|
||||
}))
|
||||
vi.mock('./file-preview', () => ({
|
||||
openFileInBrowserTab: mocks.open,
|
||||
getWorkspaceFilePreviewPlan: mocks.plan
|
||||
}))
|
||||
vi.mock('./workspace-surface-reveal', () => ({ selectWorkspaceSurface: mocks.select }))
|
||||
vi.mock('./worktree-activation', () => ({ activateAndRevealWorkspace: mocks.activate }))
|
||||
vi.mock('./resolve-owner', () => ({
|
||||
runtimeEnvironmentIdUnlessTwin: () => mocks.environmentId
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.state.gitStatusByWorktree = {}
|
||||
mocks.environmentId = null
|
||||
mocks.state.runtimeStatusByEnvironmentId.clear()
|
||||
mocks.resolve.mockReturnValue({ status: 'not-a-workspace-doc' })
|
||||
mocks.open.mockReturnValue({ status: 'browser-tab' })
|
||||
mocks.plan.mockReturnValue({ status: 'browser-tab' })
|
||||
mocks.state.openFileForLink.mockReturnValue({
|
||||
fileId: 'html-source',
|
||||
viewStateId: 'html-view',
|
||||
groupId: 'group-2'
|
||||
})
|
||||
})
|
||||
|
||||
const request = {
|
||||
kind: 'preview' as const,
|
||||
filePath: '/repo/report one.html',
|
||||
worktreeId: 'current',
|
||||
targetGroupId: 'group-2'
|
||||
}
|
||||
|
||||
function sourceFile(filePath: string) {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
worktreeId: 'current',
|
||||
language: 'plaintext',
|
||||
mode: 'edit' as const
|
||||
}
|
||||
}
|
||||
|
||||
it.each(['/repo/report.html', '/repo/report.HTM', 'C:\\repo\\report.HTML'])(
|
||||
'opens the default HTML surface for %s',
|
||||
(filePath) => {
|
||||
expect(
|
||||
openWorkspaceFile({
|
||||
kind: 'file',
|
||||
file: sourceFile(filePath),
|
||||
options: { targetGroupId: 'group-2' }
|
||||
})
|
||||
).toEqual({ surface: 'preview' })
|
||||
expect(mocks.open).toHaveBeenCalledWith({
|
||||
filePath,
|
||||
worktreeId: 'current',
|
||||
targetGroupId: 'group-2'
|
||||
})
|
||||
expect(mocks.state.openFile).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['/repo/index.ts', '/repo/page.jsp', '/repo/page.html.liquid'])(
|
||||
'opens non-HTML files in the editor: %s',
|
||||
(filePath) => {
|
||||
const file = sourceFile(filePath)
|
||||
openWorkspaceFile({ kind: 'file', file })
|
||||
expect(mocks.state.openFile).toHaveBeenCalledWith(file, undefined)
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an explicit source request in the editor', () => {
|
||||
const file = sourceFile('/repo/report.html')
|
||||
openWorkspaceFile({ kind: 'file', intent: 'source', file, options: { preview: false } })
|
||||
expect(mocks.state.openFile).toHaveBeenCalledWith(file, { preview: false })
|
||||
expect(mocks.plan).not.toHaveBeenCalled()
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a host-granted HTML artifact on its authorized editor path', () => {
|
||||
const file = { ...sourceFile('/outside/report.html'), terminalArtifactGrantId: 'grant-1' }
|
||||
openWorkspaceFile({ kind: 'file', file })
|
||||
expect(mocks.state.openFile).toHaveBeenCalledWith(file, undefined)
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an external SSH HTML file on its owning editor path', () => {
|
||||
const file = { ...sourceFile('/remote/report.html'), externalSshTargetId: 'ssh-target' }
|
||||
expect(openWorkspaceFile({ kind: 'file', file })).toEqual({
|
||||
surface: 'editor',
|
||||
fileId: file.filePath
|
||||
})
|
||||
expect(mocks.state.openFile).toHaveBeenCalledWith(file, undefined)
|
||||
expect(mocks.plan).not.toHaveBeenCalled()
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const htmlLink = {
|
||||
kind: 'link',
|
||||
file: {
|
||||
filePath: '/repo/report.html',
|
||||
relativePath: 'report.html',
|
||||
worktreeId: 'folder:docs'
|
||||
},
|
||||
linkIntent: null,
|
||||
position: { line: null, column: null },
|
||||
isCurrentRequest: () => true,
|
||||
executionHostId: 'runtime:host-1',
|
||||
clientLocal: false
|
||||
} satisfies WorkspaceFileOpenRequest
|
||||
|
||||
it('activates an HTML link workspace with its execution host before previewing', () => {
|
||||
expect(openWorkspaceFile(htmlLink)).toEqual({ surface: 'preview' })
|
||||
expect(mocks.activate).toHaveBeenCalledWith('folder:docs', {
|
||||
providesInitialSurface: true,
|
||||
executionHostId: 'runtime:host-1'
|
||||
})
|
||||
expect(mocks.activate.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.open.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(mocks.state.openFileForLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
...htmlLink,
|
||||
linkIntent: {
|
||||
workspaceId: 'folder:docs',
|
||||
sourceTabId: 'source-tab',
|
||||
hostId: 'runtime:host-1',
|
||||
targetGroupId: 'group-2'
|
||||
}
|
||||
},
|
||||
{ ...htmlLink, file: { ...htmlLink.file, externalSshTargetId: 'ssh-target' } }
|
||||
] satisfies WorkspaceFileOpenRequest[])(
|
||||
'opens HTML links with an explicit editor group or external SSH owner in the editor: %j',
|
||||
(request) => {
|
||||
expect(openWorkspaceFile(request)).toEqual({ surface: 'editor', fileId: 'html-source' })
|
||||
expect(mocks.state.openFileForLink).toHaveBeenCalledWith(
|
||||
expect.objectContaining(request.file),
|
||||
{ forceContentReload: true },
|
||||
request.linkIntent,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mocks.plan).not.toHaveBeenCalled()
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('falls back to the editor when the owning host cannot preview HTML', () => {
|
||||
mocks.plan.mockReturnValue({ status: 'unsupported', reason: 'no-channel' })
|
||||
const file = sourceFile('/repo/report.html')
|
||||
openWorkspaceFile({ kind: 'file', file })
|
||||
expect(mocks.state.openFile).toHaveBeenCalledWith(file, undefined)
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([null, false])(
|
||||
'keeps source available when the paired host has no known scoped preview support: %s',
|
||||
(supported) => {
|
||||
mocks.environmentId = 'paired-host'
|
||||
mocks.plan.mockReturnValue({ status: 'doc-preview' })
|
||||
if (supported === false) {
|
||||
mocks.state.runtimeStatusByEnvironmentId.set('paired-host', { status: { capabilities: [] } })
|
||||
}
|
||||
const file = sourceFile('/repo/report.html')
|
||||
|
||||
expect(openWorkspaceFile({ kind: 'file', file })).toEqual({
|
||||
surface: 'editor',
|
||||
fileId: file.filePath
|
||||
})
|
||||
expect(mocks.state.openFile).toHaveBeenCalledWith(file, undefined)
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
expect(
|
||||
openWorkspaceFile({ ...htmlLink, file: { ...htmlLink.file, worktreeId: 'current' } })
|
||||
).toEqual({ surface: 'editor', fileId: 'html-source' })
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
expect(
|
||||
openWorkspaceFile({
|
||||
kind: 'uncommitted',
|
||||
worktreeId: 'current',
|
||||
worktreePath: '/repo',
|
||||
entry: { path: 'report.html', status: 'modified', area: 'unstaged' }
|
||||
})
|
||||
).toEqual({ surface: 'diff' })
|
||||
expect(mocks.state.openDiff).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('defaults to scoped previews only when the paired host advertises support', () => {
|
||||
mocks.environmentId = 'paired-host'
|
||||
mocks.plan.mockReturnValue({ status: 'doc-preview' })
|
||||
mocks.state.runtimeStatusByEnvironmentId.set('paired-host', {
|
||||
status: { capabilities: ['files.readDocPreview'] }
|
||||
})
|
||||
|
||||
expect(openWorkspaceFile({ kind: 'file', file: sourceFile('/repo/report.html') })).toEqual({
|
||||
surface: 'preview'
|
||||
})
|
||||
expect(mocks.open).toHaveBeenCalledOnce()
|
||||
expect(mocks.state.openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains explicit preview behavior on an older paired host', () => {
|
||||
mocks.environmentId = 'paired-host'
|
||||
mocks.open.mockReturnValue({ status: 'doc-preview' })
|
||||
|
||||
expect(openWorkspaceFile(request)).toEqual({ surface: 'preview' })
|
||||
expect(mocks.state.openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const branchRequest = {
|
||||
kind: 'branch',
|
||||
worktreeId: 'current',
|
||||
worktreePath: '/repo',
|
||||
entry: { path: 'report.html', status: 'modified' },
|
||||
compare: {
|
||||
baseRef: 'main',
|
||||
baseOid: 'base',
|
||||
compareRef: 'HEAD',
|
||||
headOid: 'head',
|
||||
mergeBase: 'base'
|
||||
}
|
||||
} as const
|
||||
|
||||
it.each([
|
||||
[{ path: 'report.html', status: 'deleted', area: 'staged' }],
|
||||
[{ path: 'report.html', status: 'deleted', area: 'unstaged' }],
|
||||
[{ path: 'renamed.html', oldPath: 'report.html', status: 'renamed', area: 'staged' }]
|
||||
] satisfies GitStatusEntry[][])(
|
||||
'opens a committed diff when the working file is absent: %j',
|
||||
(...changes) => {
|
||||
mocks.state.gitStatusByWorktree.current = changes
|
||||
|
||||
expect(openWorkspaceFile(branchRequest)).toEqual({ surface: 'diff' })
|
||||
expect(mocks.state.openBranchDiff).toHaveBeenCalledWith(
|
||||
'current',
|
||||
'/repo',
|
||||
branchRequest.entry,
|
||||
branchRequest.compare,
|
||||
'html',
|
||||
undefined
|
||||
)
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ path: 'report.html', status: 'deleted', area: 'staged' },
|
||||
{ path: 'renamed.html', oldPath: 'report.html', status: 'renamed', area: 'staged' }
|
||||
] satisfies GitStatusEntry[])(
|
||||
'previews a recreated working file after a staged $status',
|
||||
(stagedChange) => {
|
||||
const entry: GitStatusEntry = { path: 'report.html', status: 'untracked', area: 'untracked' }
|
||||
mocks.state.gitStatusByWorktree.current = [stagedChange, entry]
|
||||
|
||||
expect(openWorkspaceFile(branchRequest)).toEqual({ surface: 'preview' })
|
||||
expect(
|
||||
openWorkspaceFile({
|
||||
kind: 'uncommitted',
|
||||
worktreeId: 'current',
|
||||
worktreePath: '/repo',
|
||||
entry
|
||||
})
|
||||
).toEqual({ surface: 'preview' })
|
||||
expect(mocks.open).toHaveBeenCalledWith({
|
||||
filePath: '/repo/report.html',
|
||||
worktreeId: 'current',
|
||||
targetGroupId: undefined
|
||||
})
|
||||
expect(mocks.state.openBranchDiff).not.toHaveBeenCalled()
|
||||
expect(mocks.state.openDiff).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ path: 'report.html', oldPath: 'before.html', status: 'renamed', area: 'staged' },
|
||||
{ path: 'copy.html', oldPath: 'report.html', status: 'copied', area: 'staged' }
|
||||
] satisfies GitStatusEntry[])(
|
||||
'previews the existing file after a staged $status to $path',
|
||||
(change) => {
|
||||
mocks.state.gitStatusByWorktree.current = [change]
|
||||
|
||||
expect(openWorkspaceFile(branchRequest)).toEqual({ surface: 'preview' })
|
||||
expect(mocks.open).toHaveBeenCalledWith({
|
||||
filePath: '/repo/report.html',
|
||||
worktreeId: 'current',
|
||||
targetGroupId: undefined
|
||||
})
|
||||
expect(mocks.state.openBranchDiff).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a local HTML preview in the requested group', () => {
|
||||
openWorkspaceFile(request)
|
||||
expect(mocks.resolve).toHaveBeenCalledWith(mocks.state, 'current', request.filePath)
|
||||
expect(mocks.open).toHaveBeenCalledWith({
|
||||
filePath: request.filePath,
|
||||
worktreeId: request.worktreeId,
|
||||
targetGroupId: request.targetGroupId
|
||||
})
|
||||
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' })
|
||||
openWorkspaceFile(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(() => openWorkspaceFile(request)).toThrow('outside workspace')
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a preview plan refusal', () => {
|
||||
mocks.open.mockReturnValue({ status: 'unsupported', message: 'unknown host' })
|
||||
expect(() => openWorkspaceFile(request)).toThrow('unknown host')
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type {
|
||||
OpenFile,
|
||||
BranchCompareLike,
|
||||
EditorOpenTargetOptions
|
||||
} from '@/store/slices/editor/types/open-file'
|
||||
import type { OpenFileOptions } from '@/store/slices/editor/types/editor-files-slice'
|
||||
import type { GitStatusEntry } from '../../../shared/git-status-types'
|
||||
import type { GitBranchChangeEntry } from '../../../shared/git-diff-compare-types'
|
||||
import { isHtmlFilePath } from '../../../shared/html-file-url'
|
||||
import { detectLanguage } from './language-detect'
|
||||
import { joinPath } from './path'
|
||||
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from './file-preview'
|
||||
import { openUncommittedEntry } from './workspace-file-changes-open'
|
||||
import { resolveWorkspaceDocAddressTarget } from './workspace-doc-address-input'
|
||||
import { selectWorkspaceSurface } from './workspace-surface-reveal'
|
||||
import { openLinkedFileInEditor } from './editor-file-link-open'
|
||||
import type { EditorOpenLinkIntent } from '@/store/slices/editor/types/editor-open-link-intent'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { activateAndRevealWorkspace } from './worktree-activation'
|
||||
import { runtimeEnvironmentIdUnlessTwin } from './resolve-owner'
|
||||
import { FILE_DOC_PREVIEW_RUNTIME_CAPABILITY } from '../../../shared/files-runtime-capabilities'
|
||||
|
||||
type ChangeOpenRequest = {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
intent?: 'default' | 'diff'
|
||||
options?: EditorOpenTargetOptions
|
||||
} & (
|
||||
| { kind: 'uncommitted'; entry: GitStatusEntry }
|
||||
| { kind: 'branch'; entry: GitBranchChangeEntry; compare: BranchCompareLike }
|
||||
)
|
||||
|
||||
export type WorkspaceFileOpenRequest =
|
||||
| {
|
||||
kind: 'file'
|
||||
file: Omit<OpenFile, 'id' | 'isDirty'>
|
||||
intent?: 'default' | 'source'
|
||||
options?: OpenFileOptions
|
||||
}
|
||||
| { kind: 'preview'; filePath: string; worktreeId: string; targetGroupId?: string }
|
||||
| {
|
||||
kind: 'link'
|
||||
file: Omit<OpenFile, 'id' | 'isDirty' | 'language' | 'mode'>
|
||||
intent?: 'default' | 'source'
|
||||
linkIntent: EditorOpenLinkIntent | null
|
||||
position: { line: number | null; column: number | null }
|
||||
isCurrentRequest: () => boolean
|
||||
executionHostId: ExecutionHostId
|
||||
clientLocal: boolean
|
||||
}
|
||||
| ChangeOpenRequest
|
||||
|
||||
export type WorkspaceFileOpenResult =
|
||||
| { surface: 'preview' }
|
||||
| { surface: 'editor'; fileId: string }
|
||||
| { surface: 'diff' }
|
||||
| { surface: 'refused' }
|
||||
|
||||
export type WorkspaceFileOpener = typeof openWorkspaceFile
|
||||
|
||||
function hasWorkingTreeFile(
|
||||
entry: Pick<GitStatusEntry, 'status' | 'conflictStatus' | 'submodule'>
|
||||
): boolean {
|
||||
return entry.status !== 'deleted' && !entry.conflictStatus && !entry.submodule
|
||||
}
|
||||
|
||||
function hasCurrentWorkingTreeFile(path: string, changes: GitStatusEntry[]): boolean {
|
||||
const workingTreeChange = changes.find(
|
||||
(change) => change.path === path && change.area !== 'staged'
|
||||
)
|
||||
if (workingTreeChange) {
|
||||
return hasWorkingTreeFile(workingTreeChange)
|
||||
}
|
||||
return !changes.some(
|
||||
(change) =>
|
||||
(change.path === path && !hasWorkingTreeFile(change)) ||
|
||||
(change.status === 'renamed' && change.oldPath === path)
|
||||
)
|
||||
}
|
||||
|
||||
function openRequestedPreview(
|
||||
request: Extract<WorkspaceFileOpenRequest, { kind: 'preview' }>
|
||||
): WorkspaceFileOpenResult {
|
||||
const state = useAppStore.getState()
|
||||
const target = resolveWorkspaceDocAddressTarget(state, request.worktreeId, request.filePath)
|
||||
if (target.status === 'unsupported') {
|
||||
throw new Error(target.message)
|
||||
}
|
||||
const worktreeId =
|
||||
target.status === 'workspace-doc' ? target.docLocation.worktreeId : request.worktreeId
|
||||
if (state.activeWorktreeId !== worktreeId) {
|
||||
selectWorkspaceSurface(state, worktreeId)
|
||||
}
|
||||
const plan = openFileInBrowserTab({
|
||||
filePath: request.filePath,
|
||||
worktreeId,
|
||||
...(worktreeId === request.worktreeId ? { targetGroupId: request.targetGroupId } : {})
|
||||
})
|
||||
if (plan.status === 'unsupported') {
|
||||
throw new Error(plan.message)
|
||||
}
|
||||
return { surface: 'preview' }
|
||||
}
|
||||
|
||||
function defaultPreviewTarget(
|
||||
request: Exclude<WorkspaceFileOpenRequest, { kind: 'preview' }>,
|
||||
state: Pick<AppState, 'gitStatusByWorktree'>
|
||||
): Parameters<typeof openFileInBrowserTab>[0] | null {
|
||||
if (request.intent === 'source' || request.intent === 'diff') {
|
||||
return null
|
||||
}
|
||||
const filePath =
|
||||
'file' in request ? request.file.filePath : joinPath(request.worktreePath, request.entry.path)
|
||||
if (!isHtmlFilePath(filePath)) {
|
||||
return null
|
||||
}
|
||||
switch (request.kind) {
|
||||
case 'file':
|
||||
return request.file.mode === 'edit' &&
|
||||
!request.file.terminalArtifactGrantId &&
|
||||
!request.file.externalSshTargetId
|
||||
? {
|
||||
filePath,
|
||||
worktreeId: request.file.worktreeId,
|
||||
targetGroupId: request.options?.targetGroupId
|
||||
}
|
||||
: null
|
||||
case 'link':
|
||||
return request.position.line === null &&
|
||||
!request.file.terminalArtifactGrantId &&
|
||||
!request.file.externalSshTargetId &&
|
||||
!request.linkIntent?.targetGroupId
|
||||
? {
|
||||
filePath,
|
||||
worktreeId: request.file.worktreeId,
|
||||
...(request.clientLocal ? { clientLocal: true } : {})
|
||||
}
|
||||
: null
|
||||
case 'uncommitted':
|
||||
case 'branch':
|
||||
if (
|
||||
!hasWorkingTreeFile(request.entry) ||
|
||||
!hasCurrentWorkingTreeFile(
|
||||
request.entry.path,
|
||||
state.gitStatusByWorktree[request.worktreeId] ?? []
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
filePath,
|
||||
worktreeId: request.worktreeId,
|
||||
targetGroupId: request.options?.targetGroupId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openWorkspaceFile(request: WorkspaceFileOpenRequest): WorkspaceFileOpenResult {
|
||||
if (request.kind === 'preview') {
|
||||
return openRequestedPreview(request)
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const target = defaultPreviewTarget(request, state)
|
||||
if (target) {
|
||||
const plan = getWorkspaceFilePreviewPlan(
|
||||
state,
|
||||
target.worktreeId,
|
||||
target.filePath,
|
||||
target.clientLocal
|
||||
)
|
||||
const previewEnvironmentId =
|
||||
plan.status === 'doc-preview'
|
||||
? runtimeEnvironmentIdUnlessTwin(state, target.worktreeId)
|
||||
: null
|
||||
if (
|
||||
plan.status !== 'unsupported' &&
|
||||
(!previewEnvironmentId ||
|
||||
state.runtimeStatusByEnvironmentId
|
||||
?.get(previewEnvironmentId)
|
||||
?.status?.capabilities?.includes(FILE_DOC_PREVIEW_RUNTIME_CAPABILITY) === true)
|
||||
) {
|
||||
if (request.kind === 'link') {
|
||||
activateAndRevealWorkspace(target.worktreeId, {
|
||||
providesInitialSurface: true,
|
||||
executionHostId: request.executionHostId
|
||||
})
|
||||
}
|
||||
if (openFileInBrowserTab(target).status !== 'unsupported') {
|
||||
return { surface: 'preview' }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (request.kind === 'link') {
|
||||
const worktreeId = request.file.worktreeId
|
||||
if (worktreeId) {
|
||||
activateAndRevealWorkspace(worktreeId, {
|
||||
providesInitialSurface: true,
|
||||
executionHostId: request.executionHostId
|
||||
})
|
||||
}
|
||||
const opened = openLinkedFileInEditor(
|
||||
request.file,
|
||||
request.linkIntent,
|
||||
request.position,
|
||||
request.isCurrentRequest
|
||||
)
|
||||
return opened ? { surface: 'editor', fileId: opened.fileId } : { surface: 'refused' }
|
||||
}
|
||||
if (request.kind === 'file') {
|
||||
return { surface: 'editor', fileId: state.openFile(request.file, request.options) }
|
||||
}
|
||||
if (request.kind === 'uncommitted') {
|
||||
openUncommittedEntry(
|
||||
state,
|
||||
request.worktreeId,
|
||||
request.worktreePath,
|
||||
request.entry,
|
||||
request.options
|
||||
)
|
||||
} else {
|
||||
state.openBranchDiff(
|
||||
request.worktreeId,
|
||||
request.worktreePath,
|
||||
request.entry,
|
||||
request.compare,
|
||||
detectLanguage(request.entry.path),
|
||||
request.options
|
||||
)
|
||||
}
|
||||
return { surface: 'diff' }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { BrowserPage } from '../../../shared/browser-workspace-types'
|
||||
|
||||
export function findWorkspaceShowingPage(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
matches: (page: BrowserPage) => boolean,
|
||||
targetGroupId?: string
|
||||
): { workspaceId: string; pageId: string } | null {
|
||||
for (const tab of state.browserTabsByWorktree[worktreeId] ?? []) {
|
||||
if (
|
||||
targetGroupId &&
|
||||
!state.unifiedTabsByWorktree[worktreeId]?.some(
|
||||
(candidate) =>
|
||||
candidate.contentType === 'browser' &&
|
||||
candidate.entityId === tab.id &&
|
||||
candidate.groupId === targetGroupId
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const page = (state.browserPagesByWorkspace[tab.id] ?? []).find(matches)
|
||||
if (page) {
|
||||
return { workspaceId: tab.id, pageId: page.id }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -5,9 +5,12 @@ export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-owners
|
||||
export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE =
|
||||
'Remote file changes require a newer Orca server. Update the HUB and try again.'
|
||||
|
||||
export const FILE_DOC_PREVIEW_RUNTIME_CAPABILITY = 'files.readDocPreview' as const
|
||||
|
||||
export const FILES_RUNTIME_CAPABILITIES = [
|
||||
'files.pathsExist',
|
||||
'files.resolve-terminal-path.cross-workspace.v1',
|
||||
FILE_DOC_PREVIEW_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { fileUriToFilesystemPath } from './file-uri-path'
|
||||
|
||||
export function isHtmlFilePath(filePath: string): boolean {
|
||||
return /\.html?$/i.test(filePath)
|
||||
}
|
||||
|
||||
/** 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)) {
|
||||
@@ -11,7 +15,7 @@ export function htmlFilePathFromUrl(input: string): string | null {
|
||||
return null
|
||||
}
|
||||
const filePath = fileUriToFilesystemPath(url)
|
||||
return filePath && /\.html?$/i.test(filePath) && !/\p{Cc}/u.test(filePath) ? filePath : null
|
||||
return filePath && isHtmlFilePath(filePath) && !/\p{Cc}/u.test(filePath) ? filePath : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
@@ -9,24 +9,35 @@ import {
|
||||
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 ({
|
||||
test('opens HTML from Explorer as a preview and lets the user edit its source', async ({
|
||||
electronApp,
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
orcaPage
|
||||
}, 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
|
||||
const name = 'default-preview.html'
|
||||
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
|
||||
const root = await orcaPage.evaluate(
|
||||
(id) => window.__store?.getState().getKnownWorktreeById(id)?.path,
|
||||
worktreeId
|
||||
)
|
||||
if (!root) {
|
||||
throw new Error('Active workspace missing')
|
||||
}
|
||||
const filePath = path.join(root, name)
|
||||
const url = pathToFileURL(filePath).href
|
||||
writeFileSync(filePath, '<title>HTML default</title><h1>Rendered by default</h1>')
|
||||
await orcaPage.evaluate(() => {
|
||||
window.localStorage.setItem('orca.browser.markup-draw-hint-seen', 'true')
|
||||
const state = window.__store?.getState()
|
||||
state?.setRightSidebarOpen(true)
|
||||
state?.setRightSidebarTab('explorer')
|
||||
})
|
||||
await input.fill(url)
|
||||
await expect(orcaPage.getByRole('option', { name: /Open file/ })).toBeVisible()
|
||||
await input.press('Enter')
|
||||
await orcaPage.getByRole('button', { name: 'Refresh Explorer', exact: true }).click()
|
||||
const row = orcaPage.locator('[data-file-explorer-row]').filter({
|
||||
has: orcaPage.locator('[data-file-explorer-row-name]').getByText(name, { exact: true })
|
||||
})
|
||||
await expect(row).toBeVisible()
|
||||
await row.click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
electronApp.evaluate(async ({ webContents }, fileUrl) => {
|
||||
@@ -36,19 +47,106 @@ test('opens a local HTML file URL with encoded characters from the new-tab menu'
|
||||
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('Rendered by default')
|
||||
await electronApp.evaluate(async ({ webContents }, fileUrl) => {
|
||||
const guest = webContents.getAllWebContents().find((contents) => contents.getURL() === fileUrl)
|
||||
await guest?.executeJavaScript(
|
||||
'const input = document.createElement("input"); input.id = "preview-state"; input.value = "Keep my input"; document.body.append(input)'
|
||||
)
|
||||
).toBe(true)
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('local-html-file-url.png') })
|
||||
}, url)
|
||||
await row.click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
electronApp.evaluate(async ({ webContents }, fileUrl) => {
|
||||
const guest = webContents
|
||||
.getAllWebContents()
|
||||
.find((contents) => contents.getURL() === fileUrl)
|
||||
return guest?.executeJavaScript('document.querySelector("#preview-state")?.value ?? null')
|
||||
}, url)
|
||||
)
|
||||
.toBe('Keep my input')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('html-default-preview.png') })
|
||||
await orcaPage.getByRole('button', { name: 'Open source file', exact: true }).click()
|
||||
await expect(orcaPage.locator('.editor-header-path')).toContainText(name)
|
||||
await expect(orcaPage.locator('.monaco-editor').first()).toBeVisible()
|
||||
await expect(orcaPage.locator('.monaco-editor').first()).toContainText('Rendered by default')
|
||||
await orcaPage.locator('.monaco-editor').first().click()
|
||||
await orcaPage.keyboard.press('ControlOrMeta+End')
|
||||
await orcaPage.keyboard.press('Enter')
|
||||
await orcaPage.keyboard.type('<p>Source updated</p>')
|
||||
await orcaPage.keyboard.press('ControlOrMeta+S')
|
||||
await expect.poll(() => readFileSync(filePath, 'utf8')).toContain('<p>Source updated</p>')
|
||||
await row.click()
|
||||
await expect(
|
||||
orcaPage.getByRole('button', { name: 'Open source file', exact: true })
|
||||
).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
electronApp.evaluate(async ({ webContents }, fileUrl) => {
|
||||
const guest = webContents
|
||||
.getAllWebContents()
|
||||
.find((contents) => contents.getURL() === fileUrl)
|
||||
return guest?.executeJavaScript('document.body.textContent ?? ""')
|
||||
}, url)
|
||||
)
|
||||
.toContain('Source updated')
|
||||
const previewTab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'HTML default' })
|
||||
await expect(previewTab).toHaveCount(1)
|
||||
await expect(previewTab).toBeVisible()
|
||||
})
|
||||
|
||||
for (const inputKind of ['file URL', 'absolute path', 'linked path'] as const) {
|
||||
test(`opens a local HTML ${inputKind} with encoded characters from the new-tab menu`, async ({
|
||||
electronApp,
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
let directory = testRepoPath
|
||||
if (inputKind === 'linked path') {
|
||||
const target = testInfo.outputPath('external-source')
|
||||
mkdirSync(target, { recursive: true })
|
||||
directory = path.join(testRepoPath, 'linked-source')
|
||||
symlinkSync(target, directory, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
}
|
||||
const filePath = path.join(directory, 'report 中%20.html')
|
||||
writeFileSync(filePath, '<title>Local file URL</title><h1>Local HTML rendered</h1>')
|
||||
const url = pathToFileURL(filePath).href
|
||||
await waitForSessionReady(orcaPage)
|
||||
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(inputKind === 'file URL' ? url : filePath)
|
||||
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')
|
||||
await expect(
|
||||
orcaPage.getByRole('button', { name: 'Open source file', exact: true })
|
||||
).toBeVisible()
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('local-html-file-url.png') })
|
||||
await orcaPage.getByRole('button', { name: 'Open source file', exact: true }).click()
|
||||
const editor = orcaPage.locator('.monaco-editor').first()
|
||||
await expect(editor).toContainText('Local HTML rendered')
|
||||
await editor.click()
|
||||
await orcaPage.keyboard.press('ControlOrMeta+End')
|
||||
await orcaPage.keyboard.press('Enter')
|
||||
await orcaPage.keyboard.type('<p>Source saved</p>')
|
||||
await orcaPage.keyboard.press('ControlOrMeta+S')
|
||||
await expect.poll(() => readFileSync(filePath, 'utf8')).toContain('<p>Source saved</p>')
|
||||
})
|
||||
}
|
||||
|
||||
test('opens a pasted HTML file URL from a paired host and reloads its assets', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
cleanupGoldenWorktree,
|
||||
createGoldenWorktree,
|
||||
openGoldenSourceControl
|
||||
} from './helpers/golden-source-control'
|
||||
|
||||
test('previews untracked HTML clicked in Source Control', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
const root = await orcaPage.evaluate(
|
||||
(id) => window.__store?.getState().getKnownWorktreeById(id)?.path,
|
||||
worktreeId
|
||||
)
|
||||
if (!root) {
|
||||
throw new Error('Active workspace missing')
|
||||
}
|
||||
const name = 'source-control-preview.html'
|
||||
const filePath = path.join(root, name)
|
||||
const url = pathToFileURL(filePath).href
|
||||
writeFileSync(filePath, '<title>HTML preview</title><h1>Rendered from Source Control</h1>')
|
||||
await orcaPage.evaluate(() => {
|
||||
window.localStorage.setItem('orca.browser.markup-draw-hint-seen', 'true')
|
||||
const state = window.__store?.getState()
|
||||
state?.setRightSidebarOpen(true)
|
||||
state?.setRightSidebarTab('source-control')
|
||||
})
|
||||
const row = orcaPage.locator(`[data-source-control-path="${name}"]`)
|
||||
await expect(row).toBeVisible({ timeout: 30_000 })
|
||||
await row.click()
|
||||
|
||||
await expect(
|
||||
orcaPage.getByRole('button', { name: 'Open source file', exact: true })
|
||||
).toBeVisible()
|
||||
await expect(orcaPage.locator('.monaco-diff-editor')).toHaveCount(0)
|
||||
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('Rendered from Source Control')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('html-source-control-preview.png') })
|
||||
})
|
||||
|
||||
test('previews HTML clicked under Committed on branch', async ({
|
||||
electronApp,
|
||||
orcaPage,
|
||||
testRepoPath,
|
||||
registerPostElectronShutdownCleanup
|
||||
}, testInfo) => {
|
||||
const fixture = createGoldenWorktree(testRepoPath, 'html-branch-preview')
|
||||
registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture))
|
||||
const name = 'committed-preview.html'
|
||||
const filePath = path.join(fixture.worktreePath, name)
|
||||
const url = pathToFileURL(filePath).href
|
||||
writeFileSync(filePath, '<title>Committed HTML</title><h1>Rendered from branch changes</h1>')
|
||||
for (const args of [
|
||||
['add', '--', name],
|
||||
['commit', '-m', 'Add HTML preview fixture']
|
||||
]) {
|
||||
const result = runProcessSync({
|
||||
program: 'git',
|
||||
args,
|
||||
cwd: fixture.worktreePath,
|
||||
stdio: 'pipe'
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || 'Failed to prepare committed HTML fixture')
|
||||
}
|
||||
}
|
||||
await waitForSessionReady(orcaPage)
|
||||
await openGoldenSourceControl(orcaPage, testRepoPath, fixture)
|
||||
await orcaPage.evaluate(() => {
|
||||
window.localStorage.setItem('orca.browser.markup-draw-hint-seen', 'true')
|
||||
})
|
||||
const row = orcaPage.locator(
|
||||
`[data-source-control-area="branch"][data-source-control-path="${name}"]`
|
||||
)
|
||||
await expect(row).toBeVisible({ timeout: 30_000 })
|
||||
await row.click()
|
||||
|
||||
await expect(
|
||||
orcaPage.getByRole('button', { name: 'Open source file', exact: true })
|
||||
).toBeVisible()
|
||||
await expect(orcaPage.locator('.monaco-diff-editor')).toHaveCount(0)
|
||||
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('Rendered from branch changes')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('html-committed-branch-preview.png') })
|
||||
})
|
||||
Reference in New Issue
Block a user