fix(markdown): use manual preview refresh

This commit is contained in:
CI Test
2026-08-19 22:05:55 +08:00
parent c0e41f4b0d
commit 23c315ed82
8 changed files with 34 additions and 144 deletions
+3 -89
View File
@@ -27,7 +27,7 @@ import { createElement } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { api, mediaUrl, type FsFileVersion, type SessionScope } from './api.ts'
import { api, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx'
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
import { baseName } from './FileTree.tsx'
@@ -48,8 +48,6 @@ type EditorLoad =
/** The docked tree panel's width bounds (drag-resize clamps into them). */
const TREE_WIDTH_DEFAULT = 240
/** Poll Markdown files once per second while their visible preview is open. */
const MARKDOWN_POLL_MS = 1000
const TREE_WIDTH_MIN = 160
const TREE_WIDTH_MAX = 480
@@ -94,23 +92,13 @@ export function EditorHost(props: {
expanded: string[]
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
/** Whether this editor tab is currently visible in an open panel. */
visible?: boolean
}) {
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile, visible = true } = props
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
const path = tab.path ?? ''
const title = tab.title
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
/** Bump to explicitly or automatically re-read the current file. */
/** Bump to explicitly re-read the current file. */
const [reloadTick, setReloadTick] = useState(0)
/** True when the disk copy changed while the editor has unsaved edits. */
const [externalChanged, setExternalChanged] = useState(false)
/** The last file version successfully loaded into the viewer. */
const loadedVersionRef = useRef<FsFileVersion | undefined>(undefined)
/** A changed version already noticed and scheduled for a reload. */
const pendingVersionRef = useRef<FsFileVersion | undefined>(undefined)
/** Prevent overlapping polling reads when the host is slow. */
const pollingRef = useRef(false)
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
// reload. The snapshot is the bare boolean so unrelated store churn never
@@ -205,15 +193,6 @@ export function EditorHost(props: {
if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth })
}
// A path/session switch starts a new observation baseline. A reloadTick does
// not clear this before the new read finishes, otherwise the polling loop
// could schedule the same reload repeatedly while the request is in flight.
useEffect(() => {
loadedVersionRef.current = undefined
pendingVersionRef.current = undefined
setExternalChanged(false)
}, [scope.sessionId, scope.cwd, path, showEmpty])
useEffect(() => {
// A (re)load or a path-less tab clears any hoisted toolbar state — the
// fresh viewer re-registers its own.
@@ -235,8 +214,6 @@ export function EditorHost(props: {
setLoad({ status: 'binary' })
return
case 'render':
pendingVersionRef.current = undefined
setExternalChanged(false)
setLoad({
status: 'ready',
viewer: action.viewer,
@@ -258,7 +235,6 @@ export function EditorHost(props: {
case 'fetchFsRead':
api.fsRead(scope, path).then((result) => {
if (cancelled) return
if (result.version !== undefined) loadedVersionRef.current = result.version
// Binary reads carry the head bytes for the detect re-match.
const outcome = planFsReadOutcome(action.viewer, {
binary: result.kind === 'binary',
@@ -278,63 +254,6 @@ export function EditorHost(props: {
return () => { cancelled = true; controller.abort() }
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty, reloadTick])
// Markdown preview freshness: read the current file once per second while
// this tab is visible. This intentionally uses the existing fs.stat route
// instead of relying on fs.watch, which is inconsistent on WSL/network
// filesystems. The manual button below remains available if polling fails.
useEffect(() => {
if (!visible || showEmpty || path === '') return
if (ctx.betterSidebar?.matchFileViewer(path)?.id !== 'markdown') return
let cancelled = false
const check = async (): Promise<void> => {
if (cancelled || pollingRef.current || loadedVersionRef.current === undefined) return
pollingRef.current = true
try {
const result = await api.fsStat(scope, path)
if (cancelled) return
const loadedVersion = loadedVersionRef.current
if (loadedVersion === undefined) return
const changed = result.version.mtimeMs !== loadedVersion.mtimeMs || result.version.size !== loadedVersion.size
if (!changed) {
pendingVersionRef.current = undefined
if (toolbarRef.current?.dirty !== true) setExternalChanged(false)
return
}
const alreadyPending = pendingVersionRef.current !== undefined
&& pendingVersionRef.current.mtimeMs === result.version.mtimeMs
&& pendingVersionRef.current.size === result.version.size
pendingVersionRef.current = result.version
if (toolbarRef.current?.dirty === true) {
if (!alreadyPending) setExternalChanged(true)
return
}
if (!alreadyPending) {
setExternalChanged(false)
setReloadTick(value => value + 1)
}
} catch {
// The explicit refresh button is the fallback for transient poll errors.
} finally {
pollingRef.current = false
}
}
void check()
const timer = window.setInterval(() => { void check() }, MARKDOWN_POLL_MS)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [visible, showEmpty, path, scope.sessionId, scope.cwd, ctx, toolbar?.mode])
// If a disk change was detected while the editor was dirty, keep the draft
// in place. Once it becomes clean (for example after saving), follow the
// still-pending disk version exactly once.
useEffect(() => {
if (!externalChanged || toolbar?.dirty === true) return
setExternalChanged(false)
setReloadTick(value => value + 1)
}, [externalChanged, toolbar?.dirty])
const refreshFile = (): void => {
if (toolbarRef.current?.dirty === true) {
const confirmed = typeof window.confirm === 'function'
@@ -342,8 +261,6 @@ export function EditorHost(props: {
: false
if (!confirmed) return
}
pendingVersionRef.current = undefined
setExternalChanged(false)
setReloadTick(value => value + 1)
}
@@ -425,9 +342,6 @@ export function EditorHost(props: {
<IconRefreshOutline16 size={14} />
</button>
)}
{externalChanged && (
<span className={css.editorStatus}>{t('fileChanged')}</span>
)}
<button
type="button"
className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
+2 -8
View File
@@ -58,15 +58,11 @@ export interface GitLogEntry {
refs: string
}
/** File version used by the Markdown freshness poll. */
export interface FsFileVersion { mtimeMs: number; size: number }
/** Text read result. */
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean; version?: FsFileVersion }
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
/** Binary read result (no content; images load through the media route).
* `head` carries the first bytes (base64) for viewer detect sniffing. */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string; version?: FsFileVersion }
/** Lightweight file metadata response used to avoid polling full contents. */
export interface FsStatResult { version: FsFileVersion }
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
/**
* One jobs.output response: the output the MODEL has read so far for the
@@ -143,8 +139,6 @@ export const api = {
* side panel's search box); matches are cwd-relative '/'-separated paths. */
fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) =>
call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal),
fsStat: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<FsStatResult>('fs.stat', scopePayload(scope, { path }), signal),
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) =>
+1 -2
View File
@@ -111,7 +111,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
],
}],
},
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile, visible }) => (
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => (
<EditorHost
ctx={ctx}
store={store}
@@ -120,7 +120,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
expanded={expanded ?? []}
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
visible={visible}
/>
),
},
-2
View File
@@ -55,7 +55,6 @@ export const zh = {
mermaidZoomReset: '重置',
mermaidZoomHint: '滚轮缩放 · 拖拽平移 · Esc 关闭',
refresh: '刷新',
fileChanged: '文件已更新',
refreshUnsavedConfirm: '文件已在磁盘更新,刷新将丢弃未保存编辑。继续吗?',
save: '保存',
saved: '已保存',
@@ -304,7 +303,6 @@ export const en: Record<keyof typeof zh, string> = {
mermaidZoomReset: 'Reset',
mermaidZoomHint: 'Scroll to zoom · drag to pan · Esc to close',
refresh: 'Refresh',
fileChanged: 'File changed on disk',
refreshUnsavedConfirm: 'The file changed on disk. Refreshing will discard unsaved edits. Continue?',
save: 'Save',
saved: 'Saved',