Show PDF export only in Markdown controls (#5548)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-17 10:51:41 -07:00 committed by GitHub
parent 0b3cfa7b46
commit 5429e659d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 262 additions and 165 deletions

View File

@ -446,8 +446,6 @@ export function setupGuestShortcutForwarding(args: {
renderer.send('ui:openTasks')
} else if (action?.type === 'openSettings') {
renderer.send('ui:openSettings')
} else if (action?.type === 'exportPdf') {
renderer.send('export:requestPdf')
} else if (action?.type === 'forceReload') {
renderer.reloadIgnoringCache()
} else if (action?.type === 'jumpToWorktreeIndex') {

View File

@ -189,12 +189,10 @@ describe('registerAppMenu', () => {
expect(template.find((item) => item.label === 'Orca')).toBeUndefined()
const fileLabels = getSubmenu(template, 'File').map((item) => item.label)
expect(fileLabels).not.toContain(`Export as PDF...\t${isMac ? '⌘⇧E' : 'Ctrl+Shift+E'}`)
expect(fileLabels[0]).toBe(`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`)
expect(fileLabels).toEqual(
expect.arrayContaining([
`Export as PDF...\t${isMac ? '⌘⇧E' : 'Ctrl+Shift+E'}`,
`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`,
'Exit'
])
expect.arrayContaining([`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`, 'Exit'])
)
const helpLabels = getSubmenu(template, 'Help').map((item) => item.label)
@ -218,10 +216,8 @@ describe('registerAppMenu', () => {
expect.arrayContaining(['Check for Updates...', `Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`])
)
// Why: on macOS File should NOT duplicate Settings/Exit — those live in
// the system app menu, so only Export belongs under File.
const fileLabels = getSubmenu(template, 'File').map((item) => item.label)
expect(fileLabels).not.toContain(`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`)
expect(fileLabels).not.toContain('Exit')
// the system app menu. Without global Export, there is no File item left.
expect(template.find((item) => item.label === 'File')).toBeUndefined()
const helpLabels = getSubmenu(template, 'Help').map((item) => item.label)
expect(helpLabels).toEqual([
'Report Crash...',

View File

@ -120,21 +120,6 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
click: (_menuItem, window) => onOpenCrashReport(window)
}
const exportPdfItem: Electron.MenuItemConstructorOptions = {
label: `${translateMain('menu.exportPdf', 'Export as PDF...')}\t${shortcutLabel('file.exportPdf')}`,
click: () => {
// Why: fire a one-way event into the focused renderer. The renderer
// owns the knowledge of whether a markdown surface is active and
// what DOM to extract — when no markdown surface is active this is
// a silent no-op on that side (see design doc §4 "Renderer UI
// trigger"). Keeping this as a send (not an invoke) avoids main
// needing to reason about surface state. Using
// BrowserWindow.getFocusedWindow() rather than the menu's
// focusedWindow param avoids the BaseWindow typing gap.
BrowserWindow.getFocusedWindow()?.webContents.send('export:requestPdf')
}
}
// Why: the macOS app-menu (named after the app) is mandatory on darwin and
// owns hide/hideOthers/unhide/services/quit roles that only make sense in
// the system menu bar. On Windows/Linux that menu would render as a
@ -159,19 +144,13 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
const fileMenu: Electron.MenuItemConstructorOptions = {
label: translateMain('menu.file', 'File'),
// Why: on Windows/Linux there is no app-named menu, so Settings and
// Quit live under File — matching the common platform convention and
// keeping all user-facing actions reachable from the in-window menu bar.
submenu: [
exportPdfItem,
// Why: on Windows/Linux there is no app-named menu, so Settings and
// Quit live under File — matching the common platform convention and
// keeping all user-facing actions reachable from the in-window menu bar.
...(isMac
? []
: ([
{ type: 'separator' },
settingsItem,
{ type: 'separator' },
{ role: 'quit', label: translateMain('menu.exit', 'Exit') }
] satisfies Electron.MenuItemConstructorOptions[]))
settingsItem,
{ type: 'separator' },
{ role: 'quit', label: translateMain('menu.exit', 'Exit') }
]
}
@ -312,7 +291,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
const template: Electron.MenuItemConstructorOptions[] = [
...(isMac ? [macAppMenu] : []),
fileMenu,
...(isMac ? [] : [fileMenu]),
editMenu,
viewMenu,
windowMenu,

View File

@ -781,11 +781,6 @@ export function createMainWindow(
return
}
if (action.type === 'exportPdf') {
mainWindow.webContents.send('export:requestPdf')
return
}
if (action.type === 'forceReload') {
opts?.onBeforeReload?.({
ignoreCache: true,

View File

@ -2339,7 +2339,6 @@ export type PreloadApi = {
onCtrlTabKeyUp: (callback: () => void) => () => void
onToggleStatusBar: (callback: () => void) => () => void
onDictationKeyDown: (callback: () => void) => () => void
onExportPdfRequested: (callback: () => void) => () => void
onActivateWorktree: (
callback: (data: {
repoId: string

View File

@ -2910,11 +2910,6 @@ const api = {
ipcRenderer.on('ui:toggleStatusBar', listener)
return () => ipcRenderer.removeListener('ui:toggleStatusBar', listener)
},
onExportPdfRequested: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('export:requestPdf', listener)
return () => ipcRenderer.removeListener('export:requestPdf', listener)
},
onDictationKeyDown: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:dictationKeyDown', listener)

View File

@ -19,9 +19,9 @@ import {
} from './ConflictComponents'
import type { MarkdownViewMode, OpenFile, PendingEditorReveal } from '@/store/slices/editor'
import type { GitStatusEntry, GitDiffResult } from '../../../../shared/types'
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
import { getMarkdownRenderMode } from './markdown-render-mode'
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter'
import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary'
import { useMarkdownDocuments } from './useMarkdownDocuments'
@ -41,11 +41,6 @@ const MermaidViewer = lazy(() => import('./MermaidViewer'))
const CsvViewer = lazy(() => import('./CsvViewer'))
const IpynbViewer = lazy(() => import('./IpynbViewer'))
const richMarkdownSizeEncoder = new TextEncoder()
// Why: encodeInto() with a pre-allocated buffer avoids creating a new
// Uint8Array on every render, reducing GC pressure for large files.
const richMarkdownSizeBuffer = new Uint8Array(RICH_MARKDOWN_MAX_SIZE_BYTES + 1)
export function getMarkdownSourceLineOffset(frontMatterRaw: string): number {
return (frontMatterRaw.match(/\r\n|\r|\n/g) ?? []).length
}
@ -326,12 +321,7 @@ export function EditorContent({
const currentContent = editBuffers[activeFile.id] ?? fc.content
const richModeUnsupportedMessage = getMarkdownRichModeUnsupportedMessage(currentContent)
const renderMode = getMarkdownRenderMode({
// Why: the threshold is defined in bytes because large pasted Unicode
// documents can exceed ProseMirror's performance envelope long before
// JS string length reaches the same numeric value.
exceedsRichModeSizeLimit:
richMarkdownSizeEncoder.encodeInto(currentContent, richMarkdownSizeBuffer).written >
RICH_MARKDOWN_MAX_SIZE_BYTES,
exceedsRichModeSizeLimit: exceedsMarkdownRichModeSizeLimit(currentContent),
hasRichModeUnsupportedContent: richModeUnsupportedMessage !== null,
viewMode: mdViewMode
})

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import { detectLanguage } from '@/lib/language-detect'
@ -10,7 +10,6 @@ import { requestEditorFileSave } from './editor-autosave'
import { exportActiveMarkdownToPdf } from './export-active-markdown'
import type { EditorToggleValue } from './EditorViewToggle'
import { EditorPanelShell } from './EditorPanelShell'
import { acquireExportPdfListener } from './editor-panel-export-pdf-listener'
import { canUseChangesModeForFile } from './editor-panel-file-mode'
import { getEditorPanelRenderModel } from './editor-panel-render-model'
import { useClosedEditorTabCleanup } from './useClosedEditorTabCleanup'
@ -112,7 +111,6 @@ function EditorPanelInner({
handleRenameConfirm
} = useUntitledFileRename({ openFiles, closeFile, openFile, clearUntitled })
useEffect(() => acquireExportPdfListener(), [])
useClosedEditorTabCleanup(openFiles)
useMarkdownPreviewShortcut({ activeFile, panelRef, openMarkdownPreview })
@ -225,6 +223,7 @@ function EditorPanelInner({
const model = getEditorPanelRenderModel({
activeFile,
fileContents,
editorDrafts,
gitStatusByWorktree,
gitBranchChangesByWorktree,
markdownViewMode,
@ -365,7 +364,9 @@ function EditorPanelInner({
!isMarkdownFrontmatterVisible
)
}
onExportMarkdownToPdf={() => void exportActiveMarkdownToPdf()}
onExportMarkdownToPdf={() =>
void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current })
}
onContentChange={handleContentChange}
onContentChangeForFile={handleContentChangeForFile}
onDirtyStateHint={handleDirtyStateHint}

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { Columns2, Eye, FileText, ListTree, Rows2 } from 'lucide-react'
import { useAppStore } from '@/store'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import type { OpenFile } from '@/store/slices/editor'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import EditorViewToggle, {
CSV_VIEW_MODE_METADATA,
@ -25,12 +25,12 @@ type EditorPanelHeaderProps = {
hasEditorToggle: boolean
availableEditorToggleModes: readonly EditorToggleValue[]
effectiveToggleValue: EditorToggleValue
mdViewMode: MarkdownViewMode
hasViewModeToggle: boolean
canOpenPreviewToSide: boolean
canShowMarkdownPreview: boolean
canShowMarkdownTableOfContents: boolean
isMarkdownTableOfContentsDisabled: boolean
shouldShowMarkdownExportAction: boolean
canExportMarkdownToPdf: boolean
showMarkdownTableOfContents: boolean
canShowMarkdownFrontmatterToggle: boolean
markdownFrontmatterVisible: boolean
@ -59,12 +59,12 @@ export function EditorPanelHeader({
hasEditorToggle,
availableEditorToggleModes,
effectiveToggleValue,
mdViewMode,
hasViewModeToggle,
canOpenPreviewToSide,
canShowMarkdownPreview,
canShowMarkdownTableOfContents,
isMarkdownTableOfContentsDisabled,
shouldShowMarkdownExportAction,
canExportMarkdownToPdf,
showMarkdownTableOfContents,
canShowMarkdownFrontmatterToggle,
markdownFrontmatterVisible,
@ -246,8 +246,8 @@ export function EditorPanelHeader({
)}
<EditorPanelMarkdownActionsMenu
isMarkdown={isMarkdown}
hasViewModeToggle={hasViewModeToggle}
mdViewMode={mdViewMode}
shouldShowMarkdownExportAction={shouldShowMarkdownExportAction}
canExportMarkdownToPdf={canExportMarkdownToPdf}
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
markdownFrontmatterVisible={markdownFrontmatterVisible}
onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter}

View File

@ -1,6 +1,5 @@
import type React from 'react'
import { MoreHorizontal } from 'lucide-react'
import type { MarkdownViewMode } from '@/store/slices/editor'
import {
DropdownMenu,
DropdownMenuContent,
@ -12,8 +11,8 @@ import { translate } from '@/i18n/i18n'
type EditorPanelMarkdownActionsMenuProps = {
isMarkdown: boolean
hasViewModeToggle: boolean
mdViewMode: MarkdownViewMode
shouldShowMarkdownExportAction: boolean
canExportMarkdownToPdf: boolean
canShowMarkdownFrontmatterToggle: boolean
markdownFrontmatterVisible: boolean
onToggleMarkdownFrontmatter: () => void
@ -22,14 +21,14 @@ type EditorPanelMarkdownActionsMenuProps = {
export function EditorPanelMarkdownActionsMenu({
isMarkdown,
hasViewModeToggle,
mdViewMode,
shouldShowMarkdownExportAction,
canExportMarkdownToPdf,
canShowMarkdownFrontmatterToggle,
markdownFrontmatterVisible,
onToggleMarkdownFrontmatter,
onExportMarkdownToPdf
}: EditorPanelMarkdownActionsMenuProps): React.JSX.Element | null {
if (!isMarkdown || (!hasViewModeToggle && !canShowMarkdownFrontmatterToggle)) {
if (!isMarkdown || (!shouldShowMarkdownExportAction && !canShowMarkdownFrontmatterToggle)) {
return null
}
@ -70,15 +69,13 @@ export function EditorPanelMarkdownActionsMenu({
'Show front matter'
)}
</DropdownMenuItem>
{hasViewModeToggle ? <DropdownMenuSeparator /> : null}
{shouldShowMarkdownExportAction ? <DropdownMenuSeparator /> : null}
</>
) : null}
{hasViewModeToggle ? (
{shouldShowMarkdownExportAction ? (
<DropdownMenuItem
// Why: source/Monaco mode has no document DOM. Avoid polling the
// portal-mounted menu; exportActiveMarkdownToPdf is a safe no-op
// when no rendered markdown subtree is found.
disabled={mdViewMode === 'source'}
// Why: source/Monaco fallbacks have no rendered document DOM to export.
disabled={!canExportMarkdownToPdf}
onSelect={onExportMarkdownToPdf}
>
{translate(

View File

@ -106,12 +106,12 @@ export function EditorPanelShell({
hasEditorToggle={model.hasEditorToggle}
availableEditorToggleModes={model.availableEditorToggleModes}
effectiveToggleValue={model.effectiveToggleValue}
mdViewMode={model.mdViewMode}
hasViewModeToggle={model.hasViewModeToggle}
canOpenPreviewToSide={model.canOpenPreviewToSide}
canShowMarkdownPreview={model.canShowMarkdownPreview}
canShowMarkdownTableOfContents={model.canShowMarkdownTableOfContents}
isMarkdownTableOfContentsDisabled={model.isMarkdownTableOfContentsDisabled}
shouldShowMarkdownExportAction={model.shouldShowMarkdownExportAction}
canExportMarkdownToPdf={model.canExportMarkdownToPdf}
showMarkdownTableOfContents={showMarkdownTableOfContents}
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
markdownFrontmatterVisible={markdownFrontmatterVisible}

View File

@ -1,23 +0,0 @@
import { exportActiveMarkdownToPdf } from './export-active-markdown'
// Why: the "File -> Export as PDF..." menu IPC fans out to every EditorPanel
// instance, and split-pane layouts mount N panels concurrently. This ref-counted
// singleton keeps exactly one renderer subscription alive while any panel exists.
let exportPdfListenerOwners = 0
let exportPdfListenerUnsubscribe: (() => void) | null = null
export function acquireExportPdfListener(): () => void {
exportPdfListenerOwners += 1
if (exportPdfListenerOwners === 1) {
exportPdfListenerUnsubscribe = window.api.ui.onExportPdfRequested(() => {
void exportActiveMarkdownToPdf()
})
}
return () => {
exportPdfListenerOwners -= 1
if (exportPdfListenerOwners === 0 && exportPdfListenerUnsubscribe) {
exportPdfListenerUnsubscribe()
exportPdfListenerUnsubscribe = null
}
}
}

View File

@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
import type { FileContent } from './editor-panel-content-types'
import { getEditorPanelRenderModel } from './editor-panel-render-model'
function markdownFile(overrides: Partial<OpenFile> = {}): OpenFile {
return {
id: '/repo/README.md',
filePath: '/repo/README.md',
relativePath: 'README.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit',
isDirty: false,
...overrides
}
}
function textContent(overrides: Partial<FileContent> = {}): FileContent {
return {
content: '# Hello',
isBinary: false,
...overrides
}
}
function renderModel(args: {
activeFile?: OpenFile
fileContents?: Record<string, FileContent>
editorDrafts?: Record<string, string>
markdownViewMode?: Record<string, 'source' | 'rich' | 'preview'>
isChangesMode?: boolean
}) {
return getEditorPanelRenderModel({
activeFile: args.activeFile ?? markdownFile(),
fileContents: args.fileContents ?? { '/repo/README.md': textContent() },
editorDrafts: args.editorDrafts ?? {},
gitStatusByWorktree: {},
gitBranchChangesByWorktree: {},
markdownViewMode: args.markdownViewMode ?? {},
isChangesMode: args.isChangesMode ?? false
})
}
describe('getEditorPanelRenderModel markdown export affordance', () => {
it('enables export for rendered markdown edit tabs', () => {
expect(renderModel({}).canExportMarkdownToPdf).toBe(true)
})
it('disables export when an inline markdown tab renders Changes mode', () => {
expect(renderModel({ isChangesMode: true }).canExportMarkdownToPdf).toBe(false)
})
it('uses unsaved drafts when resolving rich markdown fallback', () => {
const model = renderModel({
markdownViewMode: { '/repo/README.md': 'rich' },
editorDrafts: { '/repo/README.md': '[example]: https://example.com' }
})
expect(model.canExportMarkdownToPdf).toBe(false)
})
it('disables rich export when a multibyte character crosses the byte limit', () => {
const model = renderModel({
markdownViewMode: { '/repo/README.md': 'rich' },
editorDrafts: { '/repo/README.md': `${'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES)}\u00e9` }
})
expect(model.shouldShowMarkdownExportAction).toBe(true)
expect(model.canExportMarkdownToPdf).toBe(false)
})
it('disables edit export while content is still loading, even with a draft', () => {
const model = renderModel({
fileContents: {},
editorDrafts: { '/repo/README.md': '# Draft' }
})
expect(model.shouldShowMarkdownExportAction).toBe(true)
expect(model.canExportMarkdownToPdf).toBe(false)
})
it('enables export for loaded markdown preview tabs', () => {
const preview = markdownFile({
id: 'preview:/repo/README.md',
mode: 'markdown-preview',
markdownPreviewSourceFileId: '/repo/README.md'
} as Partial<OpenFile>)
expect(
renderModel({
activeFile: preview,
fileContents: { [preview.id]: textContent() }
}).canExportMarkdownToPdf
).toBe(true)
})
it('disables preview export until rendered content can exist', () => {
const preview = markdownFile({
id: 'preview:/repo/README.md',
mode: 'markdown-preview',
markdownPreviewSourceFileId: '/repo/README.md'
} as Partial<OpenFile>)
expect(renderModel({ activeFile: preview, fileContents: {} }).canExportMarkdownToPdf).toBe(
false
)
expect(
renderModel({
activeFile: preview,
fileContents: { [preview.id]: textContent({ loadError: 'missing' }) }
}).canExportMarkdownToPdf
).toBe(false)
expect(
renderModel({
activeFile: preview,
fileContents: { [preview.id]: textContent({ isBinary: true }) }
}).canExportMarkdownToPdf
).toBe(false)
})
})

View File

@ -12,12 +12,16 @@ import { getEditorHeaderOpenFileState } from './editor-header'
import type { EditorToggleValue } from './EditorViewToggle'
import type { FileContent } from './editor-panel-content-types'
import { canUseChangesModeForFile } from './editor-panel-file-mode'
import { getMarkdownRenderMode } from './markdown-render-mode'
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
type StoreState = ReturnType<typeof useAppStore.getState>
type EditorPanelRenderModelParams = {
activeFile: OpenFile
fileContents: Record<string, FileContent>
editorDrafts: StoreState['editorDrafts']
gitStatusByWorktree: StoreState['gitStatusByWorktree']
gitBranchChangesByWorktree: StoreState['gitBranchChangesByWorktree']
markdownViewMode: StoreState['markdownViewMode']
@ -27,6 +31,7 @@ type EditorPanelRenderModelParams = {
export function getEditorPanelRenderModel({
activeFile,
fileContents,
editorDrafts,
gitStatusByWorktree,
gitBranchChangesByWorktree,
markdownViewMode,
@ -98,6 +103,36 @@ export function getEditorPanelRenderModel({
: hasViewModeToggle
? mdViewMode
: 'edit'
const inlineMarkdownContent =
activeFile.mode === 'edit'
? (editorDrafts[activeFile.id] ?? fileContents[activeFile.id]?.content ?? null)
: null
const shouldShowMarkdownExportAction =
resolvedLanguage === 'markdown' &&
(activeFile.mode === 'edit' || activeFile.mode === 'markdown-preview')
const inlineMarkdownRenderMode =
activeFile.mode === 'edit' && inlineMarkdownContent !== null
? getMarkdownRenderMode({
exceedsRichModeSizeLimit: exceedsMarkdownRichModeSizeLimit(inlineMarkdownContent),
hasRichModeUnsupportedContent:
getMarkdownRichModeUnsupportedMessage(inlineMarkdownContent) !== null,
viewMode: mdViewMode
})
: null
const canExportMarkdownToPdf =
shouldShowMarkdownExportAction &&
((activeFile.mode === 'markdown-preview' &&
fileContents[activeFile.id] !== undefined &&
fileContents[activeFile.id]?.isBinary !== true &&
!fileContents[activeFile.id]?.loadError) ||
(activeFile.mode === 'edit' &&
fileContents[activeFile.id] !== undefined &&
!isChangesMode &&
inlineMarkdownRenderMode !== null &&
inlineMarkdownRenderMode !== 'source' &&
fileContents[activeFile.id]?.isBinary !== true &&
!fileContents[activeFile.id]?.loadError &&
activeFile.conflict?.conflictStatus !== 'unresolved'))
return {
isSingleDiff,
isDiffSurface: isSingleDiff || isChangesMode,
@ -120,6 +155,8 @@ export function getEditorPanelRenderModel({
hasEditorToggle: availableEditorToggleModes.length > 1,
effectiveToggleValue,
isMarkdownTableOfContentsDisabled: hasViewModeToggle && mdViewMode === 'source',
shouldShowMarkdownExportAction,
canExportMarkdownToPdf,
canShowMarkdownTableOfContents:
resolvedLanguage === 'markdown' &&
(hasViewModeToggle || activeFile.mode === 'markdown-preview'),

View File

@ -3,16 +3,17 @@ import { getActiveMarkdownExportPayload } from './markdown-export-extract'
import { translate } from '@/i18n/i18n'
/**
* Export the currently-active markdown document to PDF via the main-process
* IPC bridge. Silent no-op when no markdown surface is active the menu
* item and overflow action can both share this entry point.
* Export the markdown document for a local editor panel through the existing
* PDF bridge. Silent no-op when the panel no longer has rendered markdown.
*/
export async function exportActiveMarkdownToPdf(): Promise<void> {
const payload = getActiveMarkdownExportPayload()
export async function exportActiveMarkdownToPdf(options: {
fileId: string
root: ParentNode | null
}): Promise<void> {
const payload = getActiveMarkdownExportPayload(options)
if (!payload) {
// Why: design doc §5 — menu-triggered export with no markdown surface is
// a silent no-op. The overflow-menu item is disabled in that case so we
// only reach this branch for stray menu shortcuts.
// Why: stale panel refs can survive a dropdown click; keep export defensive
// even though the local Markdown menu disables unreachable states.
return
}

View File

@ -31,29 +31,28 @@ function basenameWithoutExt(filePath: string): string {
return dot > 0 ? base.slice(0, dot) : base
}
/**
* Locate the active markdown document DOM subtree. v1 uses a scoped query
* over the whole document: there is only one active markdown surface at a
* time, and both preview and rich modes paint a uniquely-classed container.
* If multi-pane split view ever makes multiple surfaces visible at once,
* this contract must be revisited (see design doc §4).
*/
function findActiveDocumentSubtree(): Element | null {
return document.querySelector(DOCUMENT_SUBTREE_SELECTOR)
function findDocumentSubtree(root: ParentNode): Element | null {
return root.querySelector(DOCUMENT_SUBTREE_SELECTOR)
}
/**
* Extract a clean, self-contained HTML export payload from the active
* markdown surface. Returns null when no markdown document is active or the
* Extract a clean, self-contained HTML export payload from a panel-scoped
* markdown surface. Returns null when the requested file is stale or the
* surface is in a mode (Monaco source) that does not render a document DOM.
*/
export function getActiveMarkdownExportPayload(): MarkdownExportPayload | null {
const state = useAppStore.getState()
if (state.activeTabType !== 'editor') {
export function getActiveMarkdownExportPayload({
fileId,
root
}: {
fileId: string
root: ParentNode | null
}): MarkdownExportPayload | null {
if (!root) {
return null
}
const activeFile = state.openFiles.find((f) => f.id === state.activeFileId)
if (!activeFile || activeFile.mode !== 'edit') {
const state = useAppStore.getState()
const activeFile = state.openFiles.find((f) => f.id === fileId)
if (!activeFile || (activeFile.mode !== 'edit' && activeFile.mode !== 'markdown-preview')) {
return null
}
const language = detectLanguage(activeFile.filePath)
@ -61,7 +60,7 @@ export function getActiveMarkdownExportPayload(): MarkdownExportPayload | null {
return null
}
const subtree = findActiveDocumentSubtree()
const subtree = findDocumentSubtree(root)
if (!subtree) {
return null
}

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
describe('exceedsMarkdownRichModeSizeLimit', () => {
it('allows markdown at the rich-mode byte limit', () => {
expect(exceedsMarkdownRichModeSizeLimit('a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES))).toBe(false)
})
it('detects markdown over the byte limit', () => {
expect(exceedsMarkdownRichModeSizeLimit('a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1))).toBe(
true
)
})
it('detects unread multibyte content at the byte boundary', () => {
expect(
exceedsMarkdownRichModeSizeLimit(`${'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES)}\u00e9`)
).toBe(true)
})
})

View File

@ -0,0 +1,14 @@
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
const richMarkdownSizeEncoder = new TextEncoder()
// Why: rich-mode eligibility is checked during render-model work, so this
// avoids allocating a large Uint8Array every time markdown content changes.
const richMarkdownSizeBuffer = new Uint8Array(RICH_MARKDOWN_MAX_SIZE_BYTES + 1)
export function exceedsMarkdownRichModeSizeLimit(markdownContent: string): boolean {
const probe = richMarkdownSizeEncoder.encodeInto(markdownContent, richMarkdownSizeBuffer)
// Why: encodeInto() never writes partial UTF-8 sequences. A multibyte
// character can leave written at the exact limit while unread content remains.
return probe.written > RICH_MARKDOWN_MAX_SIZE_BYTES || probe.read < markdownContent.length
}

View File

@ -37,7 +37,6 @@
"exploreOrca": "Explore Orca",
"gettingStarted": "Getting Started with Orca",
"reportCrash": "Report Crash...",
"exportPdf": "Export as PDF...",
"file": "File",
"exit": "Exit",
"edit": "Edit",

View File

@ -37,7 +37,6 @@
"exploreOrca": "Explorar Orca",
"gettingStarted": "Empezando con Orca",
"reportCrash": "Informar fallo...",
"exportPdf": "Exportar como PDF...",
"file": "Archivo",
"exit": "Salida",
"edit": "Editar",

View File

@ -37,7 +37,6 @@
"exploreOrca": "Orca を探索",
"gettingStarted": "Orca を使い始める",
"reportCrash": "クラッシュを報告...",
"exportPdf": "PDF としてエクスポート...",
"file": "ファイル",
"exit": "終了",
"edit": "編集",

View File

@ -37,7 +37,6 @@
"exploreOrca": "Orca 둘러보기",
"gettingStarted": "Orca 시작하기",
"reportCrash": "크래시 신고...",
"exportPdf": "PDF로 내보내기...",
"file": "파일",
"exit": "종료",
"edit": "편집",

View File

@ -37,7 +37,6 @@
"exploreOrca": "探索 Orca",
"gettingStarted": "Orca 入门",
"reportCrash": "发送错误报告...",
"exportPdf": "导出为 PDF...",
"file": "文件",
"exit": "退出",
"edit": "编辑",

View File

@ -2015,7 +2015,6 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onCtrlTabKeyUp: () => noopUnsubscribe,
onToggleStatusBar: () => noopUnsubscribe,
onDictationKeyDown: () => noopUnsubscribe,
onExportPdfRequested: () => noopUnsubscribe,
onActivateWorktree: () => noopUnsubscribe,
onCreateTerminal: () => noopUnsubscribe,
onRequestTerminalCreate: () => noopUnsubscribe,

View File

@ -437,11 +437,7 @@ describe('keybindings', () => {
expect(conflicts).toContainEqual({
binding: 'Mod+Shift+E',
actionIds: expect.arrayContaining([
'file.exportPdf',
'sidebar.explorer.toggle',
'worktree.palette'
])
actionIds: expect.arrayContaining(['sidebar.explorer.toggle', 'worktree.palette'])
})
})

View File

@ -34,7 +34,6 @@ export type KeybindingActionId =
| 'worktree.navigateDown'
| 'app.settings'
| 'app.forceReload'
| 'file.exportPdf'
| 'workspace.create'
| 'workspace.rename'
| 'workspace.delete'
@ -202,15 +201,6 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
defaultBindings: platformBindings(['Mod+Shift+R']),
conflictGroup: 'menu'
},
{
id: 'file.exportPdf',
title: 'Export as PDF',
group: 'Global',
scope: 'global',
searchKeywords: ['shortcut', 'export', 'pdf', 'markdown'],
defaultBindings: platformBindings(['Mod+Shift+E']),
conflictGroup: 'menu'
},
{
id: 'worktree.palette',
title: 'Switch worktree',

View File

@ -160,13 +160,16 @@ describe('resolveWindowShortcutAction', () => {
).toEqual({ type: 'jumpToTabIndex', index: 2 })
})
it('routes menu-backed actions through the same window shortcut policy', () => {
it('does not resolve the removed PDF export shortcut globally', () => {
expect(
resolveWindowShortcutAction(
{ code: 'KeyE', key: 'e', meta: true, control: false, alt: false, shift: true },
'darwin'
)
).toEqual({ type: 'exportPdf' })
).toBeNull()
})
it('routes menu-backed actions through the same window shortcut policy', () => {
expect(
resolveWindowShortcutAction(
{ code: 'KeyR', key: 'r', meta: false, control: true, alt: false, shift: true },

View File

@ -26,7 +26,6 @@ export type WindowShortcutInput = {
export type WindowShortcutAction =
| { type: 'zoom'; direction: 'in' | 'out' | 'reset' }
| { type: 'openSettings' }
| { type: 'exportPdf' }
| { type: 'forceReload' }
| { type: 'toggleWorktreePalette' }
| { type: 'toggleFloatingTerminal' }
@ -190,10 +189,6 @@ export function resolveWindowShortcutAction(
return { type: 'openSettings' }
}
if (actionMatches('file.exportPdf', input, platform, keybindings, options)) {
return { type: 'exportPdf' }
}
if (actionMatches('app.forceReload', input, platform, keybindings, options)) {
return { type: 'forceReload' }
}
@ -280,8 +275,6 @@ export function getWindowShortcutActionId(action: WindowShortcutAction): Keybind
: 'zoom.reset'
case 'openSettings':
return 'app.settings'
case 'exportPdf':
return 'file.exportPdf'
case 'forceReload':
return 'app.forceReload'
case 'toggleWorktreePalette':