mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user