fix(editor): persist PDF zoom across tabs and restarts (#21879)

* fix(editor): persist PDF zoom preferences

* fix(pdf): avoid path-only zoom persistence
This commit is contained in:
Jinwoo Hong
2026-09-21 00:24:20 -04:00
committed by GitHub
parent 7b97551acf
commit ba7583244b
6 changed files with 236 additions and 18 deletions
@@ -9,6 +9,7 @@ import { EditorDiffFileSurface } from './EditorDiffFileSurface'
import { EditorEditFileSurface } from './EditorEditFileSurface'
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
import type { FileContent } from './editor-panel-content-types'
import { buildPdfScalePreferenceKey } from './pdf-scale-preference-storage'
import { translate } from '@/i18n/i18n'
import { useEditorConflictNavigation } from './useEditorConflictNavigation'
import { useMarkdownDocuments } from './useMarkdownDocuments'
@@ -106,6 +107,9 @@ export function EditorContent({
viewStateScopeId === activeFile.id
? `${activeFile.filePath}:pdf`
: `${activeFile.filePath}::${viewStateScopeId}:pdf`
// Why: the same absolute path can exist in different worktrees, paired
// runtimes, or SSH targets; durable PDF zoom must not cross those owners.
const pdfPreferenceKey = buildPdfScalePreferenceKey(activeFile)
const monacoLanguage = resolvedLanguage === 'notebook' ? 'json' : resolvedLanguage
const reloadOpenCheckRunDetailsTab = useAppStore((state) => state.reloadOpenCheckRunDetailsTab)
const markdownDocuments = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave)
@@ -232,6 +236,7 @@ export function EditorContent({
editorViewStateKey={editorViewStateKey}
diffViewStateKey={diffViewStateKey}
pdfViewStateKey={pdfViewStateKey}
pdfPreferenceKey={pdfPreferenceKey}
fileContent={fileContents[activeFile.id]}
diffContent={diffContents[activeFile.id]}
editBuffer={editBuffers[activeFile.id]}
@@ -30,6 +30,7 @@ export function EditorEditFileSurface({
editorViewStateKey,
diffViewStateKey,
pdfViewStateKey,
pdfPreferenceKey,
fileContent,
diffContent,
editBuffer,
@@ -61,6 +62,7 @@ export function EditorEditFileSurface({
editorViewStateKey: string
diffViewStateKey: string
pdfViewStateKey: string
pdfPreferenceKey: string
fileContent: FileContent | undefined
diffContent: GitDiffResult | undefined
editBuffer: string | undefined
@@ -113,6 +115,7 @@ export function EditorEditFileSurface({
content={fileContent.content}
filePath={activeFile.filePath}
mimeType={fileContent.mimeType}
preferenceKey={pdfPreferenceKey}
scrollCacheKey={pdfViewStateKey}
/>
)
@@ -29,6 +29,9 @@ type ImageViewerProps = {
filePath: string
mimeType?: string
layout?: 'fill' | 'intrinsic'
// Why: callers without an owner identity (for example diff and conflict
// panes) must not persist a preference under a path-only key.
preferenceKey?: string | null
// Why: absent means "no PDF scroll memory" — diff and conflict-review callers
// mount several viewers on one path, so they deliberately pass nothing.
scrollCacheKey?: string | null
@@ -39,6 +42,7 @@ export default function ImageViewer({
filePath,
mimeType = FALLBACK_IMAGE_MIME_TYPE,
layout = 'fill',
preferenceKey,
scrollCacheKey = null
}: ImageViewerProps): JSX.Element {
const [isPopupOpen, setIsPopupOpen] = useState(false)
@@ -215,7 +219,12 @@ export default function ImageViewer({
if (isPdf) {
return (
<PdfViewer content={cleanedContent} filePath={filePath} scrollCacheKey={scrollCacheKey} />
<PdfViewer
content={cleanedContent}
filePath={filePath}
preferenceKey={preferenceKey}
scrollCacheKey={scrollCacheKey}
/>
)
}
@@ -23,6 +23,7 @@ import {
stepPdfScalePreference,
type PdfScalePreference
} from './pdf-scale-preference'
import { readPdfScalePreference, writePdfScalePreference } from './pdf-scale-preference-storage'
import { pdfViewPositionCache, setWithLRU } from '@/lib/scroll-cache'
import {
buildPdfScrollDestination,
@@ -44,6 +45,9 @@ const USER_SCROLL_INPUT_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown
type PdfViewerProps = {
content: string
filePath: string
// Why: callers that do not have an owner identity (for example diff and
// conflict panes) must not persist a preference under a path-only key.
preferenceKey?: string | null
// Why: absent means "no scroll memory" — the diff and conflict-review callers
// mount several viewers on one path, so a shared key would cross-write.
scrollCacheKey?: string | null
@@ -52,6 +56,7 @@ type PdfViewerProps = {
export default function PdfViewer({
content,
filePath,
preferenceKey = null,
scrollCacheKey = null
}: PdfViewerProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
@@ -65,22 +70,23 @@ export default function PdfViewer({
const findControllerRef = useRef<InstanceType<typeof PDFFindController> | null>(null)
const pdfViewerRef = useRef<InstanceType<typeof PdfJsViewer> | null>(null)
// Why: content reloads rebuild the pdf.js viewer; keep zoom across updates of
// the same file, and only reset when the open path changes.
// the same file and restore the durable preference after a remount or restart.
const scalePreferenceRef = useRef<PdfScalePreference>('page-width')
const filename = useMemo(() => filePath.split(/[/\\]/).pop() || filePath, [filePath])
const cleanedContent = useMemo(() => content.replace(/\s/g, ''), [content])
// Why: reset zoom to fit-width when the open path changes. An effect keeps the
// reset out of render (refs mutated in render can leak from discarded renders)
// and covers same-content/different-path opens the load effect skips.
// Why: restore the owner's preference outside render (refs mutated in render
// can leak from discarded renders) and cover same-content/different-path opens.
useEffect(() => {
scalePreferenceRef.current = 'page-width'
scalePreferenceRef.current = preferenceKey
? (readPdfScalePreference(preferenceKey) ?? 'page-width')
: 'page-width'
const viewer = pdfViewerRef.current
if (viewer) {
applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS)
applyPdfScalePreference(viewer, scalePreferenceRef.current, SCALE_BOUNDS)
}
}, [filePath])
}, [filePath, preferenceKey])
useEffect(() => {
const container = containerRef.current
@@ -306,15 +312,21 @@ export default function PdfViewer({
// Why: every zoom entry point (toolbar + keyboard) must record the scale
// preference so the next content reload restores it (see scalePreferenceRef).
const stepZoom = useCallback((direction: 'in' | 'out') => {
const viewer = pdfViewerRef.current
if (!viewer) {
return
}
const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS)
viewer.currentScale = next.scale
scalePreferenceRef.current = next.preference
}, [])
const stepZoom = useCallback(
(direction: 'in' | 'out') => {
const viewer = pdfViewerRef.current
if (!viewer) {
return
}
const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS)
viewer.currentScale = next.scale
scalePreferenceRef.current = next.preference
if (preferenceKey) {
writePdfScalePreference(preferenceKey, next.preference)
}
},
[preferenceKey]
)
const zoomIn = useCallback(() => stepZoom('in'), [stepZoom])
const zoomOut = useCallback(() => stepZoom('out'), [stepZoom])
@@ -326,7 +338,10 @@ export default function PdfViewer({
}
scalePreferenceRef.current = 'page-width'
applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS)
}, [])
if (preferenceKey) {
writePdfScalePreference(preferenceKey, 'page-width')
}
}, [preferenceKey])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildPdfScalePreferenceKey,
PDF_SCALE_PREFERENCES_STORAGE_KEY,
readPdfScalePreference,
writePdfScalePreference
} from './pdf-scale-preference-storage'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('PDF scale preference storage', () => {
it('keeps identical paths isolated by worktree and remote owner', () => {
const localKey = buildPdfScalePreferenceKey({ worktreeId: 'worktree-a', filePath: '/doc.pdf' })
const runtimeKey = buildPdfScalePreferenceKey({
worktreeId: 'worktree-a',
runtimeEnvironmentId: 'runtime-b',
filePath: '/doc.pdf'
})
const sshKey = buildPdfScalePreferenceKey({
worktreeId: 'worktree-a',
externalSshTargetId: 'ssh-c',
filePath: '/doc.pdf'
})
expect(new Set([localKey, runtimeKey, sshKey]).size).toBe(3)
})
it('round-trips a preference by file path', () => {
const storage = createMemoryStorage()
vi.stubGlobal('localStorage', storage)
writePdfScalePreference('/repo/report.pdf', 1.75)
expect(readPdfScalePreference('/repo/report.pdf')).toBe(1.75)
expect(readPdfScalePreference('/repo/other.pdf')).toBeNull()
})
it('persists fit-to-width resets and keeps files isolated', () => {
const storage = createMemoryStorage()
vi.stubGlobal('localStorage', storage)
writePdfScalePreference('/repo/report.pdf', 2)
writePdfScalePreference('/repo/other.pdf', 'page-width')
expect(readPdfScalePreference('/repo/report.pdf')).toBe(2)
expect(readPdfScalePreference('/repo/other.pdf')).toBe('page-width')
})
it('ignores malformed stored values', () => {
const storage = createMemoryStorage()
vi.stubGlobal('localStorage', storage)
storage.setItem(
PDF_SCALE_PREFERENCES_STORAGE_KEY,
JSON.stringify({ '/repo/report.pdf': { scale: 2 } })
)
expect(readPdfScalePreference('/repo/report.pdf')).toBeNull()
})
it('evicts the oldest entries after reaching the storage bound', () => {
const storage = createMemoryStorage()
vi.stubGlobal('localStorage', storage)
for (let index = 0; index < 101; index += 1) {
writePdfScalePreference(`/repo/report-${index}.pdf`, index)
}
expect(readPdfScalePreference('/repo/report-0.pdf')).toBeNull()
expect(readPdfScalePreference('/repo/report-100.pdf')).toBe(100)
})
it('ignores storage write failures', () => {
const storage = createMemoryStorage()
storage.setItem = () => {
throw new Error('storage unavailable')
}
vi.stubGlobal('localStorage', storage)
expect(() => writePdfScalePreference('/repo/report.pdf', 1.5)).not.toThrow()
})
})
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() {
return values.size
},
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => {
values.set(key, value)
}
}
}
@@ -0,0 +1,87 @@
import type { PdfScalePreference } from './pdf-scale-preference'
export const PDF_SCALE_PREFERENCES_STORAGE_KEY = 'orca.pdf.scale-preferences.v1'
const MAX_STORED_PREFERENCES = 100
export function buildPdfScalePreferenceKey(input: {
worktreeId: string
runtimeEnvironmentId?: string | null
externalSshTargetId?: string | null
filePath: string
}): string {
return JSON.stringify([
input.worktreeId,
input.runtimeEnvironmentId?.trim() || 'local',
input.externalSshTargetId?.trim() || null,
input.filePath
])
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isPdfScalePreference(value: unknown): value is PdfScalePreference {
return value === 'page-width' || (typeof value === 'number' && Number.isFinite(value))
}
function readStoredPreferences(storage: Storage): Record<string, unknown> {
try {
const raw = storage.getItem(PDF_SCALE_PREFERENCES_STORAGE_KEY)
if (!raw) {
return {}
}
const parsed: unknown = JSON.parse(raw)
return isRecord(parsed) ? parsed : {}
} catch {
return {}
}
}
function getStorage(): Storage | null {
try {
return globalThis.localStorage === undefined ? null : globalThis.localStorage
} catch {
return null
}
}
/** Read the last zoom choice for a PDF, if one was persisted. */
export function readPdfScalePreference(preferenceKey: string): PdfScalePreference | null {
const storage = getStorage()
if (!storage) {
return null
}
const preference = readStoredPreferences(storage)[preferenceKey]
return isPdfScalePreference(preference) ? preference : null
}
/** Persist a PDF zoom choice across viewer remounts and app restarts. */
export function writePdfScalePreference(
preferenceKey: string,
preference: PdfScalePreference
): void {
const storage = getStorage()
if (!storage) {
return
}
const stored = readStoredPreferences(storage)
// Reinsert to keep recently used files at the end of the bounded map.
delete stored[preferenceKey]
stored[preferenceKey] = preference
const keys = Object.keys(stored)
while (keys.length > MAX_STORED_PREFERENCES) {
const oldestKey = keys.shift()
if (oldestKey !== undefined) {
delete stored[oldestKey]
}
}
try {
storage.setItem(PDF_SCALE_PREFERENCES_STORAGE_KEY, JSON.stringify(stored))
} catch {
// The viewer remains usable when browser storage is unavailable or full.
}
}