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. + } +}