fix(markdown): refresh previews after external changes

This commit is contained in:
CI Test
2026-08-19 12:39:55 +08:00
parent 4a407d78b8
commit c0e41f4b0d
9 changed files with 336 additions and 15 deletions
+1 -1
View File
@@ -216,7 +216,7 @@ pnpm watch # tsdown --watch
## ⚠️ 已知限制
- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截
- Git 无 push/pull/fetch;Markdown 预览通过 1 秒版本轮询自动刷新(编辑中不覆盖未保存内容),其他文件仍需手动刷新;暂未使用 fs.watch 推送;工具行内文件打开按钮不可拦截
- 终端 Tab 拖到另一分栏会重挂载(shell 重开)
- Office 三件套预览(.docx/.xlsx/.pptx)已移至「推荐插件」(Office 预览插件,见设置页「添加插件」弹窗);未安装时此类文件走代码/下载查看兜底
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
+1 -1
View File
@@ -247,7 +247,7 @@ pnpm watch # tsdown --watch
## ⚠️ Known Limitations
- Git has no push/pull/fetch; no file watcher (manual refresh); tool inline file-open buttons cannot be intercepted
- Git has no push/pull/fetch; Markdown previews auto-refresh through a 1-second version poll (without overwriting unsaved edits), other files still require manual refresh; no fs.watch push channel yet; tool inline file-open buttons cannot be intercepted
- Dragging a terminal tab to another pane remounts it (shell restarts)
- Office-suite preview (.docx/.xlsx/.pptx) moved to the recommended office plugin (see the "Add plugins" modals in settings); without it these files fall through to the code/download fallbacks
- Browser sandbox has no login state / third-party cookies are restricted; some sites need popup login; sites that refuse embedding via `X-Frame-Options`/`frame-ancestors` (e.g. arxiv.org) show a reason panel (with "Open in browser"); in-iframe navigation does not enter the back stack
+117 -4
View File
@@ -25,9 +25,9 @@
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, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { api, mediaUrl, type SessionScope } from './api.ts'
import { api, mediaUrl, type FsFileVersion, 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,6 +48,8 @@ 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
@@ -92,11 +94,23 @@ 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 } = props
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile, visible = true } = 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. */
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
@@ -153,8 +167,10 @@ export function EditorHost(props: {
// its state and registers its commands (both null/absent for viewers
// without a toolbar — image, pdf, binary download).
const [toolbar, setToolbar] = useState<EditorToolbarState | null>(null)
const toolbarRef = useRef<EditorToolbarState | null>(null)
const controlsRef = useRef<EditorToolbarControls | null>(null)
const onToolbarState = useCallback((next: EditorToolbarState) => {
toolbarRef.current = next
setToolbar(prev => prev !== null && JSON.stringify(prev) === JSON.stringify(next) ? prev : next)
}, [])
const onToolbarControls = useCallback((controls: EditorToolbarControls | null) => {
@@ -189,9 +205,19 @@ 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.
toolbarRef.current = null
setToolbar(null)
// The seeded home tab (no path) never loads a viewer — the empty-state
// hint renders until the user picks a file.
@@ -209,6 +235,8 @@ export function EditorHost(props: {
setLoad({ status: 'binary' })
return
case 'render':
pendingVersionRef.current = undefined
setExternalChanged(false)
setLoad({
status: 'ready',
viewer: action.viewer,
@@ -230,6 +258,7 @@ 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',
@@ -247,7 +276,76 @@ 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, 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'
? window.confirm(t('refreshUnsavedConfirm'))
: false
if (!confirmed) return
}
pendingVersionRef.current = undefined
setExternalChanged(false)
setReloadTick(value => value + 1)
}
const treeOpen = treeOpenOf(tab)
/** Persist the panel flag on the tab (survives reloads with the layout). */
@@ -256,6 +354,7 @@ export function EditorHost(props: {
: toolbar.saveState === 'saving' ? t('loading')
: toolbar.saveState === 'saved' ? t('saved')
: toolbar.saveState === 'failed' ? t('saveFailed') : ''
const markdownFile = !showEmpty && ctx.betterSidebar?.matchFileViewer(path)?.id === 'markdown'
// Split mode: the path-less window IS the standalone explorer — the tree
// panel fills the whole tab (search + FileTree, full form), no editor
@@ -315,6 +414,20 @@ export function EditorHost(props: {
{saveLabel !== '' && (
<span className={clsx(css.editorStatus, toolbar?.saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>
)}
{markdownFile && (
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={refreshFile}
>
<IconRefreshOutline16 size={14} />
</button>
)}
{externalChanged && (
<span className={css.editorStatus}>{t('fileChanged')}</span>
)}
<button
type="button"
className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
+7 -2
View File
@@ -68,6 +68,8 @@ export function TextEditor(props: FileViewerProps) {
const [mode, setMode] = useState<ViewMode>('preview')
/** The editor's current text (null while clean); preview renders this. */
const [draft, setDraft] = useState<string | null>(null)
/** Latest source known to be saved on disk; avoids reverting preview after save. */
const [savedContent, setSavedContent] = useState<string | undefined>(content)
const [dirty, setDirty] = useState(false)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
const hostRef = useRef<HTMLDivElement>(null)
@@ -114,6 +116,7 @@ export function TextEditor(props: FileViewerProps) {
useEffect(() => {
setMode('preview')
setDraft(null)
setSavedContent(content)
setDirty(false)
setSaveState('idle')
hidePopup()
@@ -237,8 +240,10 @@ export function TextEditor(props: FileViewerProps) {
if (view === null || savingRef.current) return
savingRef.current = true
setSaveState('saving')
api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
const nextContent = view.state.doc.toString()
api.fsWrite(scope, path, nextContent).then(() => {
savingRef.current = false
setSavedContent(nextContent)
setDraft(null)
setDirty(false)
setSaveState('saved')
@@ -251,7 +256,7 @@ export function TextEditor(props: FileViewerProps) {
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
/** The markdown source the preview renders (draft wins over saved content). */
const mdText = draft ?? content ?? ''
const mdText = draft ?? savedContent ?? content ?? ''
/** md/mermaid block split for the preview (mermaid fences lift out). Split
* only in preview mode: edit-mode keystrokes must not re-scan the source. */
const mdBlocks = useMemo(
+8 -2
View File
@@ -58,11 +58,15 @@ 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 }
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean; version?: FsFileVersion }
/** 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 }
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 }
/**
* One jobs.output response: the output the MODEL has read so far for the
@@ -139,6 +143,8 @@ 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) =>
+2 -1
View File
@@ -111,7 +111,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
],
}],
},
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => (
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile, visible }) => (
<EditorHost
ctx={ctx}
store={store}
@@ -120,6 +120,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
expanded={expanded ?? []}
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
visible={visible}
/>
),
},
+4
View File
@@ -55,6 +55,8 @@ export const zh = {
mermaidZoomReset: '重置',
mermaidZoomHint: '滚轮缩放 · 拖拽平移 · Esc 关闭',
refresh: '刷新',
fileChanged: '文件已更新',
refreshUnsavedConfirm: '文件已在磁盘更新,刷新将丢弃未保存编辑。继续吗?',
save: '保存',
saved: '已保存',
unsaved: '未保存',
@@ -302,6 +304,8 @@ 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',
unsaved: 'Unsaved',
+22 -4
View File
@@ -139,6 +139,7 @@ async function readText(path: string, readLimit: number): Promise<{
truncated: boolean
binary: boolean
size: number
mtimeMs: number
head?: string
}> {
const info = await stat(path).catch((error: unknown) => {
@@ -160,7 +161,14 @@ async function readText(path: string, readLimit: number): Promise<{
const head = binary
? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64')
: undefined
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head }
return {
content: binary ? '' : slice.toString('utf8'),
truncated,
binary,
size,
mtimeMs: info.mtimeMs,
head,
}
} finally {
await handle.close()
}
@@ -231,14 +239,24 @@ function buildApi(
const query = requireString(payload, 'query')
return searchFiles(cwd, query)
},
'fs.stat': async (payload) => {
const { cwd } = cwdOf(payload)
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
const info = await stat(path).catch((error: unknown) => {
throw new SidebarError('fs-error', `cannot stat "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
})
if (info.isDirectory()) throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
return { version: { mtimeMs: info.mtimeMs, size: info.size } }
},
'fs.read': async (payload) => {
const { cwd } = cwdOf(payload)
// Relative paths are git-derived (status/diff report repo-root-relative
// names; the untracked diff view reads the file through this route).
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
if (binary) return { kind: 'binary', size, truncated, head }
return { kind: 'text', content, truncated }
const { content, truncated, binary, size, mtimeMs, head } = await readText(path, resolved.readLimit)
const version = { mtimeMs, size }
if (binary) return { kind: 'binary', size, truncated, head, version }
return { kind: 'text', content, truncated, version }
},
'fs.write': async (payload) => {
const { cwd } = cwdOf(payload)
+174
View File
@@ -0,0 +1,174 @@
/**
* Markdown freshness behavior: clean previews follow an external edit, while
* dirty previews keep the user's draft until an explicit confirmed refresh.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement, useEffect } 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 { api } from '../src/client/api.ts'
import { createBetterSidebarService, type FileViewerProps } from '../src/client/service.ts'
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
function toolbar(dirty: boolean) {
return {
modes: true,
mode: 'preview' as const,
dirty,
editable: true,
saveState: 'idle' as const,
}
}
let markDirty: (() => void) | undefined
/** A tiny viewer that exposes the host toolbar and renders the loaded source. */
function FakeMarkdownViewer(props: FileViewerProps) {
useEffect(() => {
props.onToolbarControls?.({ setMode: () => {}, save: () => {} })
props.onToolbarState?.(toolbar(false))
markDirty = () => { props.onToolbarState?.(toolbar(true)) }
return () => {
markDirty = undefined
props.onToolbarControls?.(null)
}
}, [props.onToolbarControls, props.onToolbarState])
return createElement('div', { 'data-testid': 'markdown-content' }, props.content ?? '')
}
function setup(): {
store: ReturnType<typeof createSidebarStore>
ctx: Context
tab: SidebarTab
} {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
service.registerFileViewer({
id: 'markdown',
exts: ['md'],
fetchStrategy: 'fsRead',
component: FakeMarkdownViewer,
})
store.setSession('markdown-refresh-session')
const home = allLeaves(store.getSnapshot().state!.splits)
.flatMap(leaf => leaf.tabs)
.find(candidate => candidate.type === 'editor')!
const tab: SidebarTab = { ...home, path: '/tmp/notes.md', title: 'notes.md' }
const ctx = { betterSidebar: service } as unknown as Context
return { store, ctx, tab }
}
function mount(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab: SidebarTab) {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => {
root.render(createElement(EditorHost, {
ctx,
store,
scope: { sessionId: 'markdown-refresh-session', cwd: '/tmp' },
tab,
expanded: [],
onToggleDir: () => {},
onReferenceFile: () => {},
visible: true,
}))
})
return {
container,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
afterEach(() => {
markDirty = undefined
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('Markdown external refresh', () => {
it('reloads a clean visible preview after the file version changes', async () => {
vi.useFakeTimers()
let disk = { content: 'before', version: { mtimeMs: 1, size: 6 } }
const read = vi.spyOn(api, 'fsRead').mockImplementation(async () => ({
kind: 'text', content: disk.content, truncated: false, version: disk.version,
}))
const stat = vi.spyOn(api, 'fsStat').mockImplementation(async () => ({ version: disk.version }))
const { store, ctx, tab } = setup()
const view = mount(ctx, store, tab)
try {
await flush()
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
const initialReads = read.mock.calls.length
disk = { content: 'after', version: { mtimeMs: 2, size: 5 } }
await act(async () => {
vi.advanceTimersByTime(1000)
await Promise.resolve()
await Promise.resolve()
})
expect(stat).toHaveBeenCalled()
expect(read.mock.calls.length).toBeGreaterThan(initialReads)
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('after')
} finally {
view.unmount()
}
})
it('keeps dirty content and requires confirmation before manual refresh', async () => {
vi.useFakeTimers()
let disk = { content: 'before', version: { mtimeMs: 1, size: 6 } }
const read = vi.spyOn(api, 'fsRead').mockImplementation(async () => ({
kind: 'text', content: disk.content, truncated: false, version: disk.version,
}))
vi.spyOn(api, 'fsStat').mockImplementation(async () => ({ version: disk.version }))
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
const { store, ctx, tab } = setup()
const view = mount(ctx, store, tab)
try {
await flush()
expect(markDirty).toBeDefined()
act(() => { markDirty?.() })
await flush()
const before = read.mock.calls.length
disk = { content: 'external', version: { mtimeMs: 2, size: 8 } }
await act(async () => {
vi.advanceTimersByTime(1000)
await Promise.resolve()
await Promise.resolve()
})
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
expect(view.container.textContent).toMatch(/文件已更新|File changed on disk/)
const refresh = view.container.querySelector<HTMLButtonElement>('button[aria-label="刷新"], button[aria-label="Refresh"]')
expect(refresh).not.toBeNull()
act(() => { refresh!.click() })
await flush()
expect(confirm).toHaveBeenCalled()
expect(read.mock.calls.length).toBe(before)
confirm.mockReturnValue(true)
act(() => { refresh!.click() })
await flush()
expect(read.mock.calls.length).toBeGreaterThan(before)
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('external')
} finally {
view.unmount()
}
})
})