fix(editor): scope default-pane reading positions by file owner (#34816)

Same-path files with different owners (runtime hosts or worktrees) shared
Monaco, rich-markdown, notebook, and PDF/media reading positions when the
tab id equals the file id. Key them by file id, matching the diff and
markdown preview keys.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 17:41:05 -07:00
committed by GitHub
co-authored by OrcaWin
parent 5470ff907c
commit 3b81adb330
2 changed files with 29 additions and 2 deletions
@@ -2,10 +2,12 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import { buildOwnedEditorFileId } from '@/store/slices/editor/file-ids/editor-file-ids'
const lifecycle = vi.hoisted(() => ({
events: [] as string[],
diffModelKeys: [] as string[],
viewStateKeys: new Array<string>(),
models: new Map<string, { content: string; undo: string[] }>(),
notebookProps: [] as {
fileId: string
@@ -70,7 +72,9 @@ vi.mock('@/lib/lazy-with-retry', async () => {
readOnly?: boolean
liveTail?: boolean
viewStateId?: string
viewStateKey: string
}) {
lifecycle.viewStateKeys.push(props.viewStateKey)
/* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */
React.useEffect(() => {
lifecycle.events.push(`mount:${props.filePath}`)
@@ -215,6 +219,7 @@ afterEach(() => {
cleanup()
lifecycle.events.length = 0
lifecycle.diffModelKeys.length = 0
lifecycle.viewStateKeys.length = 0
lifecycle.models.clear()
lifecycle.mountedProps.length = 0
lifecycle.notebookProps.length = 0
@@ -222,6 +227,28 @@ afterEach(() => {
})
describe('EditorContent Monaco lifecycle boundary', () => {
it('keeps default-pane view state separate for the same path on different runtime hosts', () => {
const filePath = '/repo/main.ts'
const worktreeId = 'repo::/repo'
const first = file(filePath, {
id: buildOwnedEditorFileId(filePath, worktreeId, 'host-a'),
runtimeEnvironmentId: 'host-a'
})
const second = file(filePath, {
id: buildOwnedEditorFileId(filePath, worktreeId, 'host-b'),
runtimeEnvironmentId: 'host-b'
})
const view = render(
<EditorContent {...props(first, 'first host')} viewStateScopeId={first.id} />
)
const firstKey = lifecycle.viewStateKeys.at(-1)
expect(firstKey).toBeTypeOf('string')
view.rerender(<EditorContent {...props(second, 'second host')} viewStateScopeId={second.id} />)
expect(lifecycle.viewStateKeys.at(-1)).not.toBe(firstKey)
})
it('unmounts the prior path before reconciling a stale retained target model', () => {
const first = file('/repo/first.ts')
const second = file('/repo/second.ts')
@@ -98,7 +98,7 @@ export function EditorContent({
}): React.JSX.Element {
const editorViewStateKey =
viewStateScopeId === activeFile.id
? activeFile.filePath
? activeFile.id
: `${activeFile.filePath}::${viewStateScopeId}`
const diffViewStateKey =
viewStateScopeId === activeFile.id ? activeFile.id : `${activeFile.id}::${viewStateScopeId}`
@@ -109,7 +109,7 @@ export function EditorContent({
// Why: only the single-pane edit path gets PDF scroll memory — diff and conflict review mount several viewers on one path.
const pdfViewStateKey =
viewStateScopeId === activeFile.id
? `${activeFile.filePath}:pdf`
? `${activeFile.id}:pdf`
: `${activeFile.filePath}::${viewStateScopeId}:pdf`
// Scope durable PDF zoom by owner: identical paths can belong to different worktrees or remote hosts.
const pdfPreferenceKey = buildPdfScalePreferenceKey(activeFile)