fix(editor): 预览刷新三件套——手动刷新按钮 + 切预览自动重载 + 预览保存后重载(fixes #167)

A. header 新增刷新按钮(toolbar 非空时显示),经 reloadSeq 重跑加载 effect;B. 编辑→预览切换时自动重载(dirty 或保存失败则抑制,避免丢草稿);C. 预览模式下 saveState 进入 saved 边沿触发一次重载。

新增 tests/editor-refresh.spec.tsx 4 用例(A 重载计数 / B 正常与 dirty 抑制 / C saved 边沿);全量 622 通过(其余 8 失败为既有 Windows 平台问题:pty-deps/side-card 的 AttachConsole)。
This commit is contained in:
MXY
2026-08-19 04:04:44 +08:00
parent 4a407d78b8
commit 4c875ceff7
3 changed files with 254 additions and 3 deletions
@@ -0,0 +1,27 @@
# 编辑器预览刷新三件套(issue #167)
日期:2026-08-19
## 问题(#167,无评论无修复)
`.md` 文件编辑保存后,切到预览不立即渲染新内容,需关闭文件重新打开。根因:`EditorHost` 的加载 effect 只依赖 `[sessionId, cwd, path, ctx, showEmpty]`——保存(fsWrite)与模式切换(setMode)都不重跑加载,且工具栏没有手动刷新入口。
## 方案(最小三件套,全部在 EditorHost.tsx)
| 编号 | 行为 | 实现 |
|------|------|------|
| A | 手动刷新按钮(文本类 viewer,即 toolbar 非空时显示) | 新增 `reloadSeq` state;effect 依赖加 `reloadSeq`;header 刷新按钮 `setReloadSeq(s => s + 1)` |
| B | 编辑保存后切回预览自动刷新 | header「预览」按钮 onClick:`toolbar.mode === 'edit'` 且非 dirty 且 saveState 非 failed → 先 `setReloadSeq(+1)` 再 `setMode('preview')`;dirty 时不刷新(草稿只在 CodeMirror 内,重载会丢) |
| C | 预览模式下保存成功后刷新 | 监听 `toolbar.saveState` 从非 `'saved'` 变为 `'saved'` 且 `mode === 'preview'` → `setReloadSeq(+1)` |
刷新语义:重载走既有 load effect(AbortController 会中止旧加载;重挂载后 TextEditor 内部 state 重置为 preview 模式——与「关闭重开」等价,但保留 tab 与面板状态)。
## 范围外(记录,不实现)
- LLM 改文件后自动刷新(磁盘指纹对比 + silent 不闪屏)——分形有先例,另行立项;
- image/pdf/binary viewer 的手动刷新(toolbar 为空,无入口;内容静态,低优先级)。
## 验证
- 单测:刷新按钮渲染 + 点击触发重载(mock fsRead 调用次数);B 的 dirty 抑制;C 的 saveState 边沿触发;
- 手工:编辑 md → 保存 → 切预览即时渲染;预览模式 Ctrl+S 后刷新;刷新按钮任意 viewer 可用。
+39 -3
View File
@@ -25,7 +25,7 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { createElement } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCheckOutline16, IconFolderOpen16, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { api, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx'
@@ -97,6 +97,9 @@ export function EditorHost(props: {
const path = tab.path ?? ''
const title = tab.title
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
// Manual refresh (issue #167): bumping the sequence re-runs the load effect
// with the same path/scope — the only reload entry besides open/close.
const [reloadSeq, setReloadSeq] = useState(0)
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
// reload. The snapshot is the bare boolean so unrelated store churn never
@@ -247,7 +250,20 @@ export function EditorHost(props: {
}
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true; controller.abort() }
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty])
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty, reloadSeq])
// Save-then-refresh in preview mode (issue #167 part C): the edge into
// 'saved' (never a lingering 'saved' state) triggers exactly one reload, so
// a preview-mode Ctrl+S shows the fresh content immediately. Edit mode is
// left alone — reloading would remount the editor and drop the caret.
const prevSaveState = useRef<EditorToolbarState['saveState'] | undefined>(undefined)
useEffect(() => {
const current = toolbar?.saveState
if (prevSaveState.current !== 'saved' && current === 'saved' && toolbar?.mode === 'preview') {
setReloadSeq(sequence => sequence + 1)
}
prevSaveState.current = current
}, [toolbar?.saveState, toolbar?.mode])
const treeOpen = treeOpenOf(tab)
/** Persist the panel flag on the tab (survives reloads with the layout). */
@@ -287,7 +303,16 @@ export function EditorHost(props: {
<button
type="button"
className={clsx(css.editorModeButton, toolbar.mode === 'preview' && css.editorModeActive)}
onClick={() => { controlsRef.current?.setMode('preview') }}
onClick={() => {
// Issue #167 part B: returning from edit to preview reloads so
// the preview renders the just-saved content. A dirty draft
// (or a failed save) suppresses the reload — the draft only
// lives in the editor instance and a remount would drop it.
if (toolbar.mode === 'edit' && toolbar.dirty !== true && toolbar.saveState !== 'failed') {
setReloadSeq(sequence => sequence + 1)
}
controlsRef.current?.setMode('preview')
}}
>
{t('preview')}
</button>
@@ -315,6 +340,17 @@ export function EditorHost(props: {
{saveLabel !== '' && (
<span className={clsx(css.editorStatus, toolbar?.saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>
)}
{toolbar !== null && (
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { setReloadSeq(sequence => sequence + 1) }}
>
<IconRefreshOutline14 size={14} />
</button>
)}
<button
type="button"
className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
+188
View File
@@ -0,0 +1,188 @@
/**
* EditorHost refresh behaviors (issue #167): the manual refresh button
* re-runs the load (A), returning from edit to preview reloads unless the
* draft is dirty or the save failed (B), and a preview-mode save reloads on
* the 'saved' edge (C). A mock viewer reports a hoisted toolbar so the host
* chrome renders exactly like the real text editor.
*/
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import type { Context } from '../src/context-types.ts'
import { EditorHost } from '../src/client/EditorHost.tsx'
import { createBetterSidebarService, type FileViewerProps } from '../src/client/service.ts'
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
const fsRead = vi.fn()
vi.mock('../src/client/api.ts', () => ({
api: { fsRead: (...args: unknown[]) => fsRead(...args) },
mediaUrl: () => '',
}))
/** One fsRead call counter reset per test. */
function reads(): number {
return fsRead.mock.calls.length
}
interface MockViewerProps {
initialMode?: 'preview' | 'edit'
dirty?: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onToolbarState?: (state: any) => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onToolbarControls?: (controls: any) => void
}
/** A viewer that hoists a real toolbar (mode toggle + save state). */
function MockTextViewer({ initialMode = 'preview', dirty = false, onToolbarState, onToolbarControls }: MockViewerProps) {
const [mode, setMode] = useState(initialMode)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
useEffect(() => {
onToolbarState?.({ modes: true, mode, dirty, editable: true, saveState })
}, [mode, saveState, dirty, onToolbarState])
useEffect(() => {
onToolbarControls?.({
setMode,
// A synchronous save (saving then saved in one batch) drives the edge.
save: () => { setSaveState('saving'); setSaveState('saved') },
})
return () => { onToolbarControls?.(null) }
}, [onToolbarControls])
return createElement('div', null, `mock-${mode}`)
}
function setup(initialMode: 'preview' | 'edit' = 'preview', dirty = false): {
ctx: Context
fileTab: () => SidebarTab
} {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
service.registerFileViewer({
id: 'mock-text',
exts: ['ts'],
priority: 0,
fetchStrategy: 'fsRead',
component: (props: FileViewerProps) => createElement(MockTextViewer, {
initialMode,
dirty,
onToolbarState: props.onToolbarState,
onToolbarControls: props.onToolbarControls,
}),
})
store.setSession('editor-home-session')
const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' }
const ctx = {
betterSidebar: service,
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
} as unknown as Context
ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' })
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
return { ctx, fileTab }
}
function mount(ctx: Context, tab: () => SidebarTab): { container: HTMLDivElement; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => {
root.render(createElement(EditorHost, {
ctx, store: ctx.betterSidebar as never, scope: { sessionId: 'editor-home-session' },
tab: tab(), expanded: [], onToggleDir: () => {}, onReferenceFile: () => {},
}))
})
return {
container,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
/** The header's refresh button (locale-dependent aria-label), or null. */
function refreshButton(container: HTMLDivElement): HTMLButtonElement | null {
return container.querySelector('button[aria-label="Refresh"], button[aria-label="刷新"]')
}
function click(button: HTMLButtonElement): void {
act(() => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
}
beforeEach(() => {
fsRead.mockReset()
fsRead.mockResolvedValue({ kind: 'text', content: 'hello', truncated: false })
})
describe('EditorHost refresh (issue #167)', () => {
it('A: the refresh button renders for a toolbar-reporting viewer and re-runs the load', async () => {
const { ctx, fileTab } = setup()
const { container, unmount } = mount(ctx, fileTab)
try {
// The initial load ran once; settle the async fsRead.
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(1)
const refresh = refreshButton(container)
expect(refresh).not.toBeNull()
click(refresh!)
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(2)
} finally {
unmount()
}
})
it('B: returning from edit to preview reloads (fresh content shows after save)', async () => {
const { ctx, fileTab } = setup('edit')
const { container, unmount } = mount(ctx, fileTab)
try {
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(1)
const preview = Array.from(container.querySelectorAll('button'))
.find(button => button.textContent === 'Preview')!
click(preview)
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(2)
} finally {
unmount()
}
})
it('B: a dirty draft suppresses the edit→preview reload (the draft would be dropped)', async () => {
const { ctx, fileTab } = setup('edit', true)
const { container, unmount } = mount(ctx, fileTab)
try {
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(1)
const preview = Array.from(container.querySelectorAll('button'))
.find(button => button.textContent === 'Preview')!
click(preview)
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(1)
} finally {
unmount()
}
})
it('C: a preview-mode save reloads on the saved edge', async () => {
const { ctx, fileTab } = setup('preview')
const { container, unmount } = mount(ctx, fileTab)
try {
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(1)
const save = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement
click(save)
await act(async () => { await Promise.resolve() })
expect(reads()).toBe(2)
} finally {
unmount()
}
})
})