mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Add Reveal in Finder to the file header menu (#30129)
* Add file location actions to the editor header * Wait for the terminal spawn join before releasing race-test gates
This commit is contained in:
@@ -3,11 +3,11 @@ import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { getHiddenRendererPtyIds, isHiddenRendererPty } from './pty-hidden-delivery-gate'
|
||||
import { ptySizes } from './pty/delivery/visibility-state'
|
||||
import { clearProviderPtyState } from './pty/provider/state-cleanup'
|
||||
import * as paneSpawnReservations from './pty/pane/spawn-reservation'
|
||||
import { WORKTREE_TERMINALS_SLEEPING_ERROR } from '../runtime/worktree-terminals-sleeping-error'
|
||||
import {
|
||||
LAUNCH_RACE_LANES,
|
||||
createLaunchRaceRuntime,
|
||||
flushMacrotasks,
|
||||
gatedClaudeAuth,
|
||||
launchRacePane,
|
||||
registerLaunchRaceLanes,
|
||||
@@ -96,8 +96,13 @@ describe('launch race parity, both spawn lanes', () => {
|
||||
|
||||
const winner = lanes.spawn.ipc(pane.args)
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce())
|
||||
const joinPaneSpawn = vi.spyOn(paneSpawnReservations, 'joinPaneSpawn')
|
||||
onTestFinished(() => joinPaneSpawn.mockRestore())
|
||||
const joiner = lanes.spawn[lane]({ ...pane.args, command: 'claude' })
|
||||
await flushMacrotasks()
|
||||
if (lane === 'runtime') {
|
||||
// Runtime preflight does filesystem work before it can join the reserved pane.
|
||||
await vi.waitFor(() => expect(joinPaneSpawn).toHaveBeenCalledOnce())
|
||||
}
|
||||
finishSpawn()
|
||||
|
||||
await expect(winner).resolves.toMatchObject({ id: 'pty-join-winner' })
|
||||
@@ -128,8 +133,10 @@ describe('launch race parity, both spawn lanes', () => {
|
||||
|
||||
const automatic = lanes.spawn.runtime({ ...pane.args, refuseSleptWorktree: true })
|
||||
await vi.waitFor(() => expect(lock.acquireWorktreeTerminalSpawn).toHaveBeenCalledOnce())
|
||||
const joinPaneSpawn = vi.spyOn(paneSpawnReservations, 'joinPaneSpawn')
|
||||
onTestFinished(() => joinPaneSpawn.mockRestore())
|
||||
const joiner = lanes.spawn.runtime(pane.args)
|
||||
await flushMacrotasks()
|
||||
await vi.waitFor(() => expect(joinPaneSpawn).toHaveBeenCalledOnce())
|
||||
refused = true
|
||||
lock.refuse()
|
||||
|
||||
|
||||
@@ -45,12 +45,6 @@ export const LAUNCH_RACE_LANES: LaunchRaceLane[] = ['ipc', 'runtime']
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test doubles implement only the members the two spawn lanes read.
|
||||
const testDouble = <T>(value: unknown): T => value as T
|
||||
|
||||
export const flushMacrotasks = async (rounds = 20): Promise<void> => {
|
||||
for (let index = 0; index < rounds; index++) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
/** One stable pane per test; reservation maps are module state, so names must not repeat. */
|
||||
export function launchRacePane(tag: string, leafId: string) {
|
||||
const worktreeId = `repo-1::/tmp/${tag}`
|
||||
|
||||
@@ -1288,8 +1288,8 @@ html.native-shell .app-layout {
|
||||
align-items: center;
|
||||
/* Keep adjacent actions aligned with the app's compact toolbar spacing. */
|
||||
gap: 4px;
|
||||
min-height: 36px;
|
||||
padding: 6px 14px;
|
||||
min-height: 32px;
|
||||
padding: 3px 14px;
|
||||
background: var(--editor-surface);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { EditorFileLocationMenuItems } from './EditorFileLocationMenuItems'
|
||||
|
||||
const store = vi.hoisted((): { state: WorktreeRuntimeOwnerState } => ({ state: {} }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: WorktreeRuntimeOwnerState) => unknown) => selector(store.state)
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
const openInFileManager = vi.fn().mockResolvedValue({ ok: true })
|
||||
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
|
||||
const onCopyPath = vi.fn()
|
||||
const file: OpenFile = {
|
||||
id: '/repo/src/file.ts',
|
||||
filePath: '/repo/src/file.ts',
|
||||
relativePath: 'src/file.ts',
|
||||
worktreeId: 'repo::/repo',
|
||||
language: 'typescript',
|
||||
mode: 'edit',
|
||||
isDirty: false
|
||||
}
|
||||
|
||||
function renderMenu(overrides: Partial<OpenFile> = {}): void {
|
||||
render(
|
||||
<DropdownMenu open modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button>File actions</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<EditorFileLocationMenuItems file={{ ...file, ...overrides }} onCopyPath={onCopyPath} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store.state = {
|
||||
repos: [{ id: 'repo', connectionId: null }],
|
||||
worktreesByRepo: {
|
||||
repo: [{ id: file.worktreeId, repoId: 'repo', hostId: 'local' }]
|
||||
},
|
||||
folderWorkspaces: [],
|
||||
projectGroups: []
|
||||
}
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Macintosh')
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { shell: { openInFileManager }, ui: { writeClipboardText } }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('EditorFileLocationMenuItems', () => {
|
||||
it('reveals the open file through the existing host-aware shell action', () => {
|
||||
renderMenu()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Reveal in Finder' }))
|
||||
expect(openInFileManager).toHaveBeenCalledWith(file.filePath, 'local')
|
||||
})
|
||||
|
||||
it('uses the header copy feedback and copies the workspace-relative path separately', () => {
|
||||
renderMenu()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Copy Path' }))
|
||||
expect(onCopyPath).toHaveBeenCalledOnce()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Copy Relative Path' }))
|
||||
expect(writeClipboardText).toHaveBeenCalledWith('src/file.ts')
|
||||
})
|
||||
|
||||
it.each([{ externalSshTargetId: 'remote' }, { runtimeEnvironmentId: 'paired-server' }])(
|
||||
'disables local reveal for a remote file: %j',
|
||||
(overrides) => {
|
||||
renderMenu(overrides)
|
||||
const reveal = screen.getByRole('menuitem', { name: 'Reveal in Finder Local only' })
|
||||
expect(reveal.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(reveal)
|
||||
expect(openInFileManager).not.toHaveBeenCalled()
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: 'Copy Relative Path' }).hasAttribute('data-disabled')
|
||||
).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses local reveal while the owning workspace is unresolved', () => {
|
||||
store.state = { repos: [], worktreesByRepo: {}, folderWorkspaces: [], projectGroups: [] }
|
||||
renderMenu()
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: /Reveal in Finder/ }).getAttribute('aria-disabled')
|
||||
).toBe('true')
|
||||
})
|
||||
|
||||
it('reveals local files even while a remote runtime is selected', () => {
|
||||
store.state.settings = { activeRuntimeEnvironmentId: 'paired-server' }
|
||||
renderMenu()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Reveal in Finder' }))
|
||||
expect(openInFileManager).toHaveBeenCalledWith(file.filePath, 'local')
|
||||
})
|
||||
|
||||
it('reveals a file in a folder workspace without a git worktree', () => {
|
||||
store.state = {
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
folderWorkspaces: [{ id: 'folder', projectGroupId: 'group', executionHostId: 'local' }],
|
||||
projectGroups: [{ id: 'group', connectionId: null }]
|
||||
}
|
||||
renderMenu({ worktreeId: 'folder:folder' })
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Reveal in Finder' }))
|
||||
expect(openInFileManager).toHaveBeenCalledWith(file.filePath, 'local')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Copy, ExternalLink } from 'lucide-react'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { LocalOnlyMenuHint } from '@/components/local-only-menu-hint'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { isRevealOrEditorOpenBlocked } from '@/lib/local-path-open-guard'
|
||||
import {
|
||||
getRevealInFileManagerLabel,
|
||||
getWorkspaceFileRevealOwner,
|
||||
revealInFileManager
|
||||
} from '@/lib/reveal-in-file-manager'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function EditorFileLocationMenuItems({
|
||||
file,
|
||||
onCopyPath
|
||||
}: {
|
||||
file: OpenFile
|
||||
onCopyPath: () => void
|
||||
}): React.JSX.Element {
|
||||
const revealOwner = useAppStore((state) =>
|
||||
getWorkspaceFileRevealOwner(state, file.worktreeId, {
|
||||
connectionId: file.externalSshTargetId ?? getConnectionIdFromState(state, file.worktreeId),
|
||||
runtimeEnvironmentId: file.runtimeEnvironmentId
|
||||
})
|
||||
)
|
||||
const revealBlocked = isRevealOrEditorOpenBlocked(revealOwner)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
disabled={revealBlocked}
|
||||
onSelect={() => void revealInFileManager(file.filePath, revealOwner)}
|
||||
>
|
||||
<ExternalLink />
|
||||
{getRevealInFileManagerLabel()}
|
||||
{revealBlocked ? <LocalOnlyMenuHint /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onCopyPath}>
|
||||
<Copy />
|
||||
{translate('auto.components.editor.EditorPanelHeader.7c08a1f990', 'Copy Path')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void window.api.ui.writeClipboardText(file.relativePath)}>
|
||||
<Copy />
|
||||
{translate('auto.components.editor.EditorPanelHeader.269ce4842b', 'Copy Relative Path')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+36
-8
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EditorPanelMarkdownActionsMenu } from './EditorPanelMarkdownActionsMenu'
|
||||
import { EditorPanelActionsMenu } from './EditorPanelActionsMenu'
|
||||
|
||||
const checkboxItems = vi.hoisted(() => ({
|
||||
list: [] as {
|
||||
@@ -41,17 +41,47 @@ vi.mock('@/components/ui/dropdown-menu', async () => {
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
describe('EditorPanelMarkdownActionsMenu', () => {
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: () => null
|
||||
}))
|
||||
|
||||
describe('EditorPanelActionsMenu', () => {
|
||||
beforeEach(() => {
|
||||
checkboxItems.list = []
|
||||
})
|
||||
|
||||
it('shows front matter as a view preference beside word wrap', () => {
|
||||
const onToggleMarkdownFrontmatter = vi.fn()
|
||||
renderToStaticMarkup(
|
||||
<EditorPanelActionsMenu
|
||||
isDiffSurface={false}
|
||||
diffWordWrap={false}
|
||||
diffShowWhitespace={false}
|
||||
editorWordWrap
|
||||
shouldShowMarkdownExportAction
|
||||
canExportMarkdownToPdf
|
||||
canShowMarkdownFrontmatterToggle
|
||||
markdownFrontmatterVisible
|
||||
onToggleDiffWordWrap={vi.fn()}
|
||||
onToggleDiffWhitespace={vi.fn()}
|
||||
onToggleEditorWordWrap={vi.fn()}
|
||||
onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter}
|
||||
onExportMarkdownToPdf={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(checkboxItems.list.map(({ label }) => label)).toEqual(['Word Wrap', 'Front Matter'])
|
||||
expect(checkboxItems.list[1]?.checked).toBe(true)
|
||||
checkboxItems.list[1]?.onCheckedChange?.(false)
|
||||
expect(onToggleMarkdownFrontmatter).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows Word Wrap for normal file tabs using editorWordWrap (#9974)', () => {
|
||||
const onToggleDiffWordWrap = vi.fn()
|
||||
const onToggleEditorWordWrap = vi.fn()
|
||||
renderToStaticMarkup(
|
||||
React.createElement(EditorPanelMarkdownActionsMenu, {
|
||||
isMarkdown: false,
|
||||
React.createElement(EditorPanelActionsMenu, {
|
||||
isDiffSurface: false,
|
||||
diffWordWrap: false,
|
||||
diffShowWhitespace: false,
|
||||
@@ -79,8 +109,7 @@ describe('EditorPanelMarkdownActionsMenu', () => {
|
||||
const onToggleDiffWordWrap = vi.fn()
|
||||
const onToggleEditorWordWrap = vi.fn()
|
||||
renderToStaticMarkup(
|
||||
React.createElement(EditorPanelMarkdownActionsMenu, {
|
||||
isMarkdown: false,
|
||||
React.createElement(EditorPanelActionsMenu, {
|
||||
isDiffSurface: true,
|
||||
diffWordWrap: true,
|
||||
diffShowWhitespace: false,
|
||||
@@ -107,8 +136,7 @@ describe('EditorPanelMarkdownActionsMenu', () => {
|
||||
it('shows and binds Show Whitespace on diff surfaces', () => {
|
||||
const onToggleDiffWhitespace = vi.fn()
|
||||
renderToStaticMarkup(
|
||||
React.createElement(EditorPanelMarkdownActionsMenu, {
|
||||
isMarkdown: false,
|
||||
React.createElement(EditorPanelActionsMenu, {
|
||||
isDiffSurface: true,
|
||||
diffWordWrap: false,
|
||||
diffShowWhitespace: true,
|
||||
+38
-45
@@ -1,5 +1,5 @@
|
||||
import type React from 'react'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import { FileDown, MoreHorizontal } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
type EditorPanelMarkdownActionsMenuProps = {
|
||||
isMarkdown: boolean
|
||||
type EditorPanelActionsMenuProps = {
|
||||
fileLocationActions?: React.ReactNode
|
||||
isDiffSurface: boolean
|
||||
/** Diff-only wrap preference; ignored for normal file tabs. */
|
||||
diffWordWrap: boolean
|
||||
@@ -30,8 +32,8 @@ type EditorPanelMarkdownActionsMenuProps = {
|
||||
onExportMarkdownToPdf: () => void
|
||||
}
|
||||
|
||||
export function EditorPanelMarkdownActionsMenu({
|
||||
isMarkdown,
|
||||
export function EditorPanelActionsMenu({
|
||||
fileLocationActions,
|
||||
isDiffSurface,
|
||||
diffWordWrap,
|
||||
diffShowWhitespace,
|
||||
@@ -45,32 +47,35 @@ export function EditorPanelMarkdownActionsMenu({
|
||||
onToggleEditorWordWrap,
|
||||
onToggleMarkdownFrontmatter,
|
||||
onExportMarkdownToPdf
|
||||
}: EditorPanelMarkdownActionsMenuProps): React.JSX.Element | null {
|
||||
const hasMarkdownActions =
|
||||
isMarkdown && (shouldShowMarkdownExportAction || canShowMarkdownFrontmatterToggle)
|
||||
}: EditorPanelActionsMenuProps): React.JSX.Element {
|
||||
// Why: normal files always get Word Wrap so long/structured lines can unwrap without Settings (#9974).
|
||||
const wordWrapChecked = isDiffSurface ? diffWordWrap : editorWordWrap
|
||||
const onToggleWordWrap = isDiffSurface ? onToggleDiffWordWrap : onToggleEditorWordWrap
|
||||
const menuLabel = fileLocationActions
|
||||
? translate('auto.components.editor.EditorPanelActionsMenu.fileActions', 'File actions')
|
||||
: translate('auto.components.editor.EditorPanelMarkdownActionsMenu.561251019a', 'More actions')
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label={translate(
|
||||
'auto.components.editor.EditorPanelMarkdownActionsMenu.561251019a',
|
||||
'More actions'
|
||||
)}
|
||||
title={translate(
|
||||
'auto.components.editor.EditorPanelMarkdownActionsMenu.561251019a',
|
||||
'More actions'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={menuLabel}>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{menuLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
{fileLocationActions ? (
|
||||
<>
|
||||
{fileLocationActions}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuCheckboxItem checked={wordWrapChecked} onCheckedChange={onToggleWordWrap}>
|
||||
{translate(
|
||||
'auto.components.editor.EditorPanelMarkdownActionsMenu.1eef809708',
|
||||
@@ -88,34 +93,22 @@ export function EditorPanelMarkdownActionsMenu({
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
) : null}
|
||||
{hasMarkdownActions ? <DropdownMenuSeparator /> : null}
|
||||
{canShowMarkdownFrontmatterToggle ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault()
|
||||
onToggleMarkdownFrontmatter()
|
||||
}}
|
||||
>
|
||||
{markdownFrontmatterVisible
|
||||
? translate(
|
||||
'auto.components.editor.EditorPanelMarkdownActionsMenu.10c39d58c1',
|
||||
'Hide front matter'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.editor.EditorPanelMarkdownActionsMenu.8c8b7f5ff5',
|
||||
'Show front matter'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{shouldShowMarkdownExportAction ? <DropdownMenuSeparator /> : null}
|
||||
</>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={markdownFrontmatterVisible}
|
||||
onCheckedChange={onToggleMarkdownFrontmatter}
|
||||
>
|
||||
{translate('auto.components.editor.EditorContent.e4b074749d', 'Front Matter')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
) : null}
|
||||
{shouldShowMarkdownExportAction ? <DropdownMenuSeparator /> : null}
|
||||
{shouldShowMarkdownExportAction ? (
|
||||
<DropdownMenuItem
|
||||
// Why: source/Monaco fallbacks have no rendered document DOM to export.
|
||||
disabled={!canExportMarkdownToPdf}
|
||||
onSelect={onExportMarkdownToPdf}
|
||||
>
|
||||
<FileDown />
|
||||
{translate(
|
||||
'auto.components.editor.EditorPanelMarkdownActionsMenu.3e0ce48c24',
|
||||
'Export as PDF'
|
||||
@@ -38,8 +38,13 @@ vi.mock('./EditorPanelHeaderPath', () => ({
|
||||
EditorPanelHeaderPath: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./EditorPanelMarkdownActionsMenu', () => ({
|
||||
EditorPanelMarkdownActionsMenu: () => null
|
||||
vi.mock('./EditorPanelActionsMenu', () => ({
|
||||
EditorPanelActionsMenu: ({ fileLocationActions }: { fileLocationActions?: React.ReactNode }) =>
|
||||
fileLocationActions ?? null
|
||||
}))
|
||||
|
||||
vi.mock('./EditorFileLocationMenuItems', () => ({
|
||||
EditorFileLocationMenuItems: () => <span data-file-location-actions />
|
||||
}))
|
||||
|
||||
vi.mock('@/components/artifacts/ArtifactPublishButton', () => ({
|
||||
@@ -102,6 +107,30 @@ function renderHeader(overrides: Partial<ComponentProps<typeof EditorPanelHeader
|
||||
}
|
||||
|
||||
describe('EditorPanelHeader', () => {
|
||||
it.each(['edit', 'markdown-preview'] as const)('offers file actions for %s tabs', (mode) => {
|
||||
expect(renderHeader({ activeFile: { ...activeFile, mode } })).toContain(
|
||||
'data-file-location-actions'
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['diff', 'conflict-review', 'check-details'] as const)(
|
||||
'does not offer on-disk file actions for %s documents',
|
||||
(mode) => {
|
||||
expect(renderHeader({ activeFile: { ...activeFile, mode } })).not.toContain(
|
||||
'data-file-location-actions'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('offers file actions for a diff with an openable working file', () => {
|
||||
expect(renderHeader({ isSingleDiff: true, openFileState: { canOpen: true } })).toContain(
|
||||
'data-file-location-actions'
|
||||
)
|
||||
expect(renderHeader({ isSingleDiff: true, openFileState: { canOpen: false } })).not.toContain(
|
||||
'data-file-location-actions'
|
||||
)
|
||||
})
|
||||
|
||||
it('shares one tooltip provider across the diff header controls', () => {
|
||||
const html = renderHeader()
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import EditorViewToggle, {
|
||||
import type { EditorToggleValue } from './EditorViewToggle'
|
||||
import type { EditorHeaderOpenFileState } from './editor-header'
|
||||
import { DiffNotesSendMenu } from './DiffNotesSendMenu'
|
||||
import { EditorPanelMarkdownActionsMenu } from './EditorPanelMarkdownActionsMenu'
|
||||
import { EditorPanelActionsMenu } from './EditorPanelActionsMenu'
|
||||
import { EditorFileLocationMenuItems } from './EditorFileLocationMenuItems'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { EditorPanelHeaderPath } from './EditorPanelHeaderPath'
|
||||
import { useDiffNavigation } from './diff-navigation-context'
|
||||
@@ -319,8 +320,14 @@ export function EditorPanelHeader({
|
||||
createRequest={createArtifactRequest}
|
||||
/>
|
||||
) : null}
|
||||
<EditorPanelMarkdownActionsMenu
|
||||
isMarkdown={isMarkdown}
|
||||
<EditorPanelActionsMenu
|
||||
fileLocationActions={
|
||||
activeFile.mode === 'edit' ||
|
||||
activeFile.mode === 'markdown-preview' ||
|
||||
(isSingleDiff && openFileState.canOpen) ? (
|
||||
<EditorFileLocationMenuItems file={activeFile} onCopyPath={onCopyPath} />
|
||||
) : undefined
|
||||
}
|
||||
isDiffSurface={isDiffSurface}
|
||||
diffWordWrap={diffWordWrap}
|
||||
diffShowWhitespace={diffShowWhitespace}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ImeInput } from '@/lib/ime-text-field'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Copy, ExternalLink, Eye, Pencil } from 'lucide-react'
|
||||
import { Eye, Pencil } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -12,21 +12,13 @@ import {
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { LocalOnlyMenuHint } from '@/components/local-only-menu-hint'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import {
|
||||
getRevealInFileManagerLabel,
|
||||
getWorkspaceFileRevealOwner,
|
||||
revealInFileManager
|
||||
} from '@/lib/reveal-in-file-manager'
|
||||
import { isRevealOrEditorOpenBlocked } from '@/lib/local-path-open-guard'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { isVirtualEditorFile } from '@/store/slices/editor/tabs/editor-tab-content-type'
|
||||
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus'
|
||||
import { useEditorHeaderFileRename } from './editor-header-file-rename'
|
||||
import { getEditorHeaderCopyState } from './editor-header'
|
||||
import { splitPathForDisplay } from './editor-path-display'
|
||||
import { EditorFileLocationMenuItems } from './EditorFileLocationMenuItems'
|
||||
|
||||
type EditorPanelHeaderPathProps = {
|
||||
activeFile: OpenFile
|
||||
@@ -51,14 +43,6 @@ export function EditorPanelHeaderPath({
|
||||
const canCopyHeaderPath = headerCopyState.copyText !== null
|
||||
// Why: virtual editor tabs use synthetic ids instead of on-disk paths.
|
||||
const isVirtualEditorTab = isVirtualEditorFile(activeFile)
|
||||
const revealOwner = useAppStore((s) =>
|
||||
getWorkspaceFileRevealOwner(s, activeFile.worktreeId, {
|
||||
connectionId:
|
||||
activeFile.externalSshTargetId ?? getConnectionIdFromState(s, activeFile.worktreeId),
|
||||
runtimeEnvironmentId: activeFile.runtimeEnvironmentId
|
||||
})
|
||||
)
|
||||
const revealBlocked = isRevealOrEditorOpenBlocked(revealOwner)
|
||||
const markdownPreviewShortcutLabel = useShortcutLabel('editor.markdownPreview')
|
||||
const {
|
||||
canRename,
|
||||
@@ -180,49 +164,20 @@ export function EditorPanelHeaderPath({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{!isVirtualEditorTab && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(activeFile.filePath)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate('auto.components.editor.EditorPanelHeader.7c08a1f990', 'Copy Path')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(activeFile.relativePath)
|
||||
}}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate(
|
||||
'auto.components.editor.EditorPanelHeader.269ce4842b',
|
||||
'Copy Relative Path'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
<EditorFileLocationMenuItems file={activeFile} onCopyPath={onCopyPath} />
|
||||
)}
|
||||
{canShowMarkdownPreview && (
|
||||
<DropdownMenuItem onSelect={onOpenMarkdownPreview}>
|
||||
<Eye className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate(
|
||||
'auto.components.editor.EditorPanelHeader.4157f3cbf3',
|
||||
'Open Markdown Preview'
|
||||
)}
|
||||
<DropdownMenuShortcut>{markdownPreviewShortcutLabel}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canShowMarkdownPreview && <DropdownMenuSeparator />}
|
||||
{!isVirtualEditorTab && (
|
||||
<DropdownMenuItem
|
||||
disabled={revealBlocked}
|
||||
onSelect={() => void revealInFileManager(activeFile.filePath, revealOwner)}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
|
||||
{getRevealInFileManagerLabel()}
|
||||
{revealBlocked ? <LocalOnlyMenuHint /> : null}
|
||||
</DropdownMenuItem>
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onOpenMarkdownPreview}>
|
||||
<Eye className="w-3.5 h-3.5 mr-1.5" />
|
||||
{translate(
|
||||
'auto.components.editor.EditorPanelHeader.4157f3cbf3',
|
||||
'Open Markdown Preview'
|
||||
)}
|
||||
<DropdownMenuShortcut>{markdownPreviewShortcutLabel}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
+4
@@ -386,6 +386,10 @@
|
||||
"d07e4b8553": "branch",
|
||||
"d16e037f40": "rich"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"10c39d58c1": "Hide front matter",
|
||||
"8c8b7f5ff5": "Show front matter"
|
||||
},
|
||||
"ReviewNotesSendMenuContent": {
|
||||
"50f7e753ea": "Sending notes to active agent...",
|
||||
"bb9c69a0c9": "Notes sent to active agent.",
|
||||
|
||||
@@ -15755,6 +15755,9 @@
|
||||
"2076ecfc9c": "Previous change",
|
||||
"631dab0df3": "Next change"
|
||||
},
|
||||
"EditorPanelActionsMenu": {
|
||||
"fileActions": "File actions"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "Export as PDF",
|
||||
"561251019a": "More actions",
|
||||
|
||||
Reference in New Issue
Block a user