diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx
index 86527b25f29..c488bc1d25d 100644
--- a/src/renderer/src/components/editor/EditorContent.tsx
+++ b/src/renderer/src/components/editor/EditorContent.tsx
@@ -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]}
diff --git a/src/renderer/src/components/editor/EditorEditFileSurface.tsx b/src/renderer/src/components/editor/EditorEditFileSurface.tsx
index a9e3f8ff856..552689c3314 100644
--- a/src/renderer/src/components/editor/EditorEditFileSurface.tsx
+++ b/src/renderer/src/components/editor/EditorEditFileSurface.tsx
@@ -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}
/>
)
diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx
index b3e86bdfc81..73aaa09adc8 100644
--- a/src/renderer/src/components/editor/ImageViewer.tsx
+++ b/src/renderer/src/components/editor/ImageViewer.tsx
@@ -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 (
-
+
)
}
diff --git a/src/renderer/src/components/editor/PdfViewer.tsx b/src/renderer/src/components/editor/PdfViewer.tsx
index 8fdd45d2c72..71fb9656391 100644
--- a/src/renderer/src/components/editor/PdfViewer.tsx
+++ b/src/renderer/src/components/editor/PdfViewer.tsx
@@ -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(null)
@@ -65,22 +70,23 @@ export default function PdfViewer({
const findControllerRef = useRef | null>(null)
const pdfViewerRef = useRef | 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('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 => {
diff --git a/src/renderer/src/components/editor/pdf-scale-preference-storage.test.ts b/src/renderer/src/components/editor/pdf-scale-preference-storage.test.ts
new file mode 100644
index 00000000000..0c0ce1b0f51
--- /dev/null
+++ b/src/renderer/src/components/editor/pdf-scale-preference-storage.test.ts
@@ -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()
+ 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)
+ }
+ }
+}
diff --git a/src/renderer/src/components/editor/pdf-scale-preference-storage.ts b/src/renderer/src/components/editor/pdf-scale-preference-storage.ts
new file mode 100644
index 00000000000..6906570f036
--- /dev/null
+++ b/src/renderer/src/components/editor/pdf-scale-preference-storage.ts
@@ -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 {
+ 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 {
+ 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.
+ }
+}